Management UI
CI / ci (push) Canceled after 0s

Attempt 2 of a management ui.
This commit is contained in:
Stefan Lazic committed 2026-09-05 13:39:55 +01:00
1 parent 9fe5b4f038
commit a0d39026c0
54 files changed
+8804 -267

No files matched your search

@@ -0,0 +1,42 @@
"use server";
import { revalidatePath } from "next/cache";
import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker";
import { isValidSlug, stackDir, stackExists } from "@/lib/stacks";
async function assertStack(slug: string): Promise<string> {
if (!isValidSlug(slug) || !(await stackExists(slug))) {
throw new Error(`No such stack: ${slug}`);
}
return stackDir(slug);
}
export async function startStackAction(slug: string): Promise<void> {
const dir = await assertStack(slug);
await composeUp(dir);
revalidatePath(`/dashboard/${slug}`);
}
export async function stopStackAction(slug: string): Promise<void> {
const dir = await assertStack(slug);
await composeDown(dir);
revalidatePath(`/dashboard/${slug}`);
}
export async function restartStackAction(slug: string): Promise<void> {
const dir = await assertStack(slug);
await composeRestart(dir);
revalidatePath(`/dashboard/${slug}`);
}
/** Recreates the container against whatever the shared image tag currently points to (--force-recreate) — not a source rebuild. See manager/.env.example's NEXTCHAIR_IMAGE comment for the actual rebuild step. */
export async function updateStackAction(slug: string): Promise<void> {
const dir = await assertStack(slug);
await composeUp(dir, { forceRecreate: true });
revalidatePath(`/dashboard/${slug}`);
}
export async function fetchLogsAction(slug: string): Promise<string> {
const dir = await assertStack(slug);
return composeLogs(dir, 200);
}
@@ -0,0 +1,94 @@
"use client";
import { useState, useTransition } from "react";
import {
fetchLogsAction,
restartStackAction,
startStackAction,
stopStackAction,
updateStackAction,
} from "./actions";
export function BusinessActions({ slug }: { slug: string }) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [logs, setLogs] = useState<string | null>(null);
const run = (action: (slug: string) => Promise<void>) => {
setError(null);
startTransition(async () => {
try {
await action(slug);
} catch (e) {
setError(e instanceof Error ? e.message : "Action failed.");
}
});
};
const loadLogs = () => {
setError(null);
startTransition(async () => {
try {
setLogs(await fetchLogsAction(slug));
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load logs.");
}
});
};
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={isPending}
onClick={() => run(startStackAction)}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
>
Start
</button>
<button
type="button"
disabled={isPending}
onClick={() => run(stopStackAction)}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
>
Stop
</button>
<button
type="button"
disabled={isPending}
onClick={() => run(restartStackAction)}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
>
Restart
</button>
<button
type="button"
disabled={isPending}
onClick={() => run(updateStackAction)}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
title="Recreates the container against the current shared image tag — not a source rebuild."
>
Update
</button>
<button
type="button"
disabled={isPending}
onClick={loadLogs}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
>
View logs
</button>
</div>
{error && (
<p role="alert" className="text-sm font-medium text-danger">
{error}
</p>
)}
{logs !== null && (
<pre className="max-h-96 overflow-auto rounded-md bg-ink p-4 text-xs text-white">{logs}</pre>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { notFound } from "next/navigation";
import { listBusinessStacks } from "@/lib/stacks";
import { BusinessActions } from "./business-actions";
export const dynamic = "force-dynamic";
export default async function BusinessDetailPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ justCreated?: string }>;
}) {
const { slug } = await params;
const { justCreated } = await searchParams;
const stacks = await listBusinessStacks();
const stack = stacks.find((s) => s.slug === slug);
if (!stack) notFound();
return (
<div className="space-y-6">
{justCreated && (
<div className="rounded-md border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-accent">
Business created and started. Visit{" "}
<a href={`https://${stack.domain}/install`} target="_blank" rel="noreferrer" className="font-semibold underline">
https://{stack.domain}/install
</a>{" "}
to finish setting it up.
</div>
)}
<div>
<h1 className="text-xl font-bold">{stack.slug}</h1>
<p className="text-sm text-muted">{stack.domain ?? "no domain set"}</p>
</div>
<BusinessActions slug={stack.slug} />
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import Link from "next/link";
import { logoutAction } from "./logout-action";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const dockgeUrl = process.env.DOCKGE_URL;
return (
<div className="min-h-screen">
<header className="flex items-center justify-between border-b border-line bg-surface px-6 py-4">
<Link href="/dashboard" className="text-sm font-bold">
NextChair Manager
</Link>
<div className="flex items-center gap-4 text-sm">
{dockgeUrl && (
<a href={dockgeUrl} target="_blank" rel="noreferrer" className="text-muted hover:text-ink">
Dockge ↗
</a>
)}
<form action={logoutAction}>
<button type="submit" className="text-muted hover:text-ink">
Sign out
</button>
</form>
</div>
</header>
<main className="mx-auto max-w-4xl px-6 py-8">{children}</main>
</div>
);
}
@@ -0,0 +1,11 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { SESSION_COOKIE_NAME } from "@/lib/session";
export async function logoutAction(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
redirect("/login");
}
+22
View File
@@ -0,0 +1,22 @@
"use server";
import { redirect } from "next/navigation";
import { provisionBusiness } from "@/lib/provisioning";
export interface AddBusinessState {
error: string | null;
}
export async function addBusinessAction(_prevState: AddBusinessState, formData: FormData): Promise<AddBusinessState> {
const slug = String(formData.get("slug") ?? "");
const domain = String(formData.get("domain") ?? "");
let result: { slug: string };
try {
result = await provisionBusiness({ slug, domain });
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to provision this business." };
}
redirect(`/dashboard/${result.slug}?justCreated=1`);
}
@@ -0,0 +1,82 @@
"use client";
import { useActionState, useState } from "react";
import { suggestSlug } from "@/lib/slug";
import { addBusinessAction, type AddBusinessState } from "./actions";
const initialState: AddBusinessState = { error: null };
export function AddBusinessForm() {
const [state, formAction, isPending] = useActionState(addBusinessAction, initialState);
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-muted">
Business name
</label>
<input
id="name"
name="name"
type="text"
required
onChange={(e) => {
if (!slugTouched) setSlug(suggestSlug(e.target.value));
}}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="slug" className="block text-sm font-medium text-muted">
Slug
</label>
<input
id="slug"
name="slug"
type="text"
required
value={slug}
onChange={(e) => {
setSlugTouched(true);
setSlug(e.target.value);
}}
className="mt-1 w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
/>
<p className="mt-1 text-xs text-muted">
Used for the container name, database name, and stack directory — lowercase letters, numbers, and hyphens
only.
</p>
</div>
<div>
<label htmlFor="domain" className="block text-sm font-medium text-muted">
Domain
</label>
<input
id="domain"
name="domain"
type="text"
required
placeholder="business.example.com"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">
Must already point at this server&rsquo;s IP before Caddy can issue a certificate for it.
</p>
</div>
{state.error && (
<p role="alert" className="text-sm font-medium text-danger">
{state.error}
</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full rounded-md bg-ink py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isPending ? "Provisioning…" : "Create Business"}
</button>
</form>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { AddBusinessForm } from "./add-business-form";
export default function NewBusinessPage() {
return (
<div className="max-w-lg space-y-6">
<div>
<h1 className="text-xl font-bold">Add Business</h1>
<p className="text-sm text-muted">
Creates the database, generates secrets, writes the compose stack and Caddy site, and starts the
container. Takes a few seconds — the button stays disabled until it&rsquo;s done.
</p>
</div>
<AddBusinessForm />
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import Link from "next/link";
import { listBusinessStacks } from "@/lib/stacks";
export const dynamic = "force-dynamic";
function StatusPill({ status }: { status: "running" | "stopped" | "unknown" }) {
const styles = {
running: "bg-accent/10 text-accent",
stopped: "bg-danger/10 text-danger",
unknown: "bg-line text-muted",
} as const;
return <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${styles[status]}`}>{status}</span>;
}
export default async function DashboardPage() {
const stacks = await listBusinessStacks();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">Businesses</h1>
<Link
href="/dashboard/new"
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white"
>
Add Business
</Link>
</div>
{stacks.length === 0 ? (
<p className="text-sm text-muted">No businesses provisioned yet.</p>
) : (
<ul className="divide-y divide-line rounded-lg border border-line bg-surface">
{stacks.map((stack) => (
<li key={stack.slug}>
<Link
href={`/dashboard/${stack.slug}`}
className="flex items-center justify-between px-4 py-3 hover:bg-stone"
>
<div>
<p className="text-sm font-semibold">{stack.slug}</p>
<p className="text-xs text-muted">{stack.domain ?? "no domain set"}</p>
</div>
<StatusPill status={stack.status} />
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
@import "tailwindcss";
@theme {
--color-ink: #111b15;
--color-stone: #f4f6f4;
--color-surface: #ffffff;
--color-muted: #5b665f;
--color-line: #dfe3df;
--color-accent: #1f7a4d;
--color-danger: #b3261e;
}
body {
background-color: var(--color-stone);
color: var(--color-ink);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "NextChair Manager",
description: "Platform-operator tool for provisioning NextChair business deployments.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { verifyManagerCredentials } from "@/lib/auth";
import { createSessionToken, isSecureDeployment, SESSION_COOKIE_NAME } from "@/lib/session";
export interface LoginState {
error: string | null;
}
export async function loginAction(_prevState: LoginState, formData: FormData): Promise<LoginState> {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
if (!email || !password) {
return { error: "Email and password are required." };
}
const valid = await verifyManagerCredentials(email, password);
if (!valid) {
return { error: "Incorrect email or password." };
}
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, createSessionToken(email), {
httpOnly: true,
secure: isSecureDeployment(),
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60,
});
redirect("/dashboard");
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useActionState } from "react";
import { loginAction, type LoginState } from "./actions";
const initialState: LoginState = { error: null };
export function LoginForm() {
const [state, formAction, isPending] = useActionState(loginAction, initialState);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-muted">
Email
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="username"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-muted">
Password
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
{state.error && (
<p role="alert" className="text-sm font-medium text-danger">
{state.error}
</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full rounded-md bg-ink py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isPending ? "Signing in…" : "Sign in"}
</button>
</form>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { LoginForm } from "./login-form";
export default function LoginPage() {
return (
<main className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm space-y-6 rounded-lg border border-line bg-surface p-8 shadow-sm">
<div>
<h1 className="text-lg font-bold">NextChair Manager</h1>
<p className="text-sm text-muted">Platform-operator sign in.</p>
</div>
<LoginForm />
</div>
</main>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/dashboard");
}
+30
View File
@@ -0,0 +1,30 @@
import bcrypt from "bcryptjs";
/**
* A single, dedicated platform-operator credential via env vars — not
* tied to any one business's own Super Administrator (each business has
* its own separate database; this tool has no "primary" one of its own,
* so there's no single business account it could sensibly check against).
* Bcrypt cost 12 and the dummy-hash timing-safe comparison mirror the
* main app's own convention (see scripts/ensure-super-admin.ts and
* src/auth.ts) — copied, not imported: this app never imports from the
* main app's src/ at all (see session.ts's header for why).
*/
// A real bcrypt hash of an unguessable value, used only so a request for
// a wrong/missing MANAGER_EMAIL still pays the same bcrypt.compare cost
// as a wrong password — otherwise "no such email" and "wrong password"
// are distinguishable by response time.
const DUMMY_HASH = bcrypt.hashSync("nextchair-manager-dummy-hash", 12);
export async function verifyManagerCredentials(email: string, password: string): Promise<boolean> {
const expectedEmail = process.env.MANAGER_EMAIL?.trim().toLowerCase();
const expectedHash = process.env.MANAGER_PASSWORD_HASH;
if (!expectedEmail || !expectedHash) {
throw new Error("MANAGER_EMAIL and MANAGER_PASSWORD_HASH must both be set.");
}
const matchesEmail = email.trim().toLowerCase() === expectedEmail;
const valid = await bcrypt.compare(password, matchesEmail ? expectedHash : DUMMY_HASH);
return matchesEmail && valid;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Generates the exact same shape as stacks/blake-heritage/compose.yaml
* and stacks/marios/compose.yaml (the two hand-written example stacks) —
* keep these in sync if that shape changes.
*/
export function generateComposeYaml(slug: string): string {
const containerName = `next-chair-${slug}`;
const volumeName = `next-chair-media-${slug}`;
return `name: ${containerName}
# Generated by the management UI — see stacks/blake-heritage/compose.yaml
# for the hand-written original this mirrors.
services:
${containerName}:
image: \${NEXTCHAIR_IMAGE:-nextchair-app:latest}
container_name: ${containerName}
restart: unless-stopped
env_file:
- .env
volumes:
- ${volumeName}:/app/storage/media
healthcheck:
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks:
- next-chair-edge
volumes:
${volumeName}:
name: ${volumeName}
networks:
next-chair-edge:
name: next-chair-edge
external: true
`;
}
export interface BusinessEnvValues {
domain: string;
databaseName: string;
databaseUser: string;
databasePassword: string;
authSecret: string;
installationToken: string;
cronSecret: string;
}
/** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */
export function generateBusinessEnv(values: BusinessEnvValues): string {
return `# Generated by the management UI. See .env.business.example for what
# each of these does.
DOMAIN="${values.domain}"
DATABASE_HOST="next-chair-mysql"
DATABASE_PORT="3306"
DATABASE_NAME="${values.databaseName}"
DATABASE_USER="${values.databaseUser}"
DATABASE_PASSWORD="${values.databasePassword}"
AUTH_SECRET="${values.authSecret}"
AUTH_TRUST_HOST="true"
NEXT_PUBLIC_APP_URL="https://${values.domain}"
INSTALLATION_TOKEN="${values.installationToken}"
CRON_SECRET="${values.cronSecret}"
SUPER_ADMIN_EMAIL=""
SUPER_ADMIN_PASSWORD=""
SUPER_ADMIN_FIRST_NAME=""
SUPER_ADMIN_LAST_NAME=""
AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET=""
GOOGLE_CALENDAR_CLIENT_ID=""
GOOGLE_CALENDAR_CLIENT_SECRET=""
GOOGLE_CALENDAR_REDIRECT_URI="https://${values.domain}/api/staff/calendar/google/callback"
CALENDAR_TOKEN_ENCRYPTION_KEY=""
MEDIA_MAX_UPLOAD_BYTES="5000000"
GOOGLE_PLACES_API_KEY=""
EMAIL_PROVIDER="console"
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
EMAIL_SMTP_PORT="587"
EMAIL_SMTP_USERNAME=""
EMAIL_SMTP_PASSWORD=""
EMAIL_FROM_ADDRESS=""
EMAIL_FROM_NAME=""
PUSH_PROVIDER="console"
VAPID_PUBLIC_KEY=""
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
VAPID_PRIVATE_KEY=""
VAPID_SUBJECT="mailto:owner@example.com"
`;
}
export function generateCaddySite(slug: string, domain: string): string {
return `${domain} {
reverse_proxy next-chair-${slug}:3000
}
`;
}
+90
View File
@@ -0,0 +1,90 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Every call here runs `docker compose` with `cwd` set to a stack's own
* directory (stacks/<slug>/) rather than passing explicit -f/--env-file
* flags — compose already auto-loads `compose.yaml` and `.env` from the
* current directory, and this is the same convention Dockge itself uses,
* so a stack behaves identically whether it's started by this app, by
* Dockge, or by hand from the command line.
*
* This app's own container needs the Docker socket mounted and the
* `docker compose` CLI installed (see manager/Dockerfile) — these calls
* are executed by the HOST's dockerd via that socket, not by a nested
* Docker daemon inside this container.
*/
export interface DockerCommandResult {
stdout: string;
stderr: string;
}
async function runDockerCompose(stackDir: string, args: string[]): Promise<DockerCommandResult> {
const result = await execFileAsync("docker", ["compose", ...args], {
cwd: stackDir,
maxBuffer: 10 * 1024 * 1024,
});
return result;
}
export async function composeUp(stackDir: string, options: { forceRecreate?: boolean } = {}): Promise<void> {
const args = ["up", "-d"];
if (options.forceRecreate) args.push("--force-recreate");
await runDockerCompose(stackDir, args);
}
export async function composeDown(stackDir: string): Promise<void> {
await runDockerCompose(stackDir, ["down"]);
}
export async function composeRestart(stackDir: string): Promise<void> {
await runDockerCompose(stackDir, ["restart"]);
}
export async function composeLogs(stackDir: string, tailLines = 200): Promise<string> {
const result = await runDockerCompose(stackDir, ["logs", "--no-color", "--tail", String(tailLines)]);
// Compose writes container logs to stderr, not stdout — this is normal
// (docker's own `logs` command does the same), not a failure signal.
return result.stdout + result.stderr;
}
export type StackRunState = "running" | "stopped" | "unknown";
/** Best-effort — a stack that's never been started at all reports "stopped", not an error. */
export async function composeStatus(stackDir: string): Promise<StackRunState> {
try {
const result = await runDockerCompose(stackDir, ["ps", "--status", "running", "--format", "json"]);
return result.stdout.trim().length > 0 ? "running" : "stopped";
} catch {
return "unknown";
}
}
export async function composeExec(stackDir: string, serviceName: string, command: string[]): Promise<void> {
await runDockerCompose(stackDir, ["exec", "-T", serviceName, ...command]);
}
/** Applies pending Prisma migrations inside a freshly-started business container — retries briefly since the container's Node process may take a moment to come up after `composeUp`. */
export async function runMigrations(stackDir: string, serviceName: string): Promise<void> {
const maxAttempts = 6;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await composeExec(stackDir, serviceName, ["npx", "prisma", "migrate", "deploy"]);
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
throw lastError instanceof Error ? lastError : new Error("Migrations failed after retries.");
}
/** Caddy doesn't watch its sites/ directory — adding or removing a business's .caddy file needs this to take effect. */
export async function reloadCaddy(): Promise<void> {
const containerName = process.env.CADDY_CONTAINER_NAME ?? "next-chair-caddy";
await execFileAsync("docker", ["exec", containerName, "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]);
}
+117
View File
@@ -0,0 +1,117 @@
import { randomBytes } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import mariadb from "mariadb";
import { composeUp, reloadCaddy, runMigrations } from "./docker";
import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template";
import { isValidSlug, stackDir, stackExists, stacksRoot } from "./stacks";
/** Hard safety check on top of isValidSlug (which already constrains the slug itself) — this is the one place that string ends up inside a raw SQL statement, where an identifier can't be parameter-bound the way a value can. */
const SAFE_DB_IDENTIFIER = /^[a-z][a-z0-9_]{1,48}[a-z0-9]$/;
/** Generated via randomBytes/hex below — always matches this, checked anyway as defense in depth before it goes into a SQL string literal. */
const SAFE_GENERATED_SECRET = /^[a-f0-9]+$/;
function randomHex(bytes: number): string {
return randomBytes(bytes).toString("hex");
}
export interface ProvisionBusinessInput {
slug: string;
domain: string;
}
export interface ProvisionBusinessResult {
slug: string;
domain: string;
installUrl: string;
}
async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
throw new Error("Refusing to create a database/user from an unsafe identifier.");
}
if (!SAFE_GENERATED_SECRET.test(databasePassword)) {
throw new Error("Refusing to create a user with an unexpected password format.");
}
const host = process.env.MYSQL_HOST ?? "next-chair-mysql";
const port = process.env.MYSQL_PORT ? Number(process.env.MYSQL_PORT) : 3306;
const rootPassword = process.env.MYSQL_ROOT_PASSWORD;
if (!rootPassword) throw new Error("MYSQL_ROOT_PASSWORD must be set.");
const conn = await mariadb.createConnection({
host,
port,
user: "root",
password: rootPassword,
// MySQL 8's default auth plugin (caching_sha2_password) needs to send
// the client its RSA public key to encrypt the password, and the
// mariadb driver refuses to fetch it over a plain connection unless
// told to — see the main app's src/lib/db/mysql-pool-config.ts for
// the full explanation of this exact error. Confirmed by hitting it
// live: creating a business failed with "RSA public key is not
// available client side" before this option was added here.
allowPublicKeyRetrieval: true,
});
try {
// Identifiers are backtick-quoted AND already validated against
// SAFE_DB_IDENTIFIER above — belt and braces, since a SQL identifier
// (unlike a value) can't be parameter-bound.
await conn.query(`CREATE DATABASE \`${databaseName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci`);
await conn.query(`CREATE USER '${databaseUser}'@'%' IDENTIFIED BY '${databasePassword}'`);
await conn.query(`GRANT ALL PRIVILEGES ON \`${databaseName}\`.* TO '${databaseUser}'@'%'`);
await conn.query("FLUSH PRIVILEGES");
} finally {
await conn.end();
}
}
export async function provisionBusiness(input: ProvisionBusinessInput): Promise<ProvisionBusinessResult> {
const slug = input.slug.trim().toLowerCase();
const domain = input.domain.trim().toLowerCase();
if (!isValidSlug(slug)) {
throw new Error("Slug must be lowercase letters, numbers, and hyphens, 3-50 characters, not a reserved name.");
}
if (!domain || domain.includes(" ")) {
throw new Error("A valid domain is required.");
}
if (await stackExists(slug)) {
throw new Error(`A stack named "${slug}" already exists.`);
}
const databaseIdentifier = slug.replace(/-/g, "_");
const databasePassword = randomHex(24);
const authSecret = randomHex(32);
const installationToken = randomHex(32);
const cronSecret = randomHex(32);
await createDatabaseAndUser(databaseIdentifier, databaseIdentifier, databasePassword);
const dir = stackDir(slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8");
await writeFile(
path.join(dir, ".env"),
generateBusinessEnv({
domain,
databaseName: databaseIdentifier,
databaseUser: databaseIdentifier,
databasePassword,
authSecret,
installationToken,
cronSecret,
}),
"utf8",
);
const sitesDir = path.join(stacksRoot(), "infra", "sites");
await mkdir(sitesDir, { recursive: true });
await writeFile(path.join(sitesDir, `${slug}.caddy`), generateCaddySite(slug, domain), "utf8");
await reloadCaddy();
await composeUp(dir);
await runMigrations(dir, `next-chair-${slug}`);
return { slug, domain, installUrl: `https://${domain}/install` };
}
+98
View File
@@ -0,0 +1,98 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Signed-cookie session for the manager app — adapted from (not imported
* from) the main NextChair app's src/lib/booking-draft/sign.ts and
* src/lib/security/secrets-match.ts. Deliberately copied rather than
* shared: this app must never import anything from the main app's src/ —
* a previous attempt at this exact tool was built as routes inside the
* main app and repeatedly broke from transitively importing Prisma/
* Auth.js/business data into a mode that has no business database at
* all — see ../../README.md's "Why it's a separate app" for the full
* history. Keeping this app's module graph fully separate is what makes
* that failure structurally impossible to repeat here.
*
* HMAC-signed (tamper-evident), not encrypted — the payload is just an
* email and an expiry, nothing sensitive to disclose to the browser
* holding its own cookie. Session length: 7 days.
*/
const CONTEXT = "nextchair-manager-session-v1";
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export interface ManagerSession {
email: string;
exp: number;
}
function getSigningKey(): Buffer {
const secret = process.env.MANAGER_SESSION_SECRET;
if (!secret) {
throw new Error("MANAGER_SESSION_SECRET must be set.");
}
return createHmac("sha256", secret).update(CONTEXT).digest();
}
function sign(payload: string): string {
const mac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
return `${encodedPayload}.${mac}`;
}
function verifyToken(token: string): string | null {
const separatorIndex = token.indexOf(".");
if (separatorIndex === -1) return null;
const encodedPayload = token.slice(0, separatorIndex);
const mac = token.slice(separatorIndex + 1);
if (!encodedPayload || !mac) return null;
let payload: string;
try {
payload = Buffer.from(encodedPayload, "base64url").toString("utf8");
} catch {
return null;
}
const expectedMac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
const macBuffer = Buffer.from(mac);
const expectedBuffer = Buffer.from(expectedMac);
if (macBuffer.length !== expectedBuffer.length) return null;
if (!timingSafeEqual(macBuffer, expectedBuffer)) return null;
return payload;
}
export function createSessionToken(email: string): string {
const payload: ManagerSession = { email, exp: Date.now() + SESSION_TTL_MS };
return sign(JSON.stringify(payload));
}
/** Returns the session if the token is validly signed and not expired, otherwise null. Never throws on malformed input. */
export function verifySessionToken(token: string | undefined): ManagerSession | null {
if (!token) return null;
const payload = verifyToken(token);
if (!payload) return null;
let session: ManagerSession;
try {
session = JSON.parse(payload);
} catch {
return null;
}
if (typeof session.email !== "string" || typeof session.exp !== "number") return null;
if (Date.now() > session.exp) return null;
return session;
}
/**
* Same signal/reasoning as the main app's src/lib/security/cookie-
* security.ts's isSecureDeployment() — NODE_ENV=production doesn't mean
* TLS is actually in front of this app, so the cookie's Secure attribute
* is derived from the deployer-declared public URL instead.
*/
export function isSecureDeployment(): boolean {
return (process.env.MANAGER_PUBLIC_URL ?? "").startsWith("https://");
}
export const SESSION_COOKIE_NAME = "manager_session";
+24
View File
@@ -0,0 +1,24 @@
/**
* Pure string logic, deliberately kept free of any Node-only import
* (fs, child_process) so it's safe to use from both server code
* (stacks.ts, provisioning.ts) and client components (the Add Business
* form's live slug suggestion) without pulling Node APIs into a browser
* bundle.
*/
export const RESERVED_SLUGS = new Set(["infra", "manager"]);
const SLUG_PATTERN = /^[a-z][a-z0-9-]{1,48}[a-z0-9]$/;
export function isValidSlug(slug: string): boolean {
return SLUG_PATTERN.test(slug) && !RESERVED_SLUGS.has(slug);
}
export function suggestSlug(businessName: string): string {
return businessName
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
}
+74
View File
@@ -0,0 +1,74 @@
import { readdir, readFile, access } from "node:fs/promises";
import path from "node:path";
import { composeStatus, type StackRunState } from "./docker";
import { RESERVED_SLUGS } from "./slug";
export { isValidSlug, suggestSlug } from "./slug";
export interface BusinessStackSummary {
slug: string;
domain: string | null;
containerName: string;
status: StackRunState;
}
export function stacksRoot(): string {
const dir = process.env.STACKS_DIR;
if (!dir) throw new Error("STACKS_DIR must be set.");
return dir;
}
export function stackDir(slug: string): string {
return path.join(stacksRoot(), slug);
}
/** Only ever reads the DOMAIN line — deliberately never returns the rest of a business's .env (database password, AUTH_SECRET, etc.) to the UI. */
async function readDomain(envPath: string): Promise<string | null> {
try {
const content = await readFile(envPath, "utf8");
for (const line of content.split("\n")) {
const match = /^DOMAIN\s*=\s*"?([^"\n]*)"?\s*$/.exec(line.trim());
if (match?.[1]) return match[1];
}
return null;
} catch {
return null;
}
}
export async function listBusinessStacks(): Promise<BusinessStackSummary[]> {
const root = stacksRoot();
let entries: string[];
try {
entries = await readdir(root);
} catch {
return [];
}
const stacks: BusinessStackSummary[] = [];
for (const slug of entries) {
if (RESERVED_SLUGS.has(slug)) continue;
const dir = path.join(root, slug);
const composePath = path.join(dir, "compose.yaml");
try {
await access(composePath);
} catch {
continue;
}
const domain = await readDomain(path.join(dir, ".env"));
const status = await composeStatus(dir);
stacks.push({ slug, domain, containerName: `next-chair-${slug}`, status });
}
return stacks.sort((a, b) => a.slug.localeCompare(b.slug));
}
export async function stackExists(slug: string): Promise<boolean> {
try {
await access(path.join(stackDir(slug), "compose.yaml"));
return true;
} catch {
return false;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse, type NextRequest } from "next/server";
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/session";
// Next.js 16 renamed the `middleware.ts` file convention to `proxy.ts`
// (https://nextjs.org/docs/messages/middleware-to-proxy) — it now always
// runs on the Node.js runtime, which is why plain `node:crypto` in
// session.ts works fine here without any Edge-runtime workaround.
const PUBLIC_PATHS = ["/login"];
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (PUBLIC_PATHS.some((path) => pathname.startsWith(path))) return NextResponse.next();
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
const session = verifySessionToken(token);
if (!session) {
const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
// Runs on every page and API route except static assets — this app has
// no public-facing content at all, unlike the main app's proxy.ts,
// which has to carve out installation/customer-facing exceptions.
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};