Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ mutation-campaign-results/
# Unattended task execution evidence (baseline/test logs, morning reports)
.artifacts/
/mvp/
# P7-C1: operator-visible host persistence root for the production Compose
# topology (bind-mounted postgres/redis state). Never commit runtime data.
/data/
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,12 @@ cp .env.example .env
docker compose up -d --build # build + start app, db, email-worker
docker compose logs -f app
docker compose ps # verify app, db, email-worker are up
docker compose down
docker compose down -v # DANGEROUS: removes database data volumes
docker compose down # stops + removes containers (keeps ./data)
# NOTE (P7-C1): authoritative state lives in the operator-visible host bind
# mount ./data/postgres (EXAM_DATA_ROOT, default ./data). `docker compose down`
# retains it; `docker compose down -v` is a no-op for bind mounts. To destroy
# authoritative data you must explicitly delete ./data/postgres. See
# docs/deployment/backup-and-recovery.md.

# (Optional) enable the Redis profile — the shared rate limiter reads/writes
# Redis when the runtime is ready:
Expand Down Expand Up @@ -207,6 +211,23 @@ The bootstrap: (1) locates or creates the internal default organization
(4) writes an `admin.bootstrap` audit row. It refuses a second active
Admin unless `--force` is supplied. It does NOT create Candidate accounts.

Alternatively, on a fresh installation you can use the **Launchpad**
first-install page: set `LAUNCHPAD_SETUP_TOKEN=<openssl rand -hex 32>` in
`.env`, start the stack, and navigate to `/launchpad` to complete the
first-Admin setup in the browser. The Launchpad and the CLI share one
canonical atomic mutation body; once the installation is initialized,
`/launchpad` redirects to `/login` (it never reopens). See
[`docs/deployment/backup-and-recovery.md`](docs/deployment/backup-and-recovery.md) §8.

#### Backup and recovery

Authoritative state is the PostgreSQL data directory under
`./data/postgres`. **Host persistence is not backup** — see
[`docs/deployment/backup-and-recovery.md`](docs/deployment/backup-and-recovery.md)
for the full decision tree: stopped-directory relocation (C1),
cold-filesystem backup/restore (C1), C2 logical `pg_dump` online backup +
clean restore, and C3 physical `pg_basebackup` + WAL archive / PITR.

## Docker Files Reference

