Installation
CI / ci (push) Canceled after 0s

install token now shown in dashboard until install completes.
This commit is contained in:
Stefan Lazic committed 2026-09-06 20:14:25 +01:00
1 parent d7a8b15482
commit e9e28a243f
18 files changed
+1976 -14

No files matched your search

+1079 -1
View File
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -8,7 +8,8 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@@ -30,6 +31,7 @@
"eslint": "^9.39.5", "eslint": "^9.39.5",
"eslint-config-next": "^16.2.12", "eslint-config-next": "^16.2.12",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "^6.0.3" "typescript": "^6.0.3",
"vitest": "^4.1.10"
} }
} }
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/session";
import proxy from "../proxy";
/**
* The installation-token panel (and its regenerate action) lives under
* /dashboard/[slug] like every other Manager page — it has no auth check
* of its own, relying entirely on this proxy gating every non-/login
* route (see proxy.ts's own header, and manager/README.md's "How auth
* works"). This test exists specifically because this feature is the
* first thing in this app sensitive enough that "only authenticated
* Manager users can view it" is a named requirement, not just incidental.
*/
describe("proxy — Manager auth gate", () => {
const originalSecret = process.env.MANAGER_SESSION_SECRET;
beforeEach(() => {
process.env.MANAGER_SESSION_SECRET = "test-session-secret-value";
});
afterEach(() => {
if (originalSecret === undefined) delete process.env.MANAGER_SESSION_SECRET;
else process.env.MANAGER_SESSION_SECRET = originalSecret;
});
it("redirects to /login when a business dashboard page is requested without a session cookie", () => {
const request = new NextRequest(new URL("http://localhost/dashboard/acme-barbers"));
const response = proxy(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("http://localhost/login");
});
it("redirects to /login for an invalid/tampered session cookie", () => {
const request = new NextRequest(new URL("http://localhost/dashboard/acme-barbers"), {
headers: { cookie: `${SESSION_COOKIE_NAME}=not-a-real-token` },
});
const response = proxy(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("http://localhost/login");
});
it("passes through to the business dashboard page (where the installation token lives) with a valid session cookie", () => {
const token = createSessionToken("operator@example.com");
const request = new NextRequest(new URL("http://localhost/dashboard/acme-barbers"), {
headers: { cookie: `${SESSION_COOKIE_NAME}=${token}` },
});
const response = proxy(request);
// NextResponse.next() carries no redirect / error status.
expect(response.status).toBe(200);
});
it("never gates /login itself (or a signed-out operator could never reach the sign-in form)", () => {
const request = new NextRequest(new URL("http://localhost/login"));
const response = proxy(request);
expect(response.status).toBe(200);
});
});
@@ -0,0 +1,133 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const composeUpMock = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/docker", () => ({
composeUp: (...args: unknown[]) => composeUpMock(...args),
composeDown: vi.fn(),
composeLogs: vi.fn(),
composeRestart: vi.fn(),
}));
const fetchInstallationStatusMock = vi.fn();
vi.mock("@/lib/installation-status", () => ({
fetchInstallationStatus: (...args: unknown[]) => fetchInstallationStatusMock(...args),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { regenerateInstallationTokenAction } from "../actions";
const TOKEN_HEX_PATTERN = /^[a-f0-9]{64}$/;
describe("regenerateInstallationTokenAction", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-actions-test-"));
process.env.STACKS_DIR = stacksDir;
composeUpMock.mockClear();
fetchInstallationStatusMock.mockReset();
fetchInstallationStatusMock.mockResolvedValue({ status: "not_installed", installedAt: null });
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
async function createStack(slug: string, envContent: string) {
const dir = path.join(stacksDir, slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
await writeFile(path.join(dir, ".env"), envContent, "utf8");
return dir;
}
function formData(fields: Record<string, string>): FormData {
const fd = new FormData();
for (const [key, value] of Object.entries(fields)) fd.set(key, value);
return fd;
}
it("requires confirmation on the first call — does not touch the .env or the container yet", async () => {
await createStack("acme", 'INSTALLATION_TOKEN="old-token"\n');
const result = await regenerateInstallationTokenAction("acme", { error: null, needsConfirmation: false, token: null }, formData({}));
expect(result).toEqual({ error: null, needsConfirmation: true, token: null });
expect(composeUpMock).not.toHaveBeenCalled();
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(envAfter).toContain("old-token");
});
it("on confirmation, replaces the token, recreates the container, and returns the new token", async () => {
await createStack("acme", 'BUSINESS_NAME="Acme"\nINSTALLATION_TOKEN="old-token"\nAUTH_SECRET="unrelated-secret"\n');
const result = await regenerateInstallationTokenAction(
"acme",
{ error: null, needsConfirmation: true, token: null },
formData({ confirmRegenerate: "true" }),
);
expect(result.error).toBeNull();
expect(result.token).toMatch(TOKEN_HEX_PATTERN);
expect(result.token).not.toBe("old-token");
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(envAfter).toContain(`INSTALLATION_TOKEN="${result.token}"`);
expect(envAfter).not.toContain("old-token");
// Rewriting the token must not touch unrelated secrets already in the file.
expect(envAfter).toContain('AUTH_SECRET="unrelated-secret"');
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), { forceRecreate: true });
});
it("invalidates the previous token — the old value no longer appears anywhere in the business's config", async () => {
await createStack("acme", 'INSTALLATION_TOKEN="old-token-to-invalidate"\n');
await regenerateInstallationTokenAction("acme", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" }));
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(envAfter).not.toContain("old-token-to-invalidate");
});
it("refuses to regenerate once installation is complete, even if the form is (re)submitted", async () => {
await createStack("acme", 'INSTALLATION_TOKEN="old-token"\n');
fetchInstallationStatusMock.mockResolvedValue({ status: "installed", installedAt: "2026-01-01T00:00:00.000Z" });
const result = await regenerateInstallationTokenAction(
"acme",
{ error: null, needsConfirmation: true, token: null },
formData({ confirmRegenerate: "true" }),
);
expect(result.error).toMatch(/already completed installation/i);
expect(result.token).toBeNull();
expect(composeUpMock).not.toHaveBeenCalled();
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(envAfter).toContain("old-token");
});
it("tenant isolation: regenerating one business's token never touches a sibling business's .env", async () => {
await createStack("tenant-a", 'INSTALLATION_TOKEN="token-a"\n');
await createStack("tenant-b", 'INSTALLATION_TOKEN="token-b"\n');
await regenerateInstallationTokenAction("tenant-a", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" }));
const envB = await readFile(path.join(stacksDir, "tenant-b", ".env"), "utf8");
expect(envB).toContain('INSTALLATION_TOKEN="token-b"');
expect(composeUpMock).toHaveBeenCalledTimes(1);
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "tenant-a"), { forceRecreate: true });
});
it("rejects an unknown/nonexistent slug", async () => {
await expect(
regenerateInstallationTokenAction("does-not-exist", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" })),
).rejects.toThrow(/no such stack/i);
});
});
@@ -1,7 +1,11 @@
"use server"; "use server";
import path from "node:path";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker"; import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker";
import { upsertEnvValues } from "@/lib/env-file";
import { fetchInstallationStatus } from "@/lib/installation-status";
import { randomHex } from "@/lib/random";
import { isValidSlug, stackDir, stackExists } from "@/lib/stacks"; import { isValidSlug, stackDir, stackExists } from "@/lib/stacks";
async function assertStack(slug: string): Promise<string> { async function assertStack(slug: string): Promise<string> {
@@ -40,3 +44,57 @@ export async function fetchLogsAction(slug: string): Promise<string> {
const dir = await assertStack(slug); const dir = await assertStack(slug);
return composeLogs(dir, 200); return composeLogs(dir, 200);
} }
export interface RegenerateTokenState {
error: string | null;
needsConfirmation: boolean;
/** The freshly-generated token, present only right after a successful regenerate — the panel re-reads it fresh on next load otherwise, same as every other value on this page. */
token: string | null;
}
/**
* Re-issues INSTALLATION_TOKEN for a business that hasn't completed
* installation yet — same two-step confirm shape as
* dashboard/settings/actions.ts's generateVapidKeysAction: the first
* submit (no `confirmRegenerate` field) comes back with
* `needsConfirmation: true` instead of acting, and only a second submit
* with `confirmRegenerate=true` actually rotates the token.
*
* Refuses once installation is complete (checked here too, not just
* hidden in the UI — defense in depth against a stale/re-submitted
* form). Uses the exact same `randomHex` generator provisioning.ts
* itself uses, so this is a rotation of the one existing token
* mechanism, not a second one; `upsertEnvValues` (also already used by
* updateBusiness) rewrites only the one line, leaving every other
* secret in this business's .env untouched. A container recreate is
* required afterward — env vars are only read at container start (see
* docker/entrypoint.sh), never hot-reloaded.
*/
export async function regenerateInstallationTokenAction(
slug: string,
_prevState: RegenerateTokenState,
formData: FormData,
): Promise<RegenerateTokenState> {
const dir = await assertStack(slug);
const { status } = await fetchInstallationStatus(slug);
if (status === "installed") {
return {
error: "This business has already completed installation — its installation token can no longer be regenerated.",
needsConfirmation: false,
token: null,
};
}
const confirmRegenerate = formData.get("confirmRegenerate") === "true";
if (!confirmRegenerate) {
return { error: null, needsConfirmation: true, token: null };
}
const token = randomHex(32);
await upsertEnvValues(path.join(dir, ".env"), { INSTALLATION_TOKEN: token });
await composeUp(dir, { forceRecreate: true });
revalidatePath(`/dashboard/${slug}`);
return { error: null, needsConfirmation: false, token };
}
@@ -0,0 +1,169 @@
"use client";
import { useActionState, useState } from "react";
import type { InstallationView } from "@/lib/installation-status";
import { regenerateInstallationTokenAction, type RegenerateTokenState } from "./actions";
const regenerateInitialState: RegenerateTokenState = { error: null, needsConfirmation: false, token: null };
function formatInstalledAt(installedAt: string | null): string | null {
if (!installedAt) return null;
const date = new Date(installedAt);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString();
}
function CopyButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard access can be denied by the browser — the token is
// still selectable/copyable by hand from the input next to
// this button, so this is a convenience failure, not a dead end.
}
}}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium hover:bg-stone"
>
{copied ? "Copied" : "Copy"}
</button>
);
}
/**
* Renders the per-business "Installation" section on the dashboard —
* see installation-status.ts's resolveInstallationView for how `view` is
* computed. Everything here is keyed off `view`/`slug`, both scoped to
* this one business — there is no code path here that could render one
* tenant's token under another tenant's page.
*/
export function InstallationTokenPanel({ slug, view }: { slug: string; view: InstallationView }) {
const [regenerateState, regenerateAction, isRegenerating] = useActionState(
regenerateInstallationTokenAction.bind(null, slug),
regenerateInitialState,
);
const currentToken = regenerateState.token ?? (view.kind !== "installed" ? view.token : null);
return (
<div className="space-y-4 rounded-md border border-line bg-surface p-5">
<h2 className="text-lg font-semibold">Installation</h2>
{view.kind === "installed" && (
<dl className="space-y-1 text-sm">
<div className="flex gap-2">
<dt className="font-medium text-muted">Status:</dt>
<dd className="font-semibold text-accent">Installed</dd>
</div>
{formatInstalledAt(view.installedAt) && (
<div className="flex gap-2">
<dt className="font-medium text-muted">Installed at:</dt>
<dd>{formatInstalledAt(view.installedAt)}</dd>
</div>
)}
</dl>
)}
{view.kind !== "installed" && (
<div className="space-y-4">
<dl className="space-y-1 text-sm">
<div className="flex gap-2">
<dt className="font-medium text-muted">Status:</dt>
<dd className="font-semibold">Not installed</dd>
</div>
{view.kind === "unknown" && (
<p className="text-xs text-muted">
Can&apos;t confirm this business&apos;s status right now — its container may be stopped or still
starting.
</p>
)}
</dl>
{view.installUrl ? (
<div className="text-sm">
<p className="font-medium text-muted">Installation URL:</p>
<a href={view.installUrl} target="_blank" rel="noreferrer" className="break-all font-mono text-xs text-accent underline">
{view.installUrl}
</a>
</div>
) : (
<p className="text-sm text-muted">No domain set yet — set one in Edit before installing.</p>
)}
<div>
<p className="text-sm font-medium text-muted">Installation token:</p>
{currentToken ? (
<div className="mt-1 flex gap-2">
<input
type="text"
readOnly
value={currentToken}
onFocus={(e) => e.currentTarget.select()}
className="w-full rounded-md border border-line bg-stone px-3 py-2 font-mono text-xs"
/>
<CopyButton value={currentToken} />
</div>
) : (
<p className="mt-1 text-sm text-muted">
No installation token found in this business&apos;s .env — generate one below.
</p>
)}
</div>
{regenerateState.needsConfirmation && (
<div className="space-y-1 rounded-md border border-amber-300 bg-amber-50 p-4">
<p className="text-sm font-semibold text-amber-900">Regenerate installation token?</p>
<p className="text-sm text-amber-900">The existing installation token will stop working.</p>
</div>
)}
<div className="flex flex-wrap items-center gap-3 pt-1">
{view.installUrl && (
<a
href={view.installUrl}
target="_blank"
rel="noreferrer"
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white"
>
Open installer
</a>
)}
<form action={regenerateAction}>
<input type="hidden" name="confirmRegenerate" value={regenerateState.needsConfirmation ? "true" : "false"} />
<button
type="submit"
disabled={isRegenerating}
className="rounded-md border border-line px-4 py-2 text-sm font-semibold disabled:opacity-60"
>
{isRegenerating
? "Working…"
: regenerateState.needsConfirmation
? "Yes, Regenerate Token"
: currentToken
? "Regenerate token"
: "Generate token"}
</button>
</form>
</div>
{regenerateState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{regenerateState.error}
</p>
)}
{regenerateState.token && !regenerateState.error && (
<p className="text-sm text-green-700">New token generated and the container was recreated to pick it up.</p>
)}
</div>
)}
</div>
);
}
@@ -1,7 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { resolveInstallationView } from "@/lib/installation-status";
import { listBusinessStacks } from "@/lib/stacks"; import { listBusinessStacks } from "@/lib/stacks";
import { BusinessActions } from "./business-actions"; import { BusinessActions } from "./business-actions";
import { InstallationTokenPanel } from "./installation-token-panel";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -19,6 +21,8 @@ export default async function BusinessDetailPage({
const stack = stacks.find((s) => s.slug === slug); const stack = stacks.find((s) => s.slug === slug);
if (!stack) notFound(); if (!stack) notFound();
const installationView = await resolveInstallationView(slug, stack.domain);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{justCreated && ( {justCreated && (
@@ -58,6 +62,8 @@ export default async function BusinessDetailPage({
</div> </div>
<BusinessActions slug={stack.slug} /> <BusinessActions slug={stack.slug} />
<InstallationTokenPanel slug={stack.slug} view={installationView} />
</div> </div>
); );
} }
+28 -1
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { fetchInstallationStatus } from "@/lib/installation-status";
import { listBusinessStacks } from "@/lib/stacks"; import { listBusinessStacks } from "@/lib/stacks";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,6 +13,23 @@ function StatusPill({ status }: { status: "running" | "stopped" | "unknown" }) {
return <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${styles[status]}`}>{status}</span>; return <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${styles[status]}`}>{status}</span>;
} }
/**
* Only ever the two labels from the task's own example — the nuance
* between "confirmed not installed" and "container unreachable, can't
* confirm" (see installation-status.ts's three-state model) only matters
* on the business detail page where the token itself lives. Never shows
* the token — see resolveInstallationView, not used here.
*/
function InstallPill({ installed }: { installed: boolean }) {
return (
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${installed ? "bg-accent/10 text-accent" : "bg-amber-100 text-amber-800"}`}
>
{installed ? "Installed" : "Setup required"}
</span>
);
}
export default async function DashboardPage({ export default async function DashboardPage({
searchParams, searchParams,
}: { }: {
@@ -20,6 +38,12 @@ export default async function DashboardPage({
const { justDeleted, warnings } = await searchParams; const { justDeleted, warnings } = await searchParams;
const stacks = await listBusinessStacks(); const stacks = await listBusinessStacks();
// Each check is independently timeout-bounded (see
// installation-status.ts) so one stopped/dead business's container
// can't stall this whole page — resolved in parallel rather than one
// at a time.
const installStatuses = await Promise.all(stacks.map((stack) => fetchInstallationStatus(stack.slug)));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{justDeleted && ( {justDeleted && (
@@ -45,7 +69,7 @@ export default async function DashboardPage({
<p className="text-sm text-muted">No businesses provisioned yet.</p> <p className="text-sm text-muted">No businesses provisioned yet.</p>
) : ( ) : (
<ul className="divide-y divide-line rounded-lg border border-line bg-surface"> <ul className="divide-y divide-line rounded-lg border border-line bg-surface">
{stacks.map((stack) => ( {stacks.map((stack, index) => (
<li key={stack.slug}> <li key={stack.slug}>
<Link <Link
href={`/dashboard/${stack.slug}`} href={`/dashboard/${stack.slug}`}
@@ -55,7 +79,10 @@ export default async function DashboardPage({
<p className="text-sm font-semibold">{stack.name ?? stack.slug}</p> <p className="text-sm font-semibold">{stack.name ?? stack.slug}</p>
<p className="text-xs text-muted">{stack.domain ?? "no domain set"}</p> <p className="text-xs text-muted">{stack.domain ?? "no domain set"}</p>
</div> </div>
<div className="flex items-center gap-2">
<InstallPill installed={installStatuses[index]?.status === "installed"} />
<StatusPill status={stack.status} /> <StatusPill status={stack.status} />
</div>
</Link> </Link>
</li> </li>
))} ))}
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { generateBusinessEnv, type BusinessEnvValues } from "../compose-template";
const baseValues: BusinessEnvValues = {
name: "Acme Barbers",
domain: "acme.example.com",
databaseName: "acme",
databaseUser: "acme",
databasePassword: "db-password",
authSecret: "auth-secret",
installationToken: "the-installation-token-value",
cronSecret: "cron-secret",
};
describe("generateBusinessEnv — INSTALLATION_TOKEN", () => {
it("writes the given installation token into the generated .env", () => {
const env = generateBusinessEnv(baseValues);
expect(env).toContain('INSTALLATION_TOKEN="the-installation-token-value"');
});
it("never writes the token in a NEXT_PUBLIC_* (build-time-inlined) variable", () => {
const env = generateBusinessEnv(baseValues);
const publicLines = env.split("\n").filter((line) => line.startsWith("NEXT_PUBLIC_"));
for (const line of publicLines) {
expect(line).not.toContain("the-installation-token-value");
}
});
it("is the only place a business's INSTALLATION_TOKEN gets written — every business gets a distinct token if given one", () => {
const envA = generateBusinessEnv({ ...baseValues, installationToken: "token-a" });
const envB = generateBusinessEnv({ ...baseValues, domain: "other.example.com", installationToken: "token-b" });
expect(envA).toContain('INSTALLATION_TOKEN="token-a"');
expect(envB).toContain('INSTALLATION_TOKEN="token-b"');
expect(envA).not.toContain("token-b");
expect(envB).not.toContain("token-a");
});
});
@@ -0,0 +1,146 @@
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchInstallationStatus, getInstallationToken, resolveInstallationView } from "../installation-status";
describe("fetchInstallationStatus", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("reports installed with installedAt when the tenant's /api/health says installed: true", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: "healthy", database: "reachable", installed: true, installedAt: "2026-01-15T10:00:00.000Z" }),
}) as unknown as typeof fetch;
const result = await fetchInstallationStatus("acme");
expect(result).toEqual({ status: "installed", installedAt: "2026-01-15T10:00:00.000Z" });
});
it("reports not_installed (with no installedAt) when installed: false", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: "healthy", database: "reachable", installed: false, installedAt: null }),
}) as unknown as typeof fetch;
const result = await fetchInstallationStatus("acme");
expect(result).toEqual({ status: "not_installed", installedAt: null });
});
it("reports unknown — never not_installed — when the fetch throws (container down/unreachable)", async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
const result = await fetchInstallationStatus("acme");
expect(result).toEqual({ status: "unknown", installedAt: null });
});
it("reports unknown when the response isn't ok (e.g. 503 unhealthy)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }) as unknown as typeof fetch;
const result = await fetchInstallationStatus("acme");
expect(result).toEqual({ status: "unknown", installedAt: null });
});
it("reports unknown for a malformed body (no boolean `installed` field)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ unexpected: true }) }) as unknown as typeof fetch;
const result = await fetchInstallationStatus("acme");
expect(result).toEqual({ status: "unknown", installedAt: null });
});
it("targets the internal docker network hostname for the given slug, not the public domain", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ installed: false }) });
globalThis.fetch = fetchMock as unknown as typeof fetch;
await fetchInstallationStatus("acme-barbers");
expect(fetchMock).toHaveBeenCalledWith("http://next-chair-acme-barbers:3000/api/health", expect.any(Object));
});
});
describe("getInstallationToken / resolveInstallationView", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
const originalFetch = globalThis.fetch;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-test-"));
process.env.STACKS_DIR = stacksDir;
});
afterEach(async () => {
globalThis.fetch = originalFetch;
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
async function writeStackEnv(slug: string, envContent: string) {
const dir = path.join(stacksDir, slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, ".env"), envContent, "utf8");
}
it("reads the existing token straight from that business's own .env — an already-generated token is preserved, not regenerated", async () => {
await writeStackEnv("acme", 'BUSINESS_NAME="Acme"\nINSTALLATION_TOKEN="existing-token-value"\n');
const token = await getInstallationToken("acme");
expect(token).toBe("existing-token-value");
});
it("returns null (not an error) for a business with no token in its .env", async () => {
await writeStackEnv("legacy-biz", 'BUSINESS_NAME="Legacy"\n');
const token = await getInstallationToken("legacy-biz");
expect(token).toBeNull();
});
it("returns null for a business directory that doesn't exist", async () => {
const token = await getInstallationToken("does-not-exist");
expect(token).toBeNull();
});
it("tenant isolation: never returns another business's token", async () => {
await writeStackEnv("tenant-a", 'INSTALLATION_TOKEN="token-for-a"\n');
await writeStackEnv("tenant-b", 'INSTALLATION_TOKEN="token-for-b"\n');
expect(await getInstallationToken("tenant-a")).toBe("token-for-a");
expect(await getInstallationToken("tenant-b")).toBe("token-for-b");
});
it("resolveInstallationView never includes a token once installed — the token is not even read from disk in that branch", async () => {
await writeStackEnv("acme", 'INSTALLATION_TOKEN="should-never-appear"\n');
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ installed: true, installedAt: "2026-01-15T10:00:00.000Z" }),
}) as unknown as typeof fetch;
const view = await resolveInstallationView("acme", "acme.example.com");
expect(view).toEqual({ kind: "installed", installedAt: "2026-01-15T10:00:00.000Z" });
expect(JSON.stringify(view)).not.toContain("should-never-appear");
});
it("resolveInstallationView surfaces the token pre-install, with the public install URL", async () => {
await writeStackEnv("acme", 'INSTALLATION_TOKEN="real-token"\n');
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ installed: false }) }) as unknown as typeof fetch;
const view = await resolveInstallationView("acme", "acme.example.com");
expect(view).toEqual({ kind: "not_installed", token: "real-token", installUrl: "https://acme.example.com/install" });
});
it("resolveInstallationView still surfaces the token (not hidden) when status can't be confirmed", async () => {
await writeStackEnv("acme", 'INSTALLATION_TOKEN="real-token"\n');
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
const view = await resolveInstallationView("acme", "acme.example.com");
expect(view.kind).toBe("unknown");
expect(view).toMatchObject({ token: "real-token" });
});
});
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { randomHex } from "../random";
describe("randomHex", () => {
it("returns a hex string of the requested byte length", () => {
const value = randomHex(32);
expect(value).toMatch(/^[a-f0-9]+$/);
expect(value).toHaveLength(64);
});
it("is sufficiently long/unpredictable — two calls never collide", () => {
const a = randomHex(32);
const b = randomHex(32);
expect(a).not.toBe(b);
});
});
+94
View File
@@ -0,0 +1,94 @@
import path from "node:path";
import { readEnvFile, readEnvValue } from "./env-file";
import { stackDir } from "./stacks";
export type TenantInstallState = "installed" | "not_installed" | "unknown";
export interface InstallationStatusResult {
status: TenantInstallState;
installedAt: string | null;
}
const HEALTH_CHECK_TIMEOUT_MS = 2500;
/**
* Asks the tenant's OWN app whether it has completed installation, via
* the existing public `GET /api/health` route (see that route's own
* header) — fetched over the internal `next-chair-edge` Docker network,
* the exact same `next-chair-<slug>:3000` hostname:port Caddy itself
* reverse-proxies to (see compose-template.ts's `generateCaddySite`).
* Manager has no database of its own and must not gain one just to
* answer this question; `installed`/`installedAt` are already the
* tenant's own source of truth (`ApplicationInstallation`, that
* business's own MySQL DB) — this only reads it, never duplicates it.
*
* Deliberately three states, not two: an unreachable container (stopped,
* still starting, mid `--force-recreate`) must never be silently treated
* as "not installed" — that would be inferring installation state from
* container state, which is exactly the anti-pattern to avoid.
* `"unknown"` means genuinely "can't confirm right now", not "not
* installed".
*/
export async function fetchInstallationStatus(slug: string): Promise<InstallationStatusResult> {
try {
const response = await fetch(`http://next-chair-${slug}:3000/api/health`, {
cache: "no-store",
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
});
if (!response.ok) return { status: "unknown", installedAt: null };
const body: unknown = await response.json();
if (!body || typeof body !== "object" || typeof (body as { installed?: unknown }).installed !== "boolean") {
return { status: "unknown", installedAt: null };
}
const { installed, installedAt } = body as { installed: boolean; installedAt?: unknown };
if (!installed) return { status: "not_installed", installedAt: null };
return { status: "installed", installedAt: typeof installedAt === "string" ? installedAt : null };
} catch {
// Network error, DNS failure (container not running yet), or the
// AbortSignal timeout firing — all collapse to "unknown", never
// "not_installed".
return { status: "unknown", installedAt: null };
}
}
/**
* Only ever reads the one field it's asked for, from that one business's
* own `.env` — same isolation guarantee every other per-business read in
* this app already has (see stacks.ts's `listBusinessStacks`, which
* reads BUSINESS_NAME/DOMAIN/DATABASE_NAME the same way). Returns null
* for a business with no token at all (hand-set-up outside Manager, or a
* business provisioned before INSTALLATION_TOKEN existed) rather than
* throwing.
*/
export async function getInstallationToken(slug: string): Promise<string | null> {
const envContent = await readEnvFile(path.join(stackDir(slug), ".env"));
if (!envContent) return null;
return readEnvValue(envContent, "INSTALLATION_TOKEN");
}
export type InstallationView =
| { kind: "installed"; installedAt: string | null }
| { kind: "not_installed"; token: string | null; installUrl: string | null }
| { kind: "unknown"; token: string | null; installUrl: string | null };
/**
* Combines the live health check with a direct `.env` read into exactly
* what the dashboard needs to render — pulled out as its own function
* (rather than inlined in the page component) so "the token never
* appears once installed" is a plain, unit-testable fact about this
* function's return value, not something that only shows up by
* inspecting rendered JSX. In the `"installed"` branch the token is
* never even read off disk — not just omitted from the return value.
*/
export async function resolveInstallationView(slug: string, domain: string | null): Promise<InstallationView> {
const installUrl = domain ? `https://${domain}/install` : null;
const { status, installedAt } = await fetchInstallationStatus(slug);
if (status === "installed") {
return { kind: "installed", installedAt };
}
const token = await getInstallationToken(slug);
return { kind: status === "unknown" ? "unknown" : "not_installed", token, installUrl };
}
+2 -6
View File
@@ -1,4 +1,3 @@
import { randomBytes } from "node:crypto";
import { mkdir, rm, writeFile } from "node:fs/promises"; import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import mariadb, { type Connection } from "mariadb"; import mariadb, { type Connection } from "mariadb";
@@ -6,17 +5,14 @@ import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./c
import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker"; import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker";
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file"; import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
import { isEmailConfigComplete, isPushConfigComplete, resolveEmailSettings, resolvePushSettings } from "./global-settings"; import { isEmailConfigComplete, isPushConfigComplete, resolveEmailSettings, resolvePushSettings } from "./global-settings";
import { randomHex } from "./random";
import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks"; import { isValidSlug, listBusinessStacks, 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. */ /** 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]$/; 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. */ /** Generated via randomHex 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]+$/; const SAFE_GENERATED_SECRET = /^[a-f0-9]+$/;
function randomHex(bytes: number): string {
return randomBytes(bytes).toString("hex");
}
/** Free text (business name), but it ends up inside a "KEY="value"" .env line — reject anything that could break out of that. */ /** Free text (business name), but it ends up inside a "KEY="value"" .env line — reject anything that could break out of that. */
function assertSafeDisplayText(value: string, fieldLabel: string): void { function assertSafeDisplayText(value: string, fieldLabel: string): void {
if (!value.trim()) throw new Error(`${fieldLabel} is required.`); if (!value.trim()) throw new Error(`${fieldLabel} is required.`);
+14
View File
@@ -0,0 +1,14 @@
import { randomBytes } from "node:crypto";
/**
* Cryptographically secure hex string of `bytes` random bytes (i.e.
* `bytes * 2` hex characters) — the one generator used for every secret
* this app creates on a business's behalf (database password,
* `AUTH_SECRET`, `INSTALLATION_TOKEN`, `CRON_SECRET`). Pulled out of
* provisioning.ts into its own module so a later rotate/regenerate
* action (e.g. re-issuing `INSTALLATION_TOKEN`) reuses this exact
* mechanism instead of a second, duplicate one.
*/
export function randomHex(bytes: number): string {
return randomBytes(bytes).toString("hex");
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
// Manager's own Vitest setup — separate from the root app's
// vitest.config.mts (which deliberately excludes manager/**), matching
// this app's own "fully separate, own package.json/build" convention
// (see manager/README.md). Same shape as the root config otherwise.
export default defineConfig({
resolve: { tsconfigPaths: true },
test: {
environment: "node",
globals: false,
},
});
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const queryRawMock = vi.fn();
vi.mock("@/lib/db/prisma", () => ({
prisma: { $queryRaw: (...args: unknown[]) => queryRawMock(...args) },
}));
const getInstallationStateMock = vi.fn();
vi.mock("@/lib/installation/installation-state", () => ({
getInstallationState: () => getInstallationStateMock(),
}));
import { GET } from "../route";
/**
* This is a public, unauthenticated endpoint (see the route's own header)
* — the one contract that matters is that it never leaks anything beyond
* status/database/installed/installedAt, in particular never the
* installation token or any other tenant data. Manager (a separate app
* with no database access of its own) relies on `installed`/`installedAt`
* here to decide whether to still show that business's installation
* token, so those two fields also need to behave correctly.
*/
describe("GET /api/health", () => {
beforeEach(() => {
queryRawMock.mockReset();
getInstallationStateMock.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns unhealthy with 503 when the database is unreachable, without calling installation-state", async () => {
queryRawMock.mockRejectedValueOnce(new Error("connection refused"));
const response = await GET();
expect(response.status).toBe(503);
const body = await response.json();
expect(body).toEqual({ status: "unhealthy", database: "unreachable" });
expect(getInstallationStateMock).not.toHaveBeenCalled();
});
it("reports installed: false and installedAt: null before installation", async () => {
queryRawMock.mockResolvedValueOnce([{ 1: 1 }]);
getInstallationStateMock.mockResolvedValueOnce({ installed: false, status: "NOT_INSTALLED", installedAt: null });
const response = await GET();
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({ status: "healthy", database: "reachable", installed: false, installedAt: null });
});
it("reports installed: true and an ISO installedAt once installed", async () => {
queryRawMock.mockResolvedValueOnce([{ 1: 1 }]);
const installedAt = new Date("2026-01-15T10:30:00.000Z");
getInstallationStateMock.mockResolvedValueOnce({ installed: true, status: "INSTALLED", installedAt });
const response = await GET();
const body = await response.json();
expect(body).toEqual({
status: "healthy",
database: "reachable",
installed: true,
installedAt: "2026-01-15T10:30:00.000Z",
});
});
it("falls back to installed: false, installedAt: null rather than throwing if installation-state itself errors", async () => {
queryRawMock.mockResolvedValueOnce([{ 1: 1 }]);
getInstallationStateMock.mockRejectedValueOnce(new Error("boom"));
const response = await GET();
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({ status: "healthy", database: "reachable", installed: false, installedAt: null });
});
it("never returns anything beyond status/database/installed/installedAt — no token, no other tenant data", async () => {
queryRawMock.mockResolvedValueOnce([{ 1: 1 }]);
getInstallationStateMock.mockResolvedValueOnce({ installed: true, status: "INSTALLED", installedAt: new Date() });
const response = await GET();
const body = await response.json();
expect(Object.keys(body).sort()).toEqual(["database", "installed", "installedAt", "status"]);
});
});
+15 -2
View File
@@ -9,6 +9,14 @@ import { getInstallationState } from "@/lib/installation/installation-state";
* show the installer), not unhealthy. Only a database connectivity * show the installer), not unhealthy. Only a database connectivity
* failure returns a non-200 status. Never returns secrets or a stack * failure returns a non-200 status. Never returns secrets or a stack
* trace — just enough for a health probe to act on. * trace — just enough for a health probe to act on.
*
* `installed`/`installedAt` are also this deployment's only externally
* observable installation-status signal — NextChair Manager (a separate
* app with no database access of its own) polls this over the internal
* Docker network to decide whether to still show that business's
* installation token, rather than Manager needing its own authenticated
* status endpoint. Deliberately still just a boolean + a timestamp: never
* the installation token itself, never anything else about the tenant.
*/ */
export async function GET() { export async function GET() {
try { try {
@@ -17,7 +25,12 @@ export async function GET() {
return NextResponse.json({ status: "unhealthy", database: "unreachable" }, { status: 503 }); return NextResponse.json({ status: "unhealthy", database: "unreachable" }, { status: 503 });
} }
const { installed } = await getInstallationState().catch(() => ({ installed: false })); const { installed, installedAt } = await getInstallationState().catch(() => ({ installed: false, installedAt: null }));
return NextResponse.json({ status: "healthy", database: "reachable", installed }); return NextResponse.json({
status: "healthy",
database: "reachable",
installed,
installedAt: installedAt ? installedAt.toISOString() : null,
});
} }
+4 -2
View File
@@ -12,6 +12,8 @@ export const INSTALLATION_SINGLETON_ID = "singleton";
export interface InstallationState { export interface InstallationState {
installed: boolean; installed: boolean;
status: InstallationStatus; status: InstallationStatus;
/** When `status === INSTALLED`, else null. Surfaced by GET /api/health so an external tool (e.g. NextChair Manager) can show it without ever needing DB access of its own — see that route's own header. */
installedAt: Date | null;
} }
/** /**
@@ -25,10 +27,10 @@ export interface InstallationState {
export const getInstallationState = cache(async (): Promise<InstallationState> => { export const getInstallationState = cache(async (): Promise<InstallationState> => {
const row = await prisma.applicationInstallation.findUnique({ const row = await prisma.applicationInstallation.findUnique({
where: { id: INSTALLATION_SINGLETON_ID }, where: { id: INSTALLATION_SINGLETON_ID },
select: { status: true }, select: { status: true, installedAt: true },
}); });
const status = row?.status ?? InstallationStatus.NOT_INSTALLED; const status = row?.status ?? InstallationStatus.NOT_INSTALLED;
return { installed: status === InstallationStatus.INSTALLED, status }; return { installed: status === InstallationStatus.INSTALLED, status, installedAt: row?.installedAt ?? null };
}); });
/** /**