install token now shown in dashboard until install completes.
This commit is contained in:
18 files changed
+1977
-15
No files matched your search
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user