| File | Purpose |
Expand Down
167 changes: 167 additions & 0 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,173 @@
}
}
},
"/api/launchpad/status": {
"get": {
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"initialized": {
"type": "boolean"
}
},
"required": ["initialized"],
"additionalProperties": false
}
}
}
}
}
}
},
"/api/launchpad/bootstrap": {
"post": {
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"organizationName": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"organizationDisplayName": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"adminUsername": {
"type": "string",
"minLength": 3,
"maxLength": 50
},
"adminPassword": {
"type": "string",
"minLength": 8,
"maxLength": 100
},
"adminName": {
"type": "string",
"minLength": 1,
"maxLength": 100
},
"setupToken": {
"type": "string",
"minLength": 1,
"maxLength": 1024
}
},
"required": [
"organizationName",
"adminUsername",
"adminPassword",
"adminName",
"setupToken"
],
"additionalProperties": false
}
}
}
},
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"enum": [true]
},
"organizationSlug": {
"type": "string"
},
"adminUsername": {
"type": "string"
}
},
"required": ["ok", "organizationSlug", "adminUsername"],
"additionalProperties": false
}
}
}
},
"403": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"error": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"details": {},
"requestId": {
"type": "string",
"minLength": 1
}
},
"required": ["code", "message", "requestId"],
"additionalProperties": false
}
},
"required": ["error"],
"additionalProperties": false
}
}
}
},
"409": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"error": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"details": {},
"requestId": {
"type": "string",
"minLength": 1
}
},
"required": ["code", "message", "requestId"],
"additionalProperties": false
}
},
"required": ["error"],
"additionalProperties": false
}
}
}
}
}
}
},
"/api/settings/branding": {
"get": {
"parameters": [
Expand Down
18 changes: 13 additions & 5 deletions apps/api/src/authz/routeRegistryConformanceWholeApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,8 +325,14 @@ describe("P4-C1 whole-application authorization route regression lock", () => {
["GET", "/api/settings/branding"],
["GET", "/api/system/info"],
["GET", "/api/system/public-config"],
// P7-C1 Launchpad: initial-installation-only public routes. GET status
// reveals only "is the default org initialized" (login UX already
// implies it); POST bootstrap refuses once initialized, so neither is
// a token oracle nor a completed-installation oracle.
["GET", "/api/launchpad/status"],
["POST", "/api/launchpad/bootstrap"],
];
return set.some(([m, u]) => m === method && url === u);
return set.some(([m, u]) => m === method && u === url);
}

it("the authenticate-only + public route set is exactly the documented closed set (no drift)", () => {
Expand All @@ -347,7 +353,7 @@ describe("P4-C1 whole-application authorization route regression lock", () => {
).toEqual([]);
});

it("the full composition reconciles to 113 primary routes (99 protected + 14 non-protected)", () => {
it("the full composition reconciles to 115 primary routes (99 protected + 16 non-protected)", () => {
const protectedCount = capturedRoutes.filter(
(r) => categorize(r) === "protected",
).length;
Expand All @@ -362,7 +368,9 @@ describe("P4-C1 whole-application authorization route regression lock", () => {
// Admin Recovery Center read routes (queue + aggregate detail + attempt
// operations context) → 112 primary = 98 protected + 14 non-protected.
// J5-I1B4 adds the Exam Recovery Context read route → 113 primary = 99
// protected + 14 non-protected. This is a regression anchor, not a
// protected + 14 non-protected. P7-C1 adds 2 public Launchpad routes
// (status + bootstrap) → 115 primary = 99 protected + 16 non-protected.
// This is a regression anchor, not a
// hard-coded PASS: if a route is added/removed the counts move and the
// failure message names the delta so the regression is triaged, not
// silently swallowed.
Expand All @@ -371,9 +379,9 @@ describe("P4-C1 whole-application authorization route regression lock", () => {
"protected (capability/ownership-gated) routes",
).toBe(99);
expect(nonProtectedCount, "non-protected (auth-only + public) routes").toBe(
14,
16,
);
expect(capturedRoutes.length, "total primary routes").toBe(113);
expect(capturedRoutes.length, "total primary routes").toBe(115);
});

it("every protected route's capability gate carries a valid catalog permission (no ad-hoc permission strings)", () => {
Expand Down
29 changes: 29 additions & 0 deletions apps/api/src/config/runtimeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,27 @@ export interface EmailWorkerConfig {
concurrency: number;
}

/**
* Launchpad first-install configuration (P7-C1).
*
* The setup token is a deployment bootstrap secret: high entropy, body-only
* (never in a URL), never audit-logged in plaintext, and required for the
* initial first-Admin setup via `/api/launchpad/bootstrap`. An unset/empty
* value means launchpad is refused (no token-validation oracle). The token
* is read from the `LAUNCHPAD_SETUP_TOKEN` env var and is intentionally
* NOT fail-fast at boot: an unset token simply disables launchpad, so a
* bare `docker compose up` without launchpad configured starts normally.
*/
export interface LaunchpadConfig {
/**
* The configured setup token, or an empty string when not configured.
* Comparison against a request token MUST be constant-time and MUST be
* preceded by the installation-initialized check so a completed
* installation cannot become a token-validity oracle.
*/
setupToken: string;
}

export interface AppRuntimeConfig {
app: {
mode: AppMode;
Expand All @@ -221,6 +242,7 @@ export interface AppRuntimeConfig {
email: EmailConfig;
emailWorker: EmailWorkerConfig;
publicWebOrigin: PublicWebOriginConfig;
launchpad: LaunchpadConfig;
}

const DEFAULT_JWT_SECRET = "development-only-change-me";
Expand Down Expand Up @@ -872,6 +894,13 @@ export function loadRuntimeConfig(
email,
emailWorker: resolveEmailWorkerConfig(env, email),
publicWebOrigin: { origin: resolvePublicWebOrigin(env, mode) },
launchpad: {
// P7-C1: unset/empty LAUNCHPAD_SETUP_TOKEN disables launchpad (the
// bootstrap endpoint refuses). NOT fail-fast — a bare `docker compose
// up` without launchpad configured must start normally. Trimmed to
// treat a whitespace-only value as unset.
setupToken: (env.LAUNCHPAD_SETUP_TOKEN ?? "").trim(),
},
};
}

Expand Down
Loading
Loading