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
+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" });
});
});
+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 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.`);
+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");
}