Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,27 @@ POSTGRES_USER=exam
# Production: replace with a strong generated password. Required.
POSTGRES_PASSWORD=exam
POSTGRES_DB=exam

# Launchpad first-install setup token (P7-C1 corrective pass). Unset/empty
# DISABLES the browser first-Admin setup form (POST /api/launchpad/bootstrap
# returns 403). Set it in .env BEFORE the first `docker compose up` to use
# the browser first-install page; generate with high entropy:
# LAUNCHPAD_SETUP_TOKEN="$(openssl rand -hex 32)"
# Body-only (never in a URL); never audit-logged in plaintext; rate-limited.
# Once the installation is initialized, /launchpad redirects to /login and
# never reopens — removing the last Admin does NOT reopen it (Admin recovery
# is operator CLI: bootstrap-admin / reset-admin-password).
Comment on lines +150 to +152

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Qualify the “never reopens” guarantee.

The supported removal flow keeps Launchpad completed, but destructive database edits or history-replacing restores can change the freshness evidence. State that this guarantee applies to supported Admin-removal operations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example around lines 150 - 152, Update the Launchpad comment in
.env.example to qualify that it never reopens after supported Admin-removal
operations, while noting that destructive database edits or history-replacing
restores may alter the freshness evidence. Keep the existing operator CLI
recovery guidance unchanged.

# LAUNCHPAD_SETUP_TOKEN=""

# WAL archive host path for PostgreSQL PITR (P7-C3 corrective pass). The
# mount is ALWAYS present on the db service but is INERT by default
# (archive_mode=off). To enable point-in-time recovery, run
# scripts/backup/postgres-enable-pitr.sh (ALTER SYSTEM). Production MUST
# point this at an INDEPENDENT failure domain (NAS / another server / a
# separate disk); the local default is for development/drills only and is
# NOT host-loss protection.
# EXAM_WAL_ARCHIVE_HOST_PATH="./data/wal-archive"

# JWT_SECRET — required in production (no default in the bundled Compose).
# Generate a secure random string: openssl rand -base64 32
# JWT_SECRET=""
Expand Down
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/
31 changes: 29 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,29 @@ 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 (serialized by a transaction-scoped
PostgreSQL advisory lock so exactly one first installation may win); 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) §11.

#### 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. There is exactly ONE production/operator Docker
Compose entry point (`docker-compose.yml`); optional capabilities such as
PITR are PostgreSQL database configuration
(`scripts/backup/postgres-enable-pitr.sh`), not an alternate Docker
topology. The supported paths are: 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