install token now shown in dashboard until install completes.
This commit is contained in:
18 files changed
+1976
-14
No files matched your search
Generated
+1079
-1
File diff suppressed because it is too large.
Load diff
@@ -8,7 +8,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -30,6 +31,7 @@
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.2.12",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
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";
|
||||
|
||||
async function assertStack(slug: string): Promise<string> {
|
||||
@@ -40,3 +44,57 @@ export async function fetchLogsAction(slug: string): Promise<string> {
|
||||
const dir = await assertStack(slug);
|
||||
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't confirm this business'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'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 { notFound } from "next/navigation";
|
||||
import { resolveInstallationView } from "@/lib/installation-status";
|
||||
import { listBusinessStacks } from "@/lib/stacks";
|
||||
import { BusinessActions } from "./business-actions";
|
||||
import { InstallationTokenPanel } from "./installation-token-panel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -19,6 +21,8 @@ export default async function BusinessDetailPage({
|
||||
const stack = stacks.find((s) => s.slug === slug);
|
||||
if (!stack) notFound();
|
||||
|
||||
const installationView = await resolveInstallationView(slug, stack.domain);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{justCreated && (
|
||||
@@ -58,6 +62,8 @@ export default async function BusinessDetailPage({
|
||||
</div>
|
||||
|
||||
<BusinessActions slug={stack.slug} />
|
||||
|
||||
<InstallationTokenPanel slug={stack.slug} view={installationView} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { fetchInstallationStatus } from "@/lib/installation-status";
|
||||
import { listBusinessStacks } from "@/lib/stacks";
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -20,6 +38,12 @@ export default async function DashboardPage({
|
||||
const { justDeleted, warnings } = await searchParams;
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{justDeleted && (
|
||||
@@ -45,7 +69,7 @@ export default async function DashboardPage({
|
||||
<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) => (
|
||||
{stacks.map((stack, index) => (
|
||||
<li key={stack.slug}>
|
||||
<Link
|
||||
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-xs text-muted">{stack.domain ?? "no domain set"}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<InstallPill installed={installStatuses[index]?.status === "installed"} />
|
||||
<StatusPill status={stack.status} />
|
||||
</div>
|
||||
</Link>
|
||||
</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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import mariadb, { type Connection } from "mariadb";
|
||||
@@ -6,17 +5,14 @@ import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./c
|
||||
import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker";
|
||||
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
|
||||
import { isEmailConfigComplete, isPushConfigComplete, resolveEmailSettings, resolvePushSettings } from "./global-settings";
|
||||
import { randomHex } from "./random";
|
||||
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. */
|
||||
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]+$/;
|
||||
|
||||
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. */
|
||||
function assertSafeDisplayText(value: string, fieldLabel: string): void {
|
||||
if (!value.trim()) throw new Error(`${fieldLabel} is required.`);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,14 @@ import { getInstallationState } from "@/lib/installation/installation-state";
|
||||
* show the installer), not unhealthy. Only a database connectivity
|
||||
* failure returns a non-200 status. Never returns secrets or a stack
|
||||
* 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() {
|
||||
try {
|
||||
@@ -17,7 +25,12 @@ export async function GET() {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export const INSTALLATION_SINGLETON_ID = "singleton";
|
||||
export interface InstallationState {
|
||||
installed: boolean;
|
||||
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> => {
|
||||
const row = await prisma.applicationInstallation.findUnique({
|
||||
where: { id: INSTALLATION_SINGLETON_ID },
|
||||
select: { status: true },
|
||||
select: { status: true, installedAt: true },
|
||||
});
|
||||
const status = row?.status ?? InstallationStatus.NOT_INSTALLED;
|
||||
return { installed: status === InstallationStatus.INSTALLED, status };
|
||||
return { installed: status === InstallationStatus.INSTALLED, status, installedAt: row?.installedAt ?? null };
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user