diff --git a/docs/operations/luna-max-handoff-2026-08-02.md b/docs/operations/luna-max-handoff-2026-08-02.md new file mode 100644 index 00000000..01b28ba6 --- /dev/null +++ b/docs/operations/luna-max-handoff-2026-08-02.md @@ -0,0 +1,85 @@ +# Luna Max implementation handoff + +Observed at (UTC): `2026-08-02T14:16:09Z` + +## Repository state + +- Canonical repository/worktree: `databreeze-platform/.worktrees/luna-max-orchestration` +- Branch: `feat/foundation-identity-completion` +- Upstream: `origin/feat/foundation-identity-completion` +- Branch base / remote `dev`: `783a4710c0aa2a2808d78ad7f0643e6731150bd7` +- Remote `main`: `3ed3d77d0281ef239d0509c81ded447d8fffd213` +- Completed orchestration commit: `62e10cddeda3cd6d49a3c78795a420b7c58331fc` +- Open feature PR: none +- Open promotion PR: none +- CodeRabbit invocation for this batch: `0` +- Normal batch count before this handoff record: `1` commit and `8` changed files + +## Active execution state + +- Active batch: `B01 — Foundation verification and identity completion` +- Next task: `FND-003 — Close local infrastructure gaps` +- Batch target: 70 atomic commits; allowed range 30–85; repository hard maximum 99 +- Promotion changed-file target: at most 260; review stop gate: 280 +- Requirement ledger remains conservative: 608 `planned`, 3 `partial`, 0 `verified` +- Verified foundation task evidence: `FND-001`, `FND-002` +- Integrated but incompletely evidenced foundation work: `FND-003..007` + +## Delivered planning outcome + +- Added `docs/plans/004-luna-max-execution-plan.md` with 15 dependency-safe delivery batches covering all 153 unfinished orchestration tasks exactly once. +- Updated the execution ledger to version 2 with the current promotion checkpoint, `activeBatchId`, exact batch dependencies, branch names, commit budgets, changed-file limits, exit gates, and conservative foundation task states. +- Extended the orchestration checker to reject missing/duplicate task ownership, batch dependency cycles, undersized normal batches, 100-or-more commit maxima, changed-file limits above 260, undocumented batch drift, and an active batch that does not contain the next task. +- Updated the resume runbook with batch-state recovery, sequential package-manager operation, `EBUSY`/`EEXIST` recovery, and the exact Luna Max bootstrap contract. +- Corrected the stale plan-package filename in `docs/plans/README.md`. + +## Verification evidence + +Passed from the isolated worktree after sequential bootstrap: + +- `corepack pnpm install --frozen-lockfile` +- `uv sync --locked --offline` in `services/engine` +- `corepack pnpm repo:check` +- `corepack pnpm repo:build` +- `git diff --check` +- Orchestration checker: 19 plans, 155 tasks, 15 batches, 611 requirements, next `FND-003`, active `B01` +- Repository CLI tests: 74 passed +- Orchestration tests: 13 passed +- Python engine tests: 92 passed +- Turborepo tests: 21 successful tasks +- Turborepo builds: 12 successful tasks +- TypeScript/Python/Kotlin contract parity: 28 cases + +Environment gates intentionally remain open: + +- OpenTofu is unavailable on this workstation; static AWS checks pass, but pinned `fmt/init/validate` and reviewed plan evidence remain required. +- Docker Desktop/Compose v2 was unavailable during the foundation implementation; live health, collision, disk-pressure, Redis persistence, and restart checks remain required for `FND-003`. +- GitHub protected release-environment reviewers and branch restrictions require administrator evidence. +- Android instrumentation/emulator and signed packaging remain later release gates. + +## Resume instructions + +Run sequentially: + +```powershell +git fetch --all --prune +git switch feat/foundation-identity-completion +git pull --ff-only origin feat/foundation-identity-completion +corepack pnpm install --frozen-lockfile +Push-Location services/engine +uv sync --locked --offline +Pop-Location +corepack pnpm orchestration:check +corepack pnpm requirements:check +``` + +Then read `AGENTS.md`, `docs/plans/002-complete-execution-orchestration.md`, `docs/plans/003-luna-handoff-runbook.md`, `docs/plans/004-luna-max-execution-plan.md`, the Plan 010/020 documents, and the selected trace records. Resume `FND-003` from its live-environment gate; reconcile existing merged behavior before writing replacement code. + +Do not open a PR yet. Continue atomic `B01` work on this branch until it reaches at least 30 commits, targeting about 70. The feature PR goes to `dev` without CodeRabbit; the later `dev` to `main` promotion receives the single full CodeRabbit review. + +## Rollback and preserved state + +- Revert the handoff-record commit to remove only this status record. +- Revert `62e10cddeda3cd6d49a3c78795a420b7c58331fc` to remove the Luna batch plan, ledger version 2, and its validation changes as one independent unit. +- No infrastructure was applied, no database was migrated, no customer data was read, and no feature flag changed. +- Ignored `node_modules`, `.venv`, `dist`, and tool caches are disposable worktree products; tracked files are clean after the containing handoff commit. diff --git a/docs/plans/000-platform-program.md b/docs/plans/000-platform-program.md index b86015c7..f9d652b6 100644 --- a/docs/plans/000-platform-program.md +++ b/docs/plans/000-platform-program.md @@ -43,6 +43,8 @@ Child plans are written and approved before their product slice begins. Each nam docs/plans/requirement-traceability.json records all 611 IDs with requested trace fields, one primary plan/task, release status, and verified paths only after evidence exists. +`docs/plans/004-luna-max-execution-plan.md` packages the unfinished orchestration tasks into 15 dependency-safe delivery batches. Normal batches contain 30–99 atomic commits, target about 70, keep promotion diffs at or below 260 changed files, and use the `feat/*` or `fix/*` → `dev` → reviewed `main` flow below. + ## Branch, commit, and review policy - `main` contains stable releases. `dev` is the integration branch. diff --git a/docs/plans/002-complete-execution-orchestration.md b/docs/plans/002-complete-execution-orchestration.md index fd7cc544..40b8dc2f 100644 --- a/docs/plans/002-complete-execution-orchestration.md +++ b/docs/plans/002-complete-execution-orchestration.md @@ -4,7 +4,7 @@ **Goal:** Finish all 611 DataBreeze requirements through dependency-ordered, independently reversible tasks and preserve enough verified state that a new model can resume without guessing. -**Architecture:** The numbered child plans remain the requirement owners. This document adds the execution DAG, atomic task boundaries, parallel-work rules, cross-plan gates, failure handling, and handoff contract that bind those child plans into one program. `execution-orchestration.json` is the machine-readable control record; `requirement-traceability.json` remains the requirement-to-code-and-evidence authority. +**Architecture:** The numbered child plans remain the requirement owners. This document adds the execution DAG, atomic task boundaries, parallel-work rules, cross-plan gates, failure handling, and handoff contract that bind those child plans into one program. `004-luna-max-execution-plan.md` packages unfinished tasks into reviewable delivery batches, `execution-orchestration.json` is the machine-readable control record, and `requirement-traceability.json` remains the requirement-to-code-and-evidence authority. **Tech Stack:** pnpm/Turborepo and strict TypeScript, NestJS/Fastify, PostgreSQL 17 with Prisma, Redis 7.4, S3-compatible storage, Electron, Kotlin/Compose, Python 3.13, OpenAPI/JSON Schema, OpenTofu/AWS Singapore, GitHub Actions, and CodeRabbit. @@ -33,8 +33,9 @@ Read sources in this order when they disagree: 3. Normative requirements under `docs/specs/` and `docs/specs/requirement-index.json`. 4. Product and architecture documents under `docs/product/` and `docs/architecture/`. 5. The requirement-owning child plan under `docs/plans/`. -6. This orchestration plan and `execution-orchestration.json`. -7. Existing code and historical implementation reports. +6. This orchestration plan. +7. `004-luna-max-execution-plan.md` and `execution-orchestration.json`. +8. Existing code and historical implementation reports. Code is evidence of work, not evidence of full requirement completion. Use these states consistently: @@ -57,11 +58,15 @@ The ledger also uses these plan/task states; they are not requirement statuses a Never infer `verified` from a merged PR, a green unit test, file existence, or a previous model's prose. +The numbered child plans own requirement scope and release obligations. Their early generated `Paths` examples are not repository path authority. Section 4 of this document supersedes generic aggregate names such as `identity-audit-entitlements`, `production-readiness`, or hyphenated Python processor directories; use the module-owned roots and deterministic platform keys below. + ## 2. Recorded checkpoint -This plan was prepared on 2026-08-02 from remote `dev` at `b21642b` and remote `main` at `8ac8bca`; promotion PR 8 had merged and no PR was open. The requirement manifest contained 611 records: 444 P0, 154 P1, 13 P2; 608 were `planned` and 3 were `partial`. +This plan was reconciled on 2026-08-02 from remote `dev` at `783a4710c0aa2a2808d78ad7f0643e6731150bd7` and remote `main` at `3ed3d77d0281ef239d0509c81ded447d8fffd213`; promotion PR 20 had merged and no PR was open. The requirement manifest contained 611 records: 444 P0, 154 P1, 13 P2; 608 were `planned` and 3 were `partial`. + +Merged PRs 1–23 establish substantial engineering, IAM/AUD/BUA, IAE/DSM, JRA, and DSO code. PR 19 delivered the normal 73-commit foundation batch to `dev`; PR 20 promoted it to `main`; PRs 21–23 carried validated promotion-review fixes back through `dev`. Plans 010–050 must therefore start with evidence reconciliation, not blind reimplementation. Plans 060–500 remain unverified and must be treated as planned until their gates pass. -Merged PRs 1–8 establish substantial engineering, IAM/AUD/BUA, IAE/DSM, JRA, and DSO code. Plans 010–050 must therefore start with evidence reconciliation, not blind reimplementation. Plans 060–500 remain unverified and must be treated as planned until their gates pass. +The active execution packet is `B01` in `004-luna-max-execution-plan.md`, starting with `FND-003` on `feat/foundation-identity-completion`. The packet preserves the requested 30–99 commit rule, targets about 70 commits, and carries the implementation forward without opening a documentation-only PR. The hashes above are an audit anchor, not a branch lock. Every session must fetch and recompute live state; update the ledger checkpoint only as part of a committed task/PR handoff so session-local observations do not create meaningless dirty files. diff --git a/docs/plans/003-luna-handoff-runbook.md b/docs/plans/003-luna-handoff-runbook.md index 8d112214..80fdefce 100644 --- a/docs/plans/003-luna-handoff-runbook.md +++ b/docs/plans/003-luna-handoff-runbook.md @@ -5,7 +5,7 @@ Use this runbook to resume DataBreeze after a model, machine, branch, or hosted- ## Start-of-session algorithm 1. Locate the canonical `databreeze-platform` repository; do not implement in the legacy `Databreeze` directory. -2. Read repository `AGENTS.md` files, `docs/plans/README.md`, `002-complete-execution-orchestration.md`, `execution-orchestration.json`, the selected child plan, and the requirement records it owns. +2. Read repository `AGENTS.md` files, `docs/plans/README.md`, `002-complete-execution-orchestration.md`, `004-luna-max-execution-plan.md`, `execution-orchestration.json`, the selected child plan, and the requirement records it owns. 3. Fetch before trusting any recorded hash: ```powershell @@ -38,8 +38,9 @@ Use this runbook to resume DataBreeze after a model, machine, branch, or hosted- ``` If the offline Python cache is unavailable, use the documented online locked sync; do not alter the lock merely to make bootstrap pass. -8. Select `nextTaskId` only if every dependency is verified and no open PR or dirty worktree already owns it. Otherwise follow the table below and record the corrected next task. -9. State the selected task, assumptions, entry gate, expected files, tests, and stop conditions before mutation. If delegation is explicitly authorized, assign only disjoint paths with explicit integration ownership. +8. Select `activeBatchId` and `nextTaskId` only if every dependency is verified and no open PR or dirty worktree already owns them. Verify that the active batch contains the next task and that its current commit/file budgets remain safe. Otherwise follow the table below and record the corrected batch and task. +9. State the selected batch/task, assumptions, entry gate, expected files, tests, commit/file budget, and stop conditions before mutation. If delegation is explicitly authorized, assign only disjoint paths with explicit integration ownership. +10. Run package-manager operations sequentially within a worktree. Never start concurrent `pnpm install`, check, test, or build commands that share the same `node_modules` tree. ## Resume-state decision table @@ -57,6 +58,7 @@ Use this runbook to resume DataBreeze after a model, machine, branch, or hosted- | Dependency is only `implemented`, not `verified` | Finish its tests/evidence/review gate before the dependent task | Treating merged code as a verified dependency | | Baseline fails before task edits | Diagnose and document whether environment or repository caused it; repair in a `fix/*` unit or stop if unsafe | Attributing the failure to the new task or weakening the gate | | Migration/schema or generated-contract drift exists | Reconcile canonical sources, regeneration, migrations, and compatibility before feature work | Editing generated clients or database state manually | +| Package installation/check reports `EBUSY` or `EEXIST` in `node_modules/.pnpm` | Another package-manager process used the same worktree or a previous one was interrupted | Stop concurrent package-manager processes, preserve tracked files, rerun one frozen install sequentially, then rerun the failed gate; never delete tracked files or alter the lock to bypass it | | Required production/signing/business credential is unavailable | Complete all credential-independent code/tests/runbooks and stop at the explicit external gate | Using personal/untracked credentials or claiming release readiness | ## Atomic task execution loop @@ -89,7 +91,8 @@ For each `#### TASK-ID —` item in `002-complete-execution-orchestration.md`: 10. Inspect generated/runtime debris before commit. Do not commit `.venv`, `node_modules`, Gradle state, build output, logs, caches, secrets, local databases, Terraform state, or test reports unless the repository explicitly tracks a sanitized fixture. 11. Commit one independently reversible outcome with a semantic message. Do not combine contracts, an unrelated fix, and a different feature just to increase commit count. -12. Push after each stable task boundary. Update the ledger/checkpoint only with verified facts and leave a handoff record if stopping. +12. Recount the active batch against its base. Do not open a normal PR below 30 commits; target about 70, stop accepting new tasks at 90, and never exceed 99. Split before the promotion diff reaches 280 changed files; the packet target is 260. +13. Push after each stable task boundary. Update the ledger/checkpoint only with verified facts and leave a handoff record if stopping. ## Pull-request and CodeRabbit protocol @@ -145,6 +148,7 @@ Remote dev / main: Open feature PR / promotion PR: CodeRabbit invocation count, invocation timestamp (UTC), and review URL: Active plan / task ID: +Active delivery batch / commit count / changed-file count: Requirement IDs and statuses changed: Completed commits (hash — outcome): Checks run and exact results: @@ -165,11 +169,11 @@ The record supplements Git; it cannot claim `verified` without traceable test/re Copy this into the first Luna session and replace only the bracketed values discovered from live Git: ```text -You are resuming DataBreeze in the canonical databreeze-platform repository. Do not trust chat checkpoints until you fetch and verify Git/PR state. Read every applicable AGENTS.md plus docs/plans/README.md, docs/plans/002-complete-execution-orchestration.md, docs/plans/003-luna-handoff-runbook.md, docs/plans/execution-orchestration.json, the selected child plan, and its requirement-traceability records. +You are resuming DataBreeze in the canonical databreeze-platform repository. Do not trust chat checkpoints until you fetch and verify Git/PR state. Read every applicable AGENTS.md plus docs/plans/README.md, docs/plans/002-complete-execution-orchestration.md, docs/plans/003-luna-handoff-runbook.md, docs/plans/004-luna-max-execution-plan.md, docs/plans/execution-orchestration.json, the selected child plan, and its requirement-traceability records. Live verified checkpoint: branch [BRANCH], HEAD [HEAD], origin/dev [DEV], origin/main [MAIN], open feature PR [FEATURE_PR_OR_NONE], open dev→main promotion PR [PROMOTION_PR_OR_NONE]. Run the orchestration checker and the documented clean baseline before edits. Preserve all user changes and use an ignored worktree if isolation is needed. -Resume task [TASK_ID] only after proving its dependency/entry gate. Follow test-first atomic delivery: canonical contracts when the interface changes, failing domain/state tests, PostgreSQL migration/tenant/transaction/outbox tests when durable state changes, implementation through ports, vertical client/adapter coverage when the task involves client behavior, safe telemetry/recovery, traceability evidence, scoped checks, repo:check, repo:build, diff review, and one reversible commit. For documentation-only or other non-durable/non-client tasks, record why those conditional tests do not apply. Do not mark merged code verified without all evidence. +Resume batch [BATCH_ID] and task [TASK_ID] only after proving their dependency/entry gates, branch ownership, and commit/file budgets. Follow test-first atomic delivery: canonical contracts when the interface changes, failing domain/state tests, PostgreSQL migration/tenant/transaction/outbox tests when durable state changes, implementation through ports, vertical client/adapter coverage when the task involves client behavior, safe telemetry/recovery, traceability evidence, scoped checks, repo:check, repo:build, diff review, and one reversible commit. For documentation-only or other non-durable/non-client tasks, record why those conditional tests do not apply. Do not mark merged code verified without all evidence. Run pnpm installation/check/test/build commands sequentially within one worktree. Git flow is fixed: feat/* or fix/* → PR to dev with hosted checks and no CodeRabbit; merge preserving atomic commits; immediately open dev→main; request exactly one CodeRabbit full review there; reproduce every comment, fix only valid findings, document rejected ones, never request a second review on that PR. Prefer 30–70 commits, hard cap 99, and do not invoke the promotion review over 280 changed files. diff --git a/docs/plans/004-luna-max-execution-plan.md b/docs/plans/004-luna-max-execution-plan.md new file mode 100644 index 00000000..4b4f54bd --- /dev/null +++ b/docs/plans/004-luna-max-execution-plan.md @@ -0,0 +1,159 @@ +# Luna Max Complete Implementation Execution Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` when delegation is explicitly authorized, or `superpowers:executing-plans` for inline delivery. Execute one orchestration task at a time and preserve the checkbox and handoff state in the authoritative records. + +**Goal:** Give Luna Max a deterministic, resumable route from the current foundation checkpoint through all 611 DataBreeze requirements, grouped into reviewable promotion batches that preserve atomic rollback commits. + +**Architecture:** `002-complete-execution-orchestration.md` owns the 155-task execution catalog and dependency graph; the numbered child plans own requirement scope; `requirement-traceability.json` owns requirement status and evidence. This plan packages every unfinished task into dependency-safe delivery batches, assigns shared-file ownership, fixes the Git/CodeRabbit flow, and provides the exact first-session bootstrap. + +**Tech Stack:** pnpm/Turborepo, strict TypeScript, NestJS/Fastify, Prisma/PostgreSQL 17, Redis 7.4, S3-compatible storage, Electron, Kotlin/Compose, Python 3.13, OpenAPI/JSON Schema, OpenTofu/AWS Singapore, GitHub Actions, and CodeRabbit. + +## Global Constraints + +- Work only in the canonical `databreeze-platform` repository. The legacy repositories are reference-only. +- Preserve the DataBreeze name and checksum-pinned logo sources without redrawing, recoloring, or adding duplicate wordmarks. +- PostgreSQL is authoritative; Redis is disposable. Local, Hybrid, and Cloud data-mode rules fail closed. +- Every durable operation is tenant-scoped, revision-aware, idempotent, auditable, and recoverable. Immutable records are corrected with successor versions or compensating entries. +- Workers, Desktop, and Android accept signed typed actions and scoped handles only; they never receive arbitrary commands, unrestricted paths, or database credentials. +- Vietnamese is the complete default locale and English is complete for every delivered client slice. +- Requirement status is evidence-based: merged code is not automatically `verified` or `released`. +- Normal feature PRs contain at least 30 commits, target about 70, and remain below 100. Empty, padding, or artificially split commits are forbidden. +- Feature/fix PRs target `dev` without CodeRabbit. The corresponding `dev` to `main` promotion receives exactly one full CodeRabbit review after hosted checks are otherwise ready. +- Keep the promotion diff at or below 260 changed files, leaving safety margin under the 280-file review stop gate. +- Never run package-manager commands concurrently in the same worktree. `pnpm install`, checks, tests, and builds share `node_modules` and execute sequentially there. + +--- + +## 1. Verified starting checkpoint + +This checkpoint was reconciled on 2026-08-02 after the latest promotion: + +| Item | Verified value | +|---|---| +| Integration branch | `origin/dev` at `783a4710c0aa2a2808d78ad7f0643e6731150bd7` | +| Stable branch | `origin/main` at `3ed3d77d0281ef239d0509c81ded447d8fffd213` | +| Last normal feature PR | PR #19, 73 commits, `feat/fnd003-local-infra-batch` to `dev` | +| Last promotion PR | PR #20, `dev` to `main` | +| Promotion review fixes | PRs #21, #22, and #23 back to `dev` | +| Open PRs observed | None | +| Requirement ledger | 611 total: 608 `planned`, 3 `partial`, 0 `verified` | +| Next orchestration task | `FND-003` | +| Active delivery batch | `B01` | + +`FND-001` and `FND-002` have verified task evidence. `FND-003` through `FND-007` contain substantial merged implementation, but live Docker, OpenTofu, protected-environment, and final clean-checkout evidence remain conservative gates. Plans 020 through 050 also contain merged code that must be reconciled before any missing behavior is implemented. Do not recreate those foundations blindly. + +## 2. Authority and state ownership + +Read and apply these files in this order after `AGENTS.md`, accepted ADRs, and specifications: + +1. `docs/plans/README.md` +2. The selected numbered child plan for requirement ownership and release obligations +3. `docs/plans/002-complete-execution-orchestration.md` for task and repository-path authority +4. This plan for delivery-batch ownership +5. `docs/plans/execution-orchestration.json` for live machine state +6. `docs/plans/003-luna-handoff-runbook.md` for resume and Git/PR procedure +7. The selected requirement records in `docs/plans/requirement-traceability.json` + +The Markdown files explain intent; the fetched Git graph and PR state decide what exists. If the machine ledger and Git disagree, stop feature mutation, reconcile the ledger in a focused commit, run the orchestration checker, and then resume. + +Some early child plans contain generic aggregate `Paths` examples. Do not create those directories. The module-owned API/Prisma/client/engine paths and deterministic Android/Python keys in Section 4 of `002-complete-execution-orchestration.md` supersede those examples. + +## 3. Delivery-batch map + +Each batch is one normal integration PR and one promotion PR unless the changed-file safety gate forces a split. Commit ranges are planning budgets, not quotas. If a coherent batch finishes below 30 commits, keep the branch open and continue the next compatible task; do not open a small PR merely to reset the counter. + +| Batch | Branch | Tasks | Dependencies | Commit budget | Exit gate | +|---|---|---|---|---|---| +| `B01` | `feat/foundation-identity-completion` | `FND-003..007`, all Plan 020 tasks | Verified `FND-001/002` | 50–85, target 70 | Foundation external gates recorded; IAM/AUD/BUA obligations reconciled and completed | +| `B02` | `feat/artifacts-datasets-completion` | All Plan 030 tasks | `B01` | 40–75, target 65 | Immutable artifact/evidence/dataset foundations verified | +| `B03` | `feat/jobs-processing-completion` | All Plan 040 tasks | `B02` | 45–80, target 70 | Signed typed jobs execute locally/cloud with approvals and durable recovery | +| `B04` | `feat/devices-sync-completion` | All Plan 050 tasks | `B03` | 45–80, target 70 | Desktop/Android sync, offline, conflict, transfer, and revocation gates pass | +| `B05` | `feat/collaboration-integrations` | All Plan 060 tasks | `B04` | 45–80, target 70 | Notifications, collaboration, public API, connectors, and webhooks pass | +| `B06` | `feat/dogfood-autopilot-core` | `DOG-001..007`, `FA-001..003` | `B05` | 45–75, target 65 | Ten-condition dogfood record accepted; safe Autopilot intake/routing exists | +| `B07` | `feat/autopilot-spreadsheet-auditor` | `FA-004..007`, `SA-001..007` | `B06` | 50–85, target 70 | Folder Autopilot and Spreadsheet Auditor P0/P1 gates pass | +| `B08` | `feat/quote-invoice-intelligence` | `QI-001..007`, then `ILD-001..007` | `B06` | 60–90, target 75 | Quote Intelligence and Invoice Leak Detector P0/P1 gates pass | +| `B09` | `feat/operations-capture` | `OC-001..008` | `B06` | 40–75, target 65 | Offline native capture, immutable submission, supervision, and reconciliation pass | +| `B10` | `feat/client-report-factory` | `CRF-001..007` | `B07`, `B08` | 40–75, target 65 | Evidence-linked multi-format reports and revocable sharing pass | +| `B11` | `feat/private-data-analyst` | `PDA-001..008` | `B09`, `B10` | 45–80, target 70 | Deterministic governed analysis and optional-AI boundaries pass | +| `B12` | `feat/migration-quality-suite` | `MR-001..007`, then `DQG-001..008` | `B08`, `B11` | 65–95, target 80 | Migration Ready and Data Quality Guard P0/P1 gates pass | +| `B13` | `feat/embedded-importer` | `EI-001..007` | `B05` | 35–70, target 60 | Hosted importer and outbound-only local gateway pass hostile tests | +| `B14` | `feat/production-readiness` | `GA-001..012` | `B12`, `B13` | 60–90, target 75 | Every P0/P1 requirement is verified and coordinated GA is released | +| `B15` | `feat/post-ga-extensions` | `P2-001..004` | `B14` | 35–70, target 60 | All 13 P2 requirements are opt-in, revocable, and verified | + +The machine-readable `deliveryBatches` array is authoritative for exact task membership. Its checker rejects missing or duplicate task ownership, dependency cycles, a batch below the 30-commit minimum, a maximum of 100 or more, and an active batch that does not contain `nextTaskId`. + +## 4. Parallel execution and integration ownership + +The foundation spine `B01` through `B06` is serial. After `B06`, `B07`, `B08`, and `B09` may run in separate worktrees. `B13` may start after `B05` and proceed alongside `B06` through `B12`. All other dependencies in the table remain hard gates. + +Before parallel work begins, the integration owner records: + +- the exact `origin/dev` base for every branch; +- one migration timestamp range per branch; +- canonical schema namespaces owned by that branch; +- feature-directory ownership for API, Web, Desktop, Android, and engine; +- the merge order and the person/model responsible for generated aggregate conflicts. + +Only the integration owner edits shared aggregators during a parallel merge: `services/api/src/app.module.ts`, generated OpenAPI aggregates, Prisma aggregate configuration, package export maps, root Web route registration, root localization catalogs, `requirement-traceability.json`, and `execution-orchestration.json`. Feature workers modify module-owned canonical sources and tests. After each merge, the integration owner regenerates contracts and reruns drift checks before the next branch merges. + +Recommended merge queue after the parallel wave is `B13`, `B07`, `B08`, `B09`, `B10`, `B11`, `B12`. Completion time may differ; dependency and shared-file safety decide merge order, not which worker finishes first. + +## 5. Atomic task recipe + +For every `#### TASK-ID —` entry in `002-complete-execution-orchestration.md`, Luna performs this exact cycle: + +- [ ] Read the owning requirements, accepted ADRs, current implementation, and existing tests. Record which obligations already exist and which remain. +- [ ] Reserve canonical schema names and migration ordering before editing shared interfaces. +- [ ] Write the failing contract, domain, policy, state-machine, or repository test that proves the missing behavior. Run it and confirm the expected failure. +- [ ] Add the smallest domain/application implementation needed for that test. Re-run the narrow test. +- [ ] When durable state changes, add the ordered migration and real PostgreSQL tests for tenant scope, transactions, concurrency, idempotency, and compensating rollback. +- [ ] Add adapters and only the platform surfaces owned by the task. Use generated contracts at every client/worker boundary. +- [ ] Add negative privacy/security tests, bounded failure behavior, content-safe telemetry, recovery, and rollback notes. +- [ ] Run the owning package tests, contract drift, `corepack pnpm repo:check`, `corepack pnpm repo:build`, and `git diff --check` sequentially. +- [ ] Update only the trace records supported by exact code, test, and evidence paths. Update task and batch state using immutable commit hashes. +- [ ] Commit one independently reversible outcome and push at the stable task boundary. + +Typical reversible commits inside a task are: canonical contract, domain behavior, migration/repository, adapter/API, client vertical slice, and verification/evidence. Omit a category that genuinely does not apply and record why; never create padding commits. + +## 6. PR and promotion algorithm + +1. Count commits and changed files against the batch base before opening anything. +2. Do not open the normal PR below 30 commits. At 60–75 commits, finish the current atomic task and prepare the PR. At 90 commits, stop accepting new tasks. At 99 commits, the branch is at the hard boundary and must not receive another commit before scope is split or promoted. +3. If the branch exceeds 260 changed files, split at a completed task boundary before review. Do not split a migration from its code/tests or a canonical schema from generated consumers. +4. Open `feat/*` or `fix/*` to `dev`. Run hosted checks and merge with a merge commit that preserves atomic commits. Do not invoke CodeRabbit. +5. Immediately open `dev` to `main`. When otherwise ready, request one full CodeRabbit review and record the invocation. +6. Reproduce every comment against the exact reviewed commit. Fix valid findings through focused commits merged back to `dev`; document rejected findings with tests or authoritative references. Do not request another review. +7. Merge the promotion only after required checks are green and all valid findings are resolved. Fetch both branches, record merge hashes, and activate the next dependency-ready batch. + +Focused promotion-gate fixes may use a smaller PR to `dev` because they close an already-reviewed promotion. They do not reset or weaken the next normal batch’s 30-commit minimum. + +## 7. First Luna Max session + +The active branch is `feat/foundation-identity-completion`, based on `origin/dev` at `783a4710c0aa2a2808d78ad7f0643e6731150bd7`. Its first commit is this orchestration update; continue on the same branch until `B01` reaches a coherent 50–85 commit boundary. + +Run these commands sequentially: + +```powershell +git fetch --all --prune +git status --short --branch +git rev-parse HEAD +git rev-parse origin/dev +git rev-parse origin/main +gh pr list --state open --limit 100 --json number,title,headRefName,baseRefName,isDraft,statusCheckRollup,url +corepack pnpm install --frozen-lockfile +corepack pnpm orchestration:check +corepack pnpm requirements:check +``` + +Then resume `FND-003`: + +1. Run the Docker-capable checks in `docs/operations/foundation-local-infrastructure-2026-08-02.md` when Docker Desktop/Compose v2 is available. +2. If Docker remains unavailable, preserve `FND-003` as incomplete, finish only credential-independent `FND-004..007` evidence, and record the external gate. Do not claim foundation verification. +3. Reconcile Plans 020–050 against merged code before implementing any missing behavior. For `B01`, complete Plan 020 only after the remaining foundation boundaries are explicit. +4. End every session with the handoff record from `003-luna-handoff-runbook.md`, including exact branch/HEAD, open PRs, checks, task/batch status, rollback points, and safest next command. + +## 8. Completion and stop rules + +The program is complete only when `GA-012` is released and all P0/P1 requirements are `released`; `B15` completes the separately authorized P2 scope. A plan file, UI mock, green unit test, or merged PR is not product completion. + +Stop without widening scope when specs conflict, customer/user changes overlap, a migration risks unrecoverable data, a security/privacy boundary cannot fail closed, signing or production authority is missing, or a required test remains nondeterministic after diagnosis. Preserve the branch, commits, evidence, and exact smallest decision needed. Never reset, delete, force-push, or silently downgrade a gate to keep the schedule moving. diff --git a/docs/plans/README.md b/docs/plans/README.md index de83740b..dfa45462 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -14,11 +14,12 @@ Each plan must: The planning authorities are: 1. `000-platform-program.md` — stable program policy and release gates. -2. `001-engineering-foundation.md` — historical foundation planning record. +2. `001-plan-package.md` — historical record for creating the child-plan and traceability package. 3. `002-complete-execution-orchestration.md` — implementation DAG, atomic task catalog, cross-plan gates, and edge cases. 4. `003-luna-handoff-runbook.md` — deterministic session resume, Git/PR, CodeRabbit, recovery, and handoff protocol. -5. `execution-orchestration.json` — machine-readable plan/task/dependency ledger and next-task pointer. -6. `requirement-traceability.json` — 611 unique requirement assignments (P0 444, P1 154, P2 13) and their implementation/test/release evidence. +5. `004-luna-max-execution-plan.md` — live checkpoint, 15 delivery batches, commit/file budgets, parallel lanes, and the exact Luna Max starting packet. +6. `execution-orchestration.json` — machine-readable plan/task/batch dependency ledger and active batch/next-task pointer. +7. `requirement-traceability.json` — 611 unique requirement assignments (P0 444, P1 154, P2 13) and their implementation/test/release evidence. The dependency-ordered child plans are: @@ -42,6 +43,6 @@ The dependency-ordered child plans are: 18. `400-production-readiness.md` 19. `500-post-ga-extensions.md` -Run `corepack pnpm orchestration:check` before selecting or handing off an implementation task. A child plan owns requirement scope; the orchestration plan owns execution order and task boundaries; the traceability manifest owns evidence status. Git and fetched pull-request state override historical checkpoint hashes. +Run `corepack pnpm orchestration:check` before selecting or handing off an implementation task. A child plan owns requirement scope; the orchestration plan owns task order; the Luna Max execution plan owns delivery batching; the traceability manifest owns evidence status. Git and fetched pull-request state override historical checkpoint hashes. A plan does not change product scope. Any conflict returns to the specification review process. diff --git a/docs/plans/execution-orchestration.json b/docs/plans/execution-orchestration.json index 84f853b2..37be5e90 100644 --- a/docs/plans/execution-orchestration.json +++ b/docs/plans/execution-orchestration.json @@ -1,10 +1,11 @@ { - "version": 1, + "version": 2, "program": "DataBreeze complete platform delivery", "authority": { "requirements": "docs/plans/requirement-traceability.json", "execution": "docs/plans/002-complete-execution-orchestration.md", - "resumeProtocol": "docs/plans/003-luna-handoff-runbook.md" + "resumeProtocol": "docs/plans/003-luna-handoff-runbook.md", + "deliveryBatches": "docs/plans/004-luna-max-execution-plan.md" }, "requirementTotals": { "total": 611, @@ -15,13 +16,14 @@ } }, "checkpoint": { - "observedAt": "2026-08-02T12:00:00Z", - "remoteDev": "92b3e9a4d581f3a6947b7a2bf58c8334f4ae0c18", - "remoteMain": "a2fcba34037c1ffd77816be16be75453abfb16fa", - "lastFeaturePullRequest": 18, - "lastPromotionPullRequest": 14, + "observedAt": "2026-08-02T13:38:28Z", + "remoteDev": "783a4710c0aa2a2808d78ad7f0643e6731150bd7", + "remoteMain": "3ed3d77d0281ef239d0509c81ded447d8fffd213", + "lastFeaturePullRequest": 19, + "lastPromotionPullRequest": 20, + "lastPromotionFixPullRequest": 23, "openPullRequestsObserved": 0, - "note": "Historical observation only; every session must fetch and recompute current state." + "note": "PR 19 merged a 73-commit foundation batch to dev; PR 20 promoted dev to main after CodeRabbit review and PRs 21-23 delivered validated review fixes. Historical observation only; every session must fetch and recompute current state." }, "reviewPolicy": { "featurePullRequest": { @@ -53,6 +55,7 @@ "blocked" ], "nextTaskId": "FND-003", + "activeBatchId": "B01", "taskState": { "FND-001": { "status": "verified", @@ -80,7 +83,7 @@ }, "FND-003": { "status": "in-progress", - "commit": "d9598a2", + "commit": "783a4710c0aa2a2808d78ad7f0643e6731150bd7", "evidence": [ "infrastructure/local/compose.yml", "infrastructure/local/README.md", @@ -89,16 +92,245 @@ "tools/repo-cli/test/local-infrastructure.test.mjs", "docs/operations/foundation-local-infrastructure-2026-08-02.md" ], - "note": "Static Compose/bootstrap/lifecycle checks, daemon-free config/preflight, bounded local diagnostics/log retention, AWS safety checks, telemetry redaction tests, and infrastructure path-aware CI pass. Live Docker health, port-collision, disk-pressure, and restart-persistence evidence remains pending because the Docker daemon is unavailable in this environment." + "note": "Static Compose/bootstrap/lifecycle checks, daemon-free config/preflight, bounded local diagnostics/log retention, AWS safety checks, telemetry redaction tests, and infrastructure path-aware CI are integrated and promoted. Live Docker health, port-collision, disk-pressure, and restart-persistence evidence remains pending because the Docker daemon was unavailable in the implementation environment." + }, + "FND-004": { + "status": "implemented", + "commit": "3ed3d77d0281ef239d0509c81ded447d8fffd213", + "evidence": [ + "infrastructure/aws/README.md", + "infrastructure/aws/environments/alpha/main.tf", + "tools/repo-cli/src/check-aws-infrastructure.mjs", + "tools/repo-cli/test/aws-infrastructure.test.mjs" + ], + "note": "Portable AWS modules and static safety checks are promoted. Pinned OpenTofu fmt/init/validate and any reviewed plan/apply evidence remain external gates." + }, + "FND-005": { + "status": "implemented", + "commit": "3ed3d77d0281ef239d0509c81ded447d8fffd213", + "evidence": [ + "docs/operations/telemetry-safety.md", + "packages/telemetry/test/telemetry-v1.test.mjs", + "services/engine/tests/test_telemetry.py" + ], + "note": "TypeScript and Python telemetry boundaries, hostile-input normalization, and redaction tests are promoted; final clean-checkout reconciliation remains in B01." + }, + "FND-006": { + "status": "implemented", + "commit": "3ed3d77d0281ef239d0509c81ded447d8fffd213", + "evidence": [ + "docs/operations/foundation-ci-supply-chain-2026-08-02.md", + "tools/repo-cli/src/check-ci-policy.mjs", + "tools/repo-cli/test/ci-policy.test.mjs", + "tools/repo-cli/test/provenance.test.mjs", + "tools/repo-cli/test/sbom.test.mjs" + ], + "note": "Hosted checks, supply-chain policy, SBOM, and provenance safeguards are promoted. GitHub release-environment protection remains an administrator-recorded external gate." + }, + "FND-007": { + "status": "partial-needs-reconciliation", + "commit": "3ed3d77d0281ef239d0509c81ded447d8fffd213", + "evidence": [ + "docs/development/README.md", + "docs/operations/engineering-foundation-verification.md", + "docs/operations/deployment-and-rollback.md", + "docs/operations/secret-rotation.md", + "docs/operations/provider-adapters.md" + ], + "note": "Runbooks and static evidence exist; B01 must reconcile live external gates and publish the final foundation handoff before advancing plan status." } }, + "deliveryBatches": [ + { + "batchId": "B01", + "name": "Foundation verification and identity completion", + "branch": "feat/foundation-identity-completion", + "dependencies": [], + "status": "in-progress", + "commitBudget": { "minimum": 30, "target": 70, "maximum": 85 }, + "maximumChangedFiles": 260, + "taskIds": [ + "FND-003", + "FND-004", + "FND-005", + "FND-006", + "FND-007", + "IAM-001", + "IAM-002", + "IAM-003", + "IAM-004", + "IAM-005", + "AUD-001", + "AUD-002", + "BUA-001", + "BUA-002", + "IAM-006", + "IAM-007" + ], + "exitGate": "Foundation external gates are explicit and IAM, AUD, and BUA requirements are reconciled, completed, tested, and evidenced." + }, + { + "batchId": "B02", + "name": "Artifacts, evidence, datasets, and definitions", + "branch": "feat/artifacts-datasets-completion", + "dependencies": ["B01"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "maximumChangedFiles": 260, + "taskIds": ["IAE-001", "IAE-002", "IAE-003", "IAE-004", "IAE-005", "DSM-001", "DSM-002", "DSM-003", "IAE-006", "IAE-007"], + "exitGate": "Local, Hybrid, and Cloud artifact, evidence, dataset, definition, retention, and deletion gates pass." + }, + { + "batchId": "B03", + "name": "Jobs, processing, findings, reviews, and approvals", + "branch": "feat/jobs-processing-completion", + "dependencies": ["B02"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "maximumChangedFiles": 260, + "taskIds": ["JRA-001", "JRA-002", "JRA-003", "JRA-004", "JRA-005", "JRA-006", "JRA-007", "JRA-008", "JRA-009", "JRA-010", "JRA-011"], + "exitGate": "The same signed typed action executes locally or in cloud with durable admission, evidence, approval, recovery, usage, and audit outcomes." + }, + { + "batchId": "B04", + "name": "Devices, synchronization, and offline operation", + "branch": "feat/devices-sync-completion", + "dependencies": ["B03"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "maximumChangedFiles": 260, + "taskIds": ["DSO-001", "DSO-002", "DSO-003", "DSO-004", "DSO-005", "DSO-006", "DSO-007", "DSO-008", "DSO-009", "DSO-010"], + "exitGate": "Desktop and Android operate offline, resume idempotently, expose conflicts, preserve data modes, and fail closed after revocation." + }, + { + "batchId": "B05", + "name": "Collaboration, notifications, public API, and integrations", + "branch": "feat/collaboration-integrations", + "dependencies": ["B04"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "maximumChangedFiles": 260, + "taskIds": ["NCO-001", "NCO-002", "NCO-003", "NCO-004", "INT-001", "INT-002", "INT-003", "INT-004", "NCO-005", "INT-005"], + "exitGate": "Collaboration and external access use shared contracts and replaceable adapters without restricted or undocumented APIs." + }, + { + "batchId": "B06", + "name": "Three-platform dogfood and Autopilot core", + "branch": "feat/dogfood-autopilot-core", + "dependencies": ["B05"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "maximumChangedFiles": 260, + "taskIds": ["DOG-001", "DOG-002", "DOG-003", "DOG-004", "DOG-005", "DOG-006", "DOG-007", "FA-001", "FA-002", "FA-003"], + "exitGate": "All ten dogfood conditions pass and Autopilot has governed bindings, routing, watchers, and reconciliation." + }, + { + "batchId": "B07", + "name": "Folder Autopilot and Spreadsheet Auditor", + "branch": "feat/autopilot-spreadsheet-auditor", + "dependencies": ["B06"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 70, "maximum": 85 }, + "maximumChangedFiles": 260, + "taskIds": ["FA-004", "FA-005", "FA-006", "FA-007", "SA-001", "SA-002", "SA-003", "SA-004", "SA-005", "SA-006", "SA-007"], + "exitGate": "Folder Autopilot and Spreadsheet Auditor P0/P1 requirements are verified without mutating originals." + }, + { + "batchId": "B08", + "name": "Quote and invoice intelligence", + "branch": "feat/quote-invoice-intelligence", + "dependencies": ["B06"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 75, "maximum": 90 }, + "maximumChangedFiles": 260, + "taskIds": ["QI-001", "QI-002", "QI-003", "QI-004", "QI-005", "QI-006", "QI-007", "ILD-001", "ILD-002", "ILD-003", "ILD-004", "ILD-005", "ILD-006", "ILD-007"], + "exitGate": "Quote Intelligence and Invoice Leak Detector P0/P1 requirements are verified with exact source evidence and no vendor API dependency." + }, + { + "batchId": "B09", + "name": "Operations Capture", + "branch": "feat/operations-capture", + "dependencies": ["B06"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "maximumChangedFiles": 260, + "taskIds": ["OC-001", "OC-002", "OC-003", "OC-004", "OC-005", "OC-006", "OC-007", "OC-008"], + "exitGate": "Offline native capture, immutable submissions, correction, supervision, and Desktop reconciliation pass P0/P1 gates." + }, + { + "batchId": "B10", + "name": "Client Report Factory", + "branch": "feat/client-report-factory", + "dependencies": ["B07", "B08"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "maximumChangedFiles": 260, + "taskIds": ["CRF-001", "CRF-002", "CRF-003", "CRF-004", "CRF-005", "CRF-006", "CRF-007"], + "exitGate": "Evidence-linked multi-format reports, review, release, scheduling, and revocable sharing pass P0/P1 gates." + }, + { + "batchId": "B11", + "name": "Private Data Analyst", + "branch": "feat/private-data-analyst", + "dependencies": ["B09", "B10"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "maximumChangedFiles": 260, + "taskIds": ["PDA-001", "PDA-002", "PDA-003", "PDA-004", "PDA-005", "PDA-006", "PDA-007", "PDA-008"], + "exitGate": "Governed analysis is deterministic and reproducible; optional AI can propose but never supply numeric truth." + }, + { + "batchId": "B12", + "name": "Migration Ready and Data Quality Guard", + "branch": "feat/migration-quality-suite", + "dependencies": ["B08", "B11"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 80, "maximum": 95 }, + "maximumChangedFiles": 260, + "taskIds": ["MR-001", "MR-002", "MR-003", "MR-004", "MR-005", "MR-006", "MR-007", "DQG-001", "DQG-002", "DQG-003", "DQG-004", "DQG-005", "DQG-006", "DQG-007", "DQG-008"], + "exitGate": "Migration Ready and Data Quality Guard P0/P1 requirements are verified with export-first, immutable, evidence-backed behavior." + }, + { + "batchId": "B13", + "name": "Embedded Importer", + "branch": "feat/embedded-importer", + "dependencies": ["B05"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 60, "maximum": 70 }, + "maximumChangedFiles": 260, + "taskIds": ["EI-001", "EI-002", "EI-003", "EI-004", "EI-005", "EI-006", "EI-007"], + "exitGate": "Hosted importer and outbound-only Desktop gateway pass tenant, origin, upload, replay, accessibility, and local/cloud parity gates." + }, + { + "batchId": "B14", + "name": "Production readiness and coordinated GA", + "branch": "feat/production-readiness", + "dependencies": ["B12", "B13"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 75, "maximum": 90 }, + "maximumChangedFiles": 260, + "taskIds": ["GA-001", "GA-002", "GA-003", "GA-004", "GA-005", "GA-006", "GA-007", "GA-008", "GA-009", "GA-010", "GA-011", "GA-012"], + "exitGate": "Every P0/P1 requirement is verified, artifacts are signed and recoverable, and the coordinated GA release is observed and recorded." + }, + { + "batchId": "B15", + "name": "Post-GA opt-in extensions", + "branch": "feat/post-ga-extensions", + "dependencies": ["B14"], + "status": "post-ga-planned", + "commitBudget": { "minimum": 30, "target": 60, "maximum": 70 }, + "maximumChangedFiles": 260, + "taskIds": ["P2-001", "P2-002", "P2-003", "P2-004"], + "exitGate": "All 13 P2 requirements are opt-in, disabled by default, provider-exitable, revocable, and verified." + } + ], "plans": [ { "planId": "010", "file": "010-engineering-foundation.md", "requirementCount": 0, "dependencies": [], - "status": "partial-needs-reconciliation", + "status": "in-progress", "taskIds": ["FND-001", "FND-002", "FND-003", "FND-004", "FND-005", "FND-006", "FND-007"] }, { diff --git a/packages/domain/package.json b/packages/domain/package.json index ea09ca1c..b2fe790f 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -52,6 +52,14 @@ "types": "./src/data-mode/v1.ts", "import": "./dist/data-mode/v1.js" }, + "./pkce/v1": { + "types": "./src/pkce/v1.ts", + "import": "./dist/pkce/v1.js" + }, + "./csrf/v1": { + "types": "./src/csrf/v1.ts", + "import": "./dist/csrf/v1.js" + }, "./artifact/v1": { "types": "./src/artifact/v1.ts", "import": "./dist/artifact/v1.js" diff --git a/packages/domain/src/csrf/v1.ts b/packages/domain/src/csrf/v1.ts new file mode 100644 index 00000000..a1c2e1c3 --- /dev/null +++ b/packages/domain/src/csrf/v1.ts @@ -0,0 +1,43 @@ +/** IAM-002: browser mutation requests use a validated, session-bound CSRF token. */ +export const CSRF_SCHEMA_VERSION_V1 = 1 as const; + +const MIN_TOKEN_LENGTH_V1 = 32; +const MAX_TOKEN_LENGTH_V1 = 256; +const TOKEN_PATTERN_V1 = /^[A-Za-z0-9_-]+$/u; + +export type CsrfTokenResultV1 = + | { readonly accepted: true; readonly value: string } + | { readonly accepted: false; readonly code: 'INVALID_TOKEN' }; + +function rejected(): CsrfTokenResultV1 { + return Object.freeze({ accepted: false as const, code: 'INVALID_TOKEN' as const }); +} + +/** Validate the encoded token before it is bound to a browser session. */ +export function validateCsrfTokenV1(input: unknown): CsrfTokenResultV1 { + if (typeof input !== 'string') return rejected(); + if (input.length < MIN_TOKEN_LENGTH_V1 || input.length > MAX_TOKEN_LENGTH_V1) return rejected(); + if (!TOKEN_PATTERN_V1.test(input)) return rejected(); + return Object.freeze({ accepted: true as const, value: input }); +} + +/** + * Compare cookie and header values without an early return on the first mismatch. + * Invalid values are deliberately treated as a mismatch so callers fail closed. + */ +export function compareCsrfTokensV1(cookieToken: unknown, headerToken: unknown): boolean { + const cookie = validateCsrfTokenV1(cookieToken); + const header = validateCsrfTokenV1(headerToken); + if (!cookie.accepted || !header.accepted) return false; + + const left = cookie.value; + const right = header.value; + const length = Math.max(left.length, right.length); + let difference = left.length ^ right.length; + for (let index = 0; index < length; index += 1) { + const leftCode = index < left.length ? left.charCodeAt(index) : 0; + const rightCode = index < right.length ? right.charCodeAt(index) : 0; + difference |= leftCode ^ rightCode; + } + return difference === 0; +} diff --git a/packages/domain/src/pkce/v1.ts b/packages/domain/src/pkce/v1.ts new file mode 100644 index 00000000..cb6e8385 --- /dev/null +++ b/packages/domain/src/pkce/v1.ts @@ -0,0 +1,82 @@ +/** IAM-002: provider-independent RFC 7636 S256 validation for native sign-in. */ +export const PKCE_SCHEMA_VERSION_V1 = 1 as const; +export const PKCE_VERIFIER_MIN_LENGTH_V1 = 43 as const; +export const PKCE_VERIFIER_MAX_LENGTH_V1 = 128 as const; + +export interface PkceChallengeV1 { + readonly schemaVersion: typeof PKCE_SCHEMA_VERSION_V1; + readonly method: 'S256'; + readonly challenge: string; +} + +export type PkceResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: 'INVALID_VERIFIER' | 'CRYPTO_UNAVAILABLE' }; + +export interface PkceHashPortV1 { + sha256Base64Url(value: string): string; +} + +function validVerifier(input: unknown): input is string { + return ( + typeof input === 'string' && + input.length >= PKCE_VERIFIER_MIN_LENGTH_V1 && + input.length <= PKCE_VERIFIER_MAX_LENGTH_V1 && + /^[A-Za-z0-9\-._~]+$/u.test(input) + ); +} + +function equalStrings(left: string, right: string): boolean { + if (left.length !== right.length) return false; + let difference = 0; + for (let index = 0; index < left.length; index += 1) + difference |= left.charCodeAt(index) ^ right.charCodeAt(index); + return difference === 0; +} + +export function createPkceChallengeV1( + verifierInput: unknown, + hashPort: PkceHashPortV1, +): PkceResultV1 { + if (!validVerifier(verifierInput)) + return Object.freeze({ accepted: false, code: 'INVALID_VERIFIER' }); + try { + const challenge = hashPort.sha256Base64Url(verifierInput); + if (!/^[A-Za-z0-9_-]{43}$/u.test(challenge)) + return Object.freeze({ accepted: false, code: 'CRYPTO_UNAVAILABLE' }); + return Object.freeze({ + accepted: true, + value: Object.freeze({ + schemaVersion: PKCE_SCHEMA_VERSION_V1, + method: 'S256' as const, + challenge, + }), + }); + } catch { + return Object.freeze({ accepted: false, code: 'CRYPTO_UNAVAILABLE' }); + } +} + +export function verifyPkceChallengeV1( + verifierInput: unknown, + challengeInput: unknown, + hashPort: PkceHashPortV1, +): boolean { + if (typeof challengeInput !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(challengeInput)) + return false; + const created = createPkceChallengeV1(verifierInput, hashPort); + return created.accepted && equalStrings(created.value.challenge, challengeInput); +} + +export function isAllowedRedirectUriV1(input: unknown): boolean { + if (typeof input !== 'string' || input.length > 200) return false; + if ( + input === 'com.databreeze.desktop:/oauth2redirect' || + input === 'com.databreeze.android:/oauth2redirect' + ) + return true; + const match = /^http:\/\/127\.0\.0\.1:(\d{1,5})\/callback$/u.exec(input); + if (!match) return false; + const port = Number(match[1]); + return port >= 1 && port <= 65_535; +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index e0073f2d..4f5b9a48 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -23,5 +23,7 @@ export * from './device-authorization/v1.js'; export * from './device-sync/v1.js'; export * from './device-capability/v1.js'; export * from './data-mode/v1.js'; +export * from './pkce/v1.js'; +export * from './csrf/v1.js'; export * from './permissions/v1.js'; export * from './tenant-scope/v1.js'; diff --git a/packages/domain/test/csrf-v1.test.mjs b/packages/domain/test/csrf-v1.test.mjs new file mode 100644 index 00000000..4dc93cb6 --- /dev/null +++ b/packages/domain/test/csrf-v1.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + compareCsrfTokensV1, + CSRF_SCHEMA_VERSION_V1, + validateCsrfTokenV1, +} from '../dist/csrf/v1.js'; + +test('[IAM-002] CSRF accepts a high-entropy token and compares equal values', () => { + const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; + const result = validateCsrfTokenV1(token); + + assert.equal(CSRF_SCHEMA_VERSION_V1, 1); + assert.deepEqual(result, { accepted: true, value: token }); + assert.equal(compareCsrfTokensV1(token, token), true); +}); + +test('[IAM-002] CSRF rejects missing, malformed, and oversized tokens', () => { + assert.equal(validateCsrfTokenV1(undefined).accepted, false); + assert.equal(validateCsrfTokenV1('too-short').accepted, false); + assert.equal(validateCsrfTokenV1('a'.repeat(257)).accepted, false); + assert.equal(validateCsrfTokenV1('token with spaces').accepted, false); + assert.equal(validateCsrfTokenV1('token\nwith-control').accepted, false); +}); + +test('[IAM-002] CSRF comparison fails closed for mismatches and malformed inputs', () => { + const valid = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; + + assert.equal(compareCsrfTokensV1(valid, `${valid}x`), false); + assert.equal(compareCsrfTokensV1(valid, 'different-token-value-123456789012345678901234'), false); + assert.equal(compareCsrfTokensV1(valid, undefined), false); + assert.equal(compareCsrfTokensV1('short', valid), false); +}); diff --git a/packages/domain/test/pkce-v1.test.mjs b/packages/domain/test/pkce-v1.test.mjs new file mode 100644 index 00000000..294fdc75 --- /dev/null +++ b/packages/domain/test/pkce-v1.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { + createPkceChallengeV1, + isAllowedRedirectUriV1, + verifyPkceChallengeV1, +} from '../dist/pkce/v1.js'; + +const hashPort = { + sha256Base64Url(value) { + return createHash('sha256').update(value, 'utf8').digest('base64url'); + }, +}; + +void test('[IAM-002] PKCE accepts an RFC 7636 verifier and derives an S256 challenge', async () => { + const verifier = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; + const created = createPkceChallengeV1(verifier, hashPort); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(created.value.method, 'S256'); + assert.equal(verifyPkceChallengeV1(verifier, created.value.challenge, hashPort), true); + assert.equal(verifyPkceChallengeV1(`${verifier}x`, created.value.challenge, hashPort), false); +}); + +void test('[IAM-002] PKCE rejects malformed verifiers and plain challenges', async () => { + assert.deepEqual(createPkceChallengeV1('short', hashPort), { + accepted: false, + code: 'INVALID_VERIFIER', + }); + assert.equal(verifyPkceChallengeV1('short', 'plain', hashPort), false); +}); + +void test('[IAM-002] native redirect allowlist requires an exact loopback or app callback', () => { + assert.equal(isAllowedRedirectUriV1('http://127.0.0.1:43123/callback'), true); + assert.equal(isAllowedRedirectUriV1('com.databreeze.desktop:/oauth2redirect'), true); + assert.equal(isAllowedRedirectUriV1('https://evil.example/callback'), false); + assert.equal(isAllowedRedirectUriV1('http://127.0.0.1:43123/callback?next=https://evil'), false); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index f0199a0e..f46407dc 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -21,6 +21,8 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './device-sync/v1', './device-capability/v1', './data-mode/v1', + './pkce/v1', + './csrf/v1', './artifact/v1', './artifact-intake/v1', './artifact-governance/v1', @@ -56,6 +58,8 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.IDENTITY_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.ENTITLEMENT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.MFA_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.PKCE_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.CSRF_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DEVICE_AUTHORIZATION_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_SCHEMA_VERSION_V1, 1); diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 987e8834..69c9e6eb 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -352,6 +352,76 @@ "tags": ["system"] } }, + "/v1/auth/me": { + "get": { + "operationId": "AuthenticationController.me", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/CurrentSessionDto" } } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "summary": "Read the redacted authenticated session identity", + "tags": ["auth"] + } + }, "/v1/auth/sign-in": { "post": { "operationId": "AuthenticationController.signIn", @@ -454,6 +524,196 @@ "tags": ["auth"] } }, + "/v1/auth/refresh": { + "post": { + "operationId": "AuthenticationController.refresh", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/SessionRefreshDto" } } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SessionRefreshResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "401": { + "description": "The refresh session was rejected.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Session persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "summary": "Rotate a short-lived session", + "tags": ["auth"] + } + }, + "/v1/auth/sign-out": { + "post": { + "operationId": "AuthenticationController.signOut", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/SessionSignOutDto" } } + } + }, + "responses": { + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "401": { + "description": "The session could not be authenticated.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Session persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "summary": "Revoke a session and clear browser credentials", + "tags": ["auth"] + } + }, "/v1/devices/enrollment-challenges": { "post": { "operationId": "DeviceIdentityController.issueChallenge", @@ -600,15 +860,538 @@ } }, "security": [{ "bearer": [] }], - "summary": "Enroll a device after proof of possession", - "tags": ["devices"] + "summary": "Enroll a device after proof of possession", + "tags": ["devices"] + } + }, + "/v1/devices/{deviceId}/activate": { + "post": { + "operationId": "DeviceIdentityController.activate", + "parameters": [ + { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/DeviceRevisionDto" } } + } + }, + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Activate one pending device identity", + "tags": ["devices"] + } + }, + "/v1/organizations/{organizationId}/devices": { + "get": { + "operationId": "DeviceIdentityController.list", + "parameters": [ + { + "name": "organizationId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List content-free device identities in the caller organization", + "tags": ["devices"] + } + }, + "/v1/devices/{deviceId}/revoke": { + "post": { + "operationId": "DeviceIdentityController.revoke", + "parameters": [ + { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/DeviceRevisionDto" } } + } + }, + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Permanently revoke one device identity", + "tags": ["devices"] + } + }, + "/v1/devices/{deviceId}/key": { + "post": { + "operationId": "DeviceIdentityController.rotateKey", + "parameters": [ + { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/RotateDeviceKeyDto" } } + } + }, + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Rotate a device public key and security epoch", + "tags": ["devices"] + } + }, + "/v1/auth/mfa/factors": { + "post": { + "operationId": "MfaController.enroll", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/EnrollMfaFactorDto" } } + } + }, + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Enroll a pending MFA factor for the authenticated user", + "tags": ["auth"] + } + }, + "/v1/auth/mfa/factors/{factorId}/verify": { + "post": { + "operationId": "MfaController.verify", + "parameters": [ + { "name": "factorId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/VerifyMfaFactorDto" } } + } + }, + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Verify a pending MFA factor", + "tags": ["auth"] + } + }, + "/v1/auth/mfa/recovery/redeem": { + "post": { + "operationId": "MfaController.redeemRecovery", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RedeemMfaRecoveryCodeDto" } + } + } + }, + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Redeem one hashed MFA recovery code", + "tags": ["auth"] } }, - "/v1/devices/{deviceId}/activate": { + "/v1/artifacts/inbox": { "post": { - "operationId": "DeviceIdentityController.activate", + "operationId": "InboxController.create", "parameters": [ - { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -620,11 +1403,11 @@ "requestBody": { "required": true, "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/DeviceRevisionDto" } } + "application/json": { "schema": { "$ref": "#/components/schemas/CreateInboxItemDto" } } } }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -675,20 +1458,12 @@ } }, "security": [{ "bearer": [] }], - "summary": "Activate one pending device identity", - "tags": ["devices"] - } - }, - "/v1/organizations/{organizationId}/devices": { + "summary": "Register a content-free artifact intake item", + "tags": ["artifacts"] + }, "get": { - "operationId": "DeviceIdentityController.list", + "operationId": "InboxController.list", "parameters": [ - { - "name": "organizationId", - "required": true, - "in": "path", - "schema": { "type": "string" } - }, { "name": "X-Correlation-Id", "in": "header", @@ -749,15 +1524,16 @@ } }, "security": [{ "bearer": [] }], - "summary": "List content-free device identities in the caller organization", - "tags": ["devices"] + "summary": "List content-free artifact intake items visible to the caller", + "tags": ["artifacts"] } }, - "/v1/devices/{deviceId}/revoke": { + "/v1/artifacts/{versionId}/evidence/{evidenceId}/grants": { "post": { - "operationId": "DeviceIdentityController.revoke", + "operationId": "EvidenceGrantController.issue", "parameters": [ - { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "evidenceId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -769,11 +1545,13 @@ "requestBody": { "required": true, "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/DeviceRevisionDto" } } + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateEvidenceGrantDto" } + } } }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -824,15 +1602,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Permanently revoke one device identity", - "tags": ["devices"] + "summary": "Issue a short-lived exact-evidence access grant", + "tags": ["artifacts"] } }, - "/v1/devices/{deviceId}/key": { - "post": { - "operationId": "DeviceIdentityController.rotateKey", + "/v1/artifacts/evidence-grants/{grantId}": { + "delete": { + "operationId": "EvidenceGrantController.revoke", "parameters": [ - { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "grantId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -841,12 +1619,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/RotateDeviceKeyDto" } } - } - }, "responses": { "200": { "description": "", @@ -899,13 +1671,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Rotate a device public key and security epoch", - "tags": ["devices"] + "summary": "Revoke an evidence access grant", + "tags": ["artifacts"] } }, - "/v1/artifacts/inbox": { + "/v1/datasets": { "post": { - "operationId": "InboxController.create", + "operationId": "GovernedDatasetController.create", "parameters": [ { "name": "X-Correlation-Id", @@ -918,7 +1690,9 @@ "requestBody": { "required": true, "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateInboxItemDto" } } + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateGovernedDatasetDto" } + } } }, "responses": { @@ -973,12 +1747,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Register a content-free artifact intake item", - "tags": ["artifacts"] - }, + "summary": "Create an immutable governed dataset definition draft", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/versions": { "get": { - "operationId": "InboxController.list", + "operationId": "GovernedDatasetController.list", "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1039,16 +1816,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "List content-free artifact intake items visible to the caller", - "tags": ["artifacts"] + "summary": "List governed dataset versions visible to the caller", + "tags": ["datasets"] } }, - "/v1/artifacts/{versionId}/evidence/{evidenceId}/grants": { + "/v1/datasets/{datasetId}/mappings": { "post": { - "operationId": "EvidenceGrantController.issue", + "operationId": "MappingController.create", "parameters": [ - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, - { "name": "evidenceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1060,9 +1836,7 @@ "requestBody": { "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateEvidenceGrantDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/CreateMappingDto" } } } }, "responses": { @@ -1117,15 +1891,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Issue a short-lived exact-evidence access grant", - "tags": ["artifacts"] - } - }, - "/v1/artifacts/evidence-grants/{grantId}": { - "delete": { - "operationId": "EvidenceGrantController.revoke", + "summary": "Create an immutable mapping definition draft", + "tags": ["datasets"] + }, + "get": { + "operationId": "MappingController.list", "parameters": [ - { "name": "grantId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1186,14 +1958,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Revoke an evidence access grant", - "tags": ["artifacts"] + "summary": "List immutable mapping versions", + "tags": ["datasets"] } }, - "/v1/datasets": { + "/v1/datasets/{datasetId}/rules": { "post": { - "operationId": "GovernedDatasetController.create", + "operationId": "RuleSetController.create", "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1205,9 +1978,7 @@ "requestBody": { "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateGovernedDatasetDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/CreateRuleSetDto" } } } }, "responses": { @@ -1262,13 +2033,11 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable governed dataset definition draft", + "summary": "Create an immutable quality rule-set draft", "tags": ["datasets"] - } - }, - "/v1/datasets/{datasetId}/versions": { + }, "get": { - "operationId": "GovernedDatasetController.list", + "operationId": "RuleSetController.list", "parameters": [ { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -1331,15 +2100,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List governed dataset versions visible to the caller", + "summary": "List immutable quality rule-set versions", "tags": ["datasets"] } }, - "/v1/datasets/{datasetId}/mappings": { + "/v1/reference-entities": { "post": { - "operationId": "MappingController.create", + "operationId": "ReferenceEntityController.create", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1351,7 +2119,9 @@ "requestBody": { "required": true, "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateMappingDto" } } + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateReferenceEntityDto" } + } } }, "responses": { @@ -1406,13 +2176,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable mapping definition draft", - "tags": ["datasets"] - }, - "get": { - "operationId": "MappingController.list", + "summary": "Create an immutable business-party version", + "tags": ["reference-entities"] + } + }, + "/v1/reference-entities/merge": { + "post": { + "operationId": "ReferenceEntityController.merge", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1421,8 +2192,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MergeReferenceEntityDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -1473,15 +2252,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable mapping versions", - "tags": ["datasets"] + "summary": "Record an explicit business-party merge resolution", + "tags": ["reference-entities"] } }, - "/v1/datasets/{datasetId}/rules": { - "post": { - "operationId": "RuleSetController.create", + "/v1/reference-entities/{entityId}/versions": { + "get": { + "operationId": "ReferenceEntityController.list", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1490,14 +2269,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateRuleSetDto" } } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -1548,13 +2321,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable quality rule-set draft", - "tags": ["datasets"] - }, - "get": { - "operationId": "RuleSetController.list", + "summary": "List immutable business-party versions", + "tags": ["reference-entities"] + } + }, + "/v1/devices/sync/operations": { + "post": { + "operationId": "DeviceSyncController.enqueue", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1563,6 +2337,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateDeviceSyncOperationDto" } + } + } + }, "responses": { "200": { "description": "", @@ -1615,13 +2397,11 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable quality rule-set versions", - "tags": ["datasets"] - } - }, - "/v1/reference-entities": { - "post": { - "operationId": "ReferenceEntityController.create", + "summary": "Enqueue an opaque, tenant-scoped synchronization operation", + "tags": ["devices"] + }, + "get": { + "operationId": "DeviceSyncController.list", "parameters": [ { "name": "X-Correlation-Id", @@ -1631,16 +2411,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateReferenceEntityDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -1691,13 +2463,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable business-party version", - "tags": ["reference-entities"] + "summary": "List synchronization operation status without source content", + "tags": ["devices"] } }, - "/v1/reference-entities/merge": { + "/v1/devices/sync/pull": { "post": { - "operationId": "ReferenceEntityController.merge", + "operationId": "DeviceSyncController.pull", "parameters": [ { "name": "X-Correlation-Id", @@ -1710,13 +2482,11 @@ "requestBody": { "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/MergeReferenceEntityDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/PullDeviceSyncDto" } } } }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -1767,15 +2537,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record an explicit business-party merge resolution", - "tags": ["reference-entities"] + "summary": "Pull an opaque, signed, cursor-bound synchronization batch", + "tags": ["devices"] } }, - "/v1/reference-entities/{entityId}/versions": { - "get": { - "operationId": "ReferenceEntityController.list", + "/v1/devices/sync/push": { + "post": { + "operationId": "DeviceSyncController.push", "parameters": [ - { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1784,6 +2553,12 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/PushDeviceSyncDto" } } + } + }, "responses": { "200": { "description": "", @@ -1836,14 +2611,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable business-party versions", - "tags": ["reference-entities"] + "summary": "Push a signed, dependency-ordered synchronization batch", + "tags": ["devices"] } }, - "/v1/devices/sync/operations": { + "/v1/devices/sync/operations/{operationId}/transition": { "post": { - "operationId": "DeviceSyncController.enqueue", + "operationId": "DeviceSyncController.transition", "parameters": [ + { "name": "operationId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1856,7 +2632,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateDeviceSyncOperationDto" } + "schema": { "$ref": "#/components/schemas/TransitionDeviceSyncOperationDto" } } } }, @@ -1912,11 +2688,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Enqueue an opaque, tenant-scoped synchronization operation", + "summary": "Advance one synchronization operation with an expected revision", "tags": ["devices"] - }, - "get": { - "operationId": "DeviceSyncController.list", + } + }, + "/v1/devices/sync/conflicts": { + "post": { + "operationId": "DeviceSyncController.createConflict", "parameters": [ { "name": "X-Correlation-Id", @@ -1926,6 +2704,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateDeviceSyncConflictDto" } + } + } + }, "responses": { "200": { "description": "", @@ -1978,13 +2764,11 @@ } }, "security": [{ "bearer": [] }], - "summary": "List synchronization operation status without source content", + "summary": "Record a conflict and stop the affected operation", "tags": ["devices"] - } - }, - "/v1/devices/sync/pull": { - "post": { - "operationId": "DeviceSyncController.pull", + }, + "get": { + "operationId": "DeviceSyncController.listConflicts", "parameters": [ { "name": "X-Correlation-Id", @@ -1994,12 +2778,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/PullDeviceSyncDto" } } - } - }, "responses": { "200": { "description": "", @@ -2052,13 +2830,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Pull an opaque, signed, cursor-bound synchronization batch", + "summary": "List explicit synchronization conflicts", "tags": ["devices"] } }, - "/v1/devices/sync/push": { + "/v1/devices/sync/packages": { "post": { - "operationId": "DeviceSyncController.push", + "operationId": "DeviceSyncController.issuePackage", "parameters": [ { "name": "X-Correlation-Id", @@ -2071,7 +2849,9 @@ "requestBody": { "required": true, "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/PushDeviceSyncDto" } } + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateStrictLocalPackageDto" } + } } }, "responses": { @@ -2126,15 +2906,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Push a signed, dependency-ordered synchronization batch", + "summary": "Issue a digest-only strict-Local package manifest", "tags": ["devices"] } }, - "/v1/devices/sync/operations/{operationId}/transition": { + "/v1/devices/sync/packages/receipts": { "post": { - "operationId": "DeviceSyncController.transition", + "operationId": "DeviceSyncController.receipt", "parameters": [ - { "name": "operationId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2147,7 +2926,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/TransitionDeviceSyncOperationDto" } + "schema": { "$ref": "#/components/schemas/CreateDeviceTransferReceiptDto" } } } }, @@ -2203,14 +2982,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Advance one synchronization operation with an expected revision", + "summary": "Record a content-safe strict-Local transfer receipt", "tags": ["devices"] } }, - "/v1/devices/sync/conflicts": { + "/v1/devices/{deviceId}/capabilities": { "post": { - "operationId": "DeviceSyncController.createConflict", + "operationId": "DeviceCapabilityController.report", "parameters": [ + { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2223,7 +3003,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateDeviceSyncConflictDto" } + "schema": { "$ref": "#/components/schemas/ReportDeviceCapabilityDto" } } } }, @@ -2279,12 +3059,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record a conflict and stop the affected operation", + "summary": "Report one content-free device capability", "tags": ["devices"] }, "get": { - "operationId": "DeviceSyncController.listConflicts", + "operationId": "DeviceCapabilityController.listCapabilities", "parameters": [ + { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2345,14 +3126,21 @@ } }, "security": [{ "bearer": [] }], - "summary": "List explicit synchronization conflicts", + "summary": "List content-free capabilities for one device", "tags": ["devices"] } }, - "/v1/devices/sync/packages": { + "/v1/devices/{deviceId}/capabilities/{capabilityId}/pause": { "post": { - "operationId": "DeviceSyncController.issuePackage", + "operationId": "DeviceCapabilityController.pause", "parameters": [ + { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "capabilityId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -2365,7 +3153,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateStrictLocalPackageDto" } + "schema": { "$ref": "#/components/schemas/DeviceCapabilityRevisionDto" } } } }, @@ -2421,13 +3209,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Issue a digest-only strict-Local package manifest", + "summary": "Pause one device capability with an optimistic revision", "tags": ["devices"] } }, - "/v1/devices/sync/packages/receipts": { + "/v1/devices/grants": { "post": { - "operationId": "DeviceSyncController.receipt", + "operationId": "DeviceCapabilityController.issueGrant", "parameters": [ { "name": "X-Correlation-Id", @@ -2440,9 +3228,7 @@ "requestBody": { "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateDeviceTransferReceiptDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/IssueDeviceGrantDto" } } } }, "responses": { @@ -2497,13 +3283,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record a content-safe strict-Local transfer receipt", + "summary": "Issue a typed, workspace-scoped device grant", "tags": ["devices"] } }, - "/v1/devices/{deviceId}/capabilities": { - "post": { - "operationId": "DeviceCapabilityController.report", + "/v1/devices/{deviceId}/grants": { + "get": { + "operationId": "DeviceCapabilityController.listGrants", "parameters": [ { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -2514,14 +3300,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/ReportDeviceCapabilityDto" } - } - } - }, "responses": { "200": { "description": "", @@ -2574,13 +3352,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Report one content-free device capability", + "summary": "List typed grants for one device in the current workspace", "tags": ["devices"] - }, - "get": { - "operationId": "DeviceCapabilityController.listCapabilities", + } + }, + "/v1/devices/grants/{grantId}/revoke": { + "post": { + "operationId": "DeviceCapabilityController.revokeGrant", "parameters": [ - { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "grantId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2589,6 +3369,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeviceGrantRevisionDto" } + } + } + }, "responses": { "200": { "description": "", @@ -2641,21 +3429,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List content-free capabilities for one device", + "summary": "Revoke one typed device grant with an optimistic revision", "tags": ["devices"] } }, - "/v1/devices/{deviceId}/capabilities/{capabilityId}/pause": { + "/v1/data-mode-policies": { "post": { - "operationId": "DeviceCapabilityController.pause", + "operationId": "DataModePolicyController.publish", "parameters": [ - { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, - { - "name": "capabilityId", - "required": true, - "in": "path", - "schema": { "type": "string" } - }, { "name": "X-Correlation-Id", "in": "header", @@ -2668,7 +3449,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/DeviceCapabilityRevisionDto" } + "schema": { "$ref": "#/components/schemas/PublishDataModePolicyDto" } } } }, @@ -2724,14 +3505,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Pause one device capability with an optimistic revision", + "summary": "Publish an immutable workspace data-mode policy version", "tags": ["devices"] } }, - "/v1/devices/grants": { - "post": { - "operationId": "DeviceCapabilityController.issueGrant", + "/v1/data-mode-policies/{policyId}": { + "get": { + "operationId": "DataModePolicyController.list", "parameters": [ + { "name": "policyId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2740,12 +3522,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/IssueDeviceGrantDto" } } - } - }, "responses": { "200": { "description": "", @@ -2798,15 +3574,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Issue a typed, workspace-scoped device grant", + "summary": "List immutable versions of one workspace data-mode policy", "tags": ["devices"] } }, - "/v1/devices/{deviceId}/grants": { + "/v1/audit/events": { "get": { - "operationId": "DeviceCapabilityController.listGrants", + "operationId": "AuditController.events", "parameters": [ - { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2867,15 +3642,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List typed grants for one device in the current workspace", - "tags": ["devices"] + "summary": "List immutable audit events visible to the caller", + "tags": ["audit"] } }, - "/v1/devices/grants/{grantId}/revoke": { - "post": { - "operationId": "DeviceCapabilityController.revokeGrant", + "/v1/audit/seals": { + "get": { + "operationId": "AuditController.seals", "parameters": [ - { "name": "grantId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2884,14 +3658,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/DeviceGrantRevisionDto" } - } - } - }, "responses": { "200": { "description": "", @@ -2944,14 +3710,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Revoke one typed device grant with an optimistic revision", - "tags": ["devices"] + "summary": "List verified audit seals visible to the caller", + "tags": ["audit"] } }, - "/v1/data-mode-policies": { - "post": { - "operationId": "DataModePolicyController.publish", + "/v1/entitlements/snapshots/{snapshotId}": { + "get": { + "operationId": "EntitlementController.snapshot", "parameters": [ + { "name": "snapshotId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2960,14 +3727,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/PublishDataModePolicyDto" } - } - } - }, "responses": { "200": { "description": "", @@ -3020,15 +3779,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Publish an immutable workspace data-mode policy version", - "tags": ["devices"] + "summary": "Read one immutable entitlement snapshot in the caller scope", + "tags": ["entitlements"] } }, - "/v1/data-mode-policies/{policyId}": { + "/v1/entitlements/usage": { "get": { - "operationId": "DataModePolicyController.list", + "operationId": "EntitlementController.usage", "parameters": [ - { "name": "policyId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3089,8 +3847,8 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable versions of one workspace data-mode policy", - "tags": ["devices"] + "summary": "Read the append-only usage ledger state in the caller scope", + "tags": ["entitlements"] } } }, @@ -3122,6 +3880,17 @@ "required": ["clientPlatform", "clientVersion"], "additionalProperties": false }, + "CurrentSessionDto": { + "type": "object", + "properties": { + "userId": { "type": "string", "format": "uuid" }, + "organizationId": { "type": "string", "format": "uuid" }, + "workspaceId": { "type": "string", "format": "uuid" }, + "authorizationEpoch": { "type": "number", "minimum": 1 }, + "mfaRequired": { "type": "boolean" } + }, + "required": ["userId", "organizationId", "authorizationEpoch", "mfaRequired"] + }, "SignInDto": { "type": "object", "properties": { @@ -3150,12 +3919,42 @@ "organizationId", "workspaceId", "accessToken", - "refreshToken", "accessExpiresAt", "securityEpoch", "mfaRequired" ] }, + "SessionRefreshDto": { + "type": "object", + "properties": { + "clientPlatform": { "type": "string", "enum": ["android", "desktop", "web"] }, + "refreshToken": { "type": "string", "minLength": 1, "maxLength": 4096, "writeOnly": true } + }, + "required": ["clientPlatform"] + }, + "SessionRefreshResponseDto": { + "type": "object", + "properties": { + "sessionId": { "type": "string", "format": "uuid" }, + "accessToken": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "refreshToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "writeOnly": true + }, + "accessExpiresAt": { "type": "string", "format": "date-time" } + }, + "required": ["sessionId", "accessToken", "accessExpiresAt"] + }, + "SessionSignOutDto": { + "type": "object", + "properties": { + "clientPlatform": { "type": "string", "enum": ["android", "desktop", "web"] }, + "sessionId": { "type": "string", "format": "uuid" } + }, + "required": ["clientPlatform", "sessionId"] + }, "IssueDeviceEnrollmentChallengeDto": { "type": "object", "properties": { @@ -3203,6 +4002,30 @@ }, "required": ["expectedRevision", "at", "nextPublicKey"] }, + "EnrollMfaFactorDto": { + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, + "method": { "type": "string", "enum": ["TOTP", "WEBAUTHN"] }, + "secretReference": { "type": "string", "maxLength": 512, "writeOnly": true }, + "enrolledAt": { "type": "string", "format": "date-time" }, + "revision": { "type": "number", "minimum": 1 } + }, + "required": ["id", "method", "secretReference", "enrolledAt"] + }, + "VerifyMfaFactorDto": { + "type": "object", + "properties": { "at": { "type": "string", "format": "date-time" } }, + "required": ["at"] + }, + "RedeemMfaRecoveryCodeDto": { + "type": "object", + "properties": { + "presentedDigest": { "type": "string", "maxLength": 256, "writeOnly": true }, + "at": { "type": "string", "format": "date-time" } + }, + "required": ["presentedDigest", "at"] + }, "CreateInboxItemDto": { "type": "object", "properties": { diff --git a/services/api/prisma/migrations/20260802210000_iam_mfa_recovery/migration.sql b/services/api/prisma/migrations/20260802210000_iam_mfa_recovery/migration.sql new file mode 100644 index 00000000..d80c0c76 --- /dev/null +++ b/services/api/prisma/migrations/20260802210000_iam_mfa_recovery/migration.sql @@ -0,0 +1,20 @@ +-- Add revisioned MFA factor transitions required by the domain state machine. +ALTER TABLE "iam"."mfa_factors" + ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 1; + +-- Recovery-code digests are stored separately so they can be redeemed once +-- without exposing the presented value or mutating the immutable digest. +CREATE TABLE "iam"."mfa_recovery_codes" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "digest" VARCHAR(256) NOT NULL, + "status" VARCHAR(24) NOT NULL DEFAULT 'AVAILABLE', + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "used_at" TIMESTAMPTZ(6), + "revision" INTEGER NOT NULL DEFAULT 1, + + CONSTRAINT "mfa_recovery_codes_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "mfa_recovery_codes_user_status_idx" + ON "iam"."mfa_recovery_codes"("user_id", "status"); diff --git a/services/api/prisma/migrations/20260802220000_iam_access_tokens/migration.sql b/services/api/prisma/migrations/20260802220000_iam_access_tokens/migration.sql new file mode 100644 index 00000000..2c57dc9f --- /dev/null +++ b/services/api/prisma/migrations/20260802220000_iam_access_tokens/migration.sql @@ -0,0 +1,16 @@ +-- IAM-005: persist only digests for short-lived access-token lookup. +CREATE TABLE "iam"."access_tokens" ( + "id" UUID NOT NULL, + "session_id" UUID NOT NULL, + "token_digest" VARCHAR(128) NOT NULL, + "issued_at" TIMESTAMPTZ(6) NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "status" VARCHAR(24) NOT NULL DEFAULT 'ACTIVE', + "revoked_at" TIMESTAMPTZ(6), + + CONSTRAINT "access_tokens_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "access_tokens_digest_key" ON "iam"."access_tokens"("token_digest"); +CREATE INDEX "access_tokens_session_status_idx" ON "iam"."access_tokens"("session_id", "status"); +CREATE INDEX "access_tokens_expiry_idx" ON "iam"."access_tokens"("expires_at"); diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index d529d923..0dd1e5b9 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -171,12 +171,42 @@ model MfaFactor { enrolledAt DateTime @map("enrolled_at") @db.Timestamptz(6) verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6) revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) + revision Int @default(1) @@index([userId, status], map: "mfa_factors_user_status_idx") @@map("mfa_factors") @@schema("iam") } +model MfaRecoveryCode { + id String @id @db.Uuid + userId String @map("user_id") @db.Uuid + digest String @db.VarChar(256) + status String @default("AVAILABLE") @db.VarChar(24) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + usedAt DateTime? @map("used_at") @db.Timestamptz(6) + revision Int @default(1) + + @@index([userId, status], map: "mfa_recovery_codes_user_status_idx") + @@map("mfa_recovery_codes") + @@schema("iam") +} + +model AccessTokenRecord { + id String @id @db.Uuid + sessionId String @map("session_id") @db.Uuid + tokenDigest String @unique(map: "access_tokens_digest_key") @map("token_digest") @db.VarChar(128) + issuedAt DateTime @map("issued_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + status String @default("ACTIVE") @db.VarChar(24) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) + + @@index([sessionId, status], map: "access_tokens_session_status_idx") + @@index([expiresAt], map: "access_tokens_expiry_idx") + @@map("access_tokens") + @@schema("iam") +} + model AuthorizationSnapshot { id String @id @db.Uuid deviceId String @map("device_id") @db.Uuid diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index 994350d6..7a0ee73b 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -5,24 +5,41 @@ import { SystemModule, type SystemModuleOptions } from './features/system/system import { IaeModule, type IaeModuleOptions } from './features/iae/iae.module.js'; import { DsmModule, type DsmModuleOptions } from './features/dsm/dsm.module.js'; import { DsoModule, type DsoModuleOptions } from './features/dso/dso.module.js'; +import { AudModule, type AudModuleOptions } from './features/aud/aud.module.js'; +import { BuaModule, type BuaModuleOptions } from './features/bua/bua.module.js'; +import { SessionRequestTenantContextAdapter } from './platform/http/session-tenant-context.adapter.js'; export type AppModuleOptions = SystemModuleOptions & IamModuleOptions & IaeModuleOptions & DsmModuleOptions & - DsoModuleOptions; + DsoModuleOptions & + AudModuleOptions & + BuaModuleOptions; @Module({}) export class AppModule { static register(options: AppModuleOptions = {}): DynamicModule { + const sessions = options.sessions; + const requestTenantContext = + options.requestTenantContext ?? + (typeof sessions?.findPrincipalByAccessToken === 'function' + ? new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: sessions.findPrincipalByAccessToken.bind(sessions), + }) + : undefined); + const composedOptions = + requestTenantContext === undefined ? options : { ...options, requestTenantContext }; return { module: AppModule, imports: [ - SystemModule.register(options), - IamModule.register(options), - IaeModule.register(options), - DsmModule.register(options), - DsoModule.register(options), + SystemModule.register(composedOptions), + IamModule.register(composedOptions), + IaeModule.register(composedOptions), + DsmModule.register(composedOptions), + DsoModule.register(composedOptions), + AudModule.register(composedOptions), + BuaModule.register(composedOptions), ], }; } diff --git a/services/api/src/bootstrap.ts b/services/api/src/bootstrap.ts index 241e07db..94eb97d4 100644 --- a/services/api/src/bootstrap.ts +++ b/services/api/src/bootstrap.ts @@ -13,7 +13,10 @@ import type { ClientCompatibilityPort } from './features/system/application/clie import type { ReadinessPort } from './features/system/application/readiness.port.js'; import { ProblemDetailsFilter } from './platform/http/problem-details.filter.js'; import { configureOpenApi } from './platform/http/openapi.js'; -import { installRequestContext } from './platform/http/request-context.js'; +import { + installRequestContext, + type RequestContextOptions, +} from './platform/http/request-context.js'; import { createValidationPipe } from './platform/http/validation.js'; export interface ApiApplication { @@ -28,13 +31,14 @@ export interface ApiApplicationOptions DsoModuleOptions { readonly compatibilityPort?: ClientCompatibilityPort; readonly readinessPort?: ReadinessPort; + readonly requestContext?: RequestContextOptions; } export async function createApiApplication( options: ApiApplicationOptions = {}, ): Promise { const adapter = new FastifyAdapter({ bodyLimit: 65_536, logger: false }); - installRequestContext(adapter.getInstance()); + installRequestContext(adapter.getInstance(), options.requestContext); const app = await NestFactory.create( AppModule.register(options), adapter, diff --git a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts new file mode 100644 index 00000000..202c5b25 --- /dev/null +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -0,0 +1,382 @@ +import { + AUDIT_ACTIONS_V1, + sanitizeAuditSummaryV1, + verifyAuditChainV1, + type AuditActorTypeV1, + type AuditEventV1, + type AuditSealV1, + type AuditDigestPortV1, +} from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { randomUUID } from 'node:crypto'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + AuditRepositoryPortV1, + AuditTransactionPortV1, +} from '../application/audit-repository.port.js'; + +export interface AuditEventDatabaseRowV1 { + readonly id: string; + readonly schemaVersion: number; + readonly action: string; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly actorType: string; + readonly actorId: string; + readonly entityType: string; + readonly entityId: string; + readonly entityRevision: number; + readonly sequence: number; + readonly occurredAt: Date; + readonly correlationId: string; + readonly idempotencyKey: string; + readonly summary: unknown; + readonly previousDigest: string | null; + readonly digest: string; + readonly createdAt: Date; +} + +export interface AuditSealDatabaseRowV1 { + readonly id: string; + readonly schemaVersion: number; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly firstSequence: number; + readonly lastSequence: number; + readonly eventCount: number; + readonly rootDigest: string; + readonly sealedAt: Date; + readonly createdAt: Date; +} + +interface AuditEventCreateDataV1 extends Omit { + readonly createdAt: Date; +} + +interface AuditSealCreateDataV1 extends Omit { + readonly createdAt: Date; +} + +interface AuditEventDelegateV1 { + create(input: { readonly data: AuditEventCreateDataV1 }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly sequence: 'asc' | 'desc' }; + }): Promise; +} + +interface AuditSealDelegateV1 { + create(input: { readonly data: AuditSealCreateDataV1 }): Promise; + findFirst(input: { + readonly where: Readonly>; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly lastSequence: 'asc' | 'desc' }; + }): Promise; +} + +export interface AuditDatabaseClientV1 { + readonly auditEventRecord: AuditEventDelegateV1; + readonly auditSealRecord: AuditSealDelegateV1; + $transaction( + work: (transaction: AuditDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function scopeKey(scope: TenantScopeV1): string { + if (scope.scopeType === 'organization') return `organization:${scope.organizationId}`; + if (scope.scopeType === 'workspace') + return `workspace:${scope.organizationId}:${scope.workspaceId}`; + return `project:${scope.organizationId}:${scope.workspaceId}:${scope.projectId}`; +} + +function persistedScope(row: { + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; +}): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('AUD_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function positiveInteger(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +function persistedEvent(row: AuditEventDatabaseRowV1): AuditEventV1 { + const eventId = parseStableIdentifierV1(row.id); + const organizationId = parseStableIdentifierV1(row.organizationId); + const workspaceId = + row.workspaceId === null ? undefined : parseStableIdentifierV1(row.workspaceId); + const projectId = row.projectId === null ? undefined : parseStableIdentifierV1(row.projectId); + const actorId = parseStableIdentifierV1(row.actorId); + const entityId = parseStableIdentifierV1(row.entityId); + const correlationId = parseStableIdentifierV1(row.correlationId); + const occurredAt = parseStrictUtcTimestampV1(row.occurredAt.toISOString()); + const tenantScope = persistedScope(row); + const summary = sanitizeAuditSummaryV1(row.summary); + if ( + row.schemaVersion !== 1 || + !eventId.accepted || + !organizationId.accepted || + (row.workspaceId !== null && !workspaceId?.accepted) || + (row.projectId !== null && !projectId?.accepted) || + !actorId.accepted || + !entityId.accepted || + !correlationId.accepted || + !occurredAt.accepted || + !summary.accepted || + !AUDIT_ACTIONS_V1.includes(row.action as (typeof AUDIT_ACTIONS_V1)[number]) || + !['USER', 'SERVICE_ACCOUNT', 'DEVICE', 'SYSTEM'].includes(row.actorType) || + !text(row.entityType, 80) || + !positiveInteger(row.entityRevision) || + !positiveInteger(row.sequence) || + !text(row.idempotencyKey, 200) || + !text(row.digest, 512) || + (row.previousDigest !== null && !text(row.previousDigest, 512)) + ) { + throw new Error('AUD_PERSISTED_EVENT_INVALID'); + } + return Object.freeze({ + schemaVersion: 1, + eventId: eventId.value, + action: row.action as AuditEventV1['action'], + tenantScope, + actor: Object.freeze({ + actorType: row.actorType as AuditActorTypeV1, + actorId: actorId.value, + }), + entityType: row.entityType, + entityId: entityId.value, + entityRevision: row.entityRevision, + sequence: row.sequence, + occurredAt: occurredAt.value, + correlationId: correlationId.value, + idempotencyKey: row.idempotencyKey, + summary: summary.value, + previousDigest: row.previousDigest, + digest: row.digest, + }); +} + +function persistedSeal(row: AuditSealDatabaseRowV1): AuditSealV1 { + const scope = persistedScope(row); + if ( + row.schemaVersion !== 1 || + !positiveInteger(row.firstSequence) || + !positiveInteger(row.lastSequence) || + row.lastSequence < row.firstSequence || + !positiveInteger(row.eventCount) || + !text(row.rootDigest, 512) || + !parseStrictUtcTimestampV1(row.sealedAt.toISOString()).accepted + ) { + throw new Error('AUD_PERSISTED_SEAL_INVALID'); + } + const sealedAt = parseStrictUtcTimestampV1(row.sealedAt.toISOString()); + if (!sealedAt.accepted) throw new Error('AUD_PERSISTED_SEAL_INVALID'); + return Object.freeze({ + schemaVersion: 1, + tenantScope: scope, + firstSequence: row.firstSequence, + lastSequence: row.lastSequence, + eventCount: row.eventCount, + rootDigest: row.rootDigest, + sealedAt: sealedAt.value, + }); +} + +function eventCreateData(event: AuditEventV1): AuditEventCreateDataV1 { + return { + ...databaseScope(event.tenantScope), + id: event.eventId, + schemaVersion: event.schemaVersion, + action: event.action, + scopeKey: scopeKey(event.tenantScope), + actorType: event.actor.actorType, + actorId: event.actor.actorId, + entityType: event.entityType, + entityId: event.entityId, + entityRevision: event.entityRevision, + sequence: event.sequence, + occurredAt: new Date(event.occurredAt), + correlationId: event.correlationId, + idempotencyKey: event.idempotencyKey, + summary: event.summary, + previousDigest: event.previousDigest, + digest: event.digest, + createdAt: new Date(), + }; +} + +function sealCreateData(seal: AuditSealV1): AuditSealCreateDataV1 { + return { + ...databaseScope(seal.tenantScope), + id: randomUUID(), + schemaVersion: seal.schemaVersion, + scopeKey: scopeKey(seal.tenantScope), + firstSequence: seal.firstSequence, + lastSequence: seal.lastSequence, + eventCount: seal.eventCount, + rootDigest: seal.rootDigest, + sealedAt: new Date(seal.sealedAt), + createdAt: new Date(), + }; +} + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { + public constructor( + private readonly client: AuditDatabaseClientV1, + private readonly digestPort: AuditDigestPortV1, + ) {} + + public async appendEvent( + context: IamTenantContextV1, + event: AuditEventV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, event.tenantScope)) + throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.auditEventRecord.findUnique({ + where: { id: event.eventId }, + }); + if (existing !== null) { + const current = persistedEvent(existing); + if (JSON.stringify(current) !== JSON.stringify(event)) throw new Error('AUD_IMMUTABLE_EVENT'); + return current; + } + const siblings = await this.client.auditEventRecord.findMany({ + where: { scopeKey: scopeKey(event.tenantScope) }, + orderBy: { sequence: 'desc' }, + }); + const duplicate = siblings.find((row) => row.idempotencyKey === event.idempotencyKey); + if (duplicate !== undefined) throw new Error('AUD_IDEMPOTENCY_CONFLICT'); + const latest = siblings[0]; + if ( + latest !== undefined && + (event.sequence !== latest.sequence + 1 || event.previousDigest !== latest.digest) + ) { + throw new Error('AUD_SEQUENCE_CONFLICT'); + } + const created = await this.client.auditEventRecord.create({ data: eventCreateData(event) }); + return persistedEvent(created); + } + + public async listEvents(context: IamTenantContextV1): Promise { + const rows = await this.client.auditEventRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { sequence: 'asc' }, + }); + const events = rows + .filter((row) => visible(context.tenantScope, persistedScope(row))) + .map(persistedEvent); + const verified = verifyAuditChainV1(events, this.digestPort); + if (!verified.accepted) throw new Error('AUD_CHAIN_INVALID'); + return events; + } + + public async saveSeal(context: IamTenantContextV1, seal: AuditSealV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, seal.tenantScope)) + throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.auditSealRecord.findFirst({ + where: { + scopeKey: scopeKey(seal.tenantScope), + firstSequence: seal.firstSequence, + lastSequence: seal.lastSequence, + }, + }); + if (existing !== null) { + if (JSON.stringify(persistedSeal(existing)) !== JSON.stringify(seal)) + throw new Error('AUD_IMMUTABLE_SEAL'); + return; + } + await this.client.auditSealRecord.create({ data: sealCreateData(seal) }); + } + + public async listSeals(context: IamTenantContextV1): Promise { + const rows = await this.client.auditSealRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { lastSequence: 'asc' }, + }); + return rows + .filter((row) => visible(context.tenantScope, persistedScope(row))) + .map(persistedSeal); + } +} + +export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 { + public constructor( + private readonly client: AuditDatabaseClientV1, + private readonly digestPort: AuditDigestPortV1, + ) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: AuditTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaAuditTransactionAdapter(transaction, this.digestPort)), + ); + } + + public appendEvent(context: IamTenantContextV1, event: AuditEventV1): Promise { + return new PrismaAuditTransactionAdapter(this.client, this.digestPort).appendEvent( + context, + event, + ); + } + + public listEvents(context: IamTenantContextV1): Promise { + return new PrismaAuditTransactionAdapter(this.client, this.digestPort).listEvents(context); + } + + public saveSeal(context: IamTenantContextV1, seal: AuditSealV1): Promise { + return new PrismaAuditTransactionAdapter(this.client, this.digestPort).saveSeal(context, seal); + } + + public listSeals(context: IamTenantContextV1): Promise { + return new PrismaAuditTransactionAdapter(this.client, this.digestPort).listSeals(context); + } +} diff --git a/services/api/src/features/aud/adapter/sha256-audit-digest.adapter.ts b/services/api/src/features/aud/adapter/sha256-audit-digest.adapter.ts new file mode 100644 index 00000000..ae5e2731 --- /dev/null +++ b/services/api/src/features/aud/adapter/sha256-audit-digest.adapter.ts @@ -0,0 +1,10 @@ +import { createHash } from 'node:crypto'; + +import type { AuditDigestPortV1 } from '@databreeze/domain/audit/v1'; + +/** Deterministic digest implementation for the control-plane audit chain. */ +export class Sha256AuditDigestAdapter implements AuditDigestPortV1 { + public digest(canonicalRecord: string): string { + return createHash('sha256').update(canonicalRecord, 'utf8').digest('base64url'); + } +} diff --git a/services/api/src/features/aud/api/audit.controller.ts b/services/api/src/features/aud/api/audit.controller.ts new file mode 100644 index 00000000..75c6b9bb --- /dev/null +++ b/services/api/src/features/aud/api/audit.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Inject, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + AUDIT_REPOSITORY_PORT, + type AuditRepositoryPortV1, +} from '../application/audit-repository.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('audit') +@ApiBearerAuth() +@Controller('v1/audit') +export class AuditController { + public constructor( + @Inject(AUDIT_REPOSITORY_PORT) private readonly repository: AuditRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) {} + + @Get('events') + @ApiOperation({ summary: 'List immutable audit events visible to the caller' }) + async events(@Req() request: unknown): Promise { + const context = await this.requestContext.resolve(request); + return this.repository.listEvents(context); + } + + @Get('seals') + @ApiOperation({ summary: 'List verified audit seals visible to the caller' }) + async seals(@Req() request: unknown): Promise { + const context = await this.requestContext.resolve(request); + return this.repository.listSeals(context); + } +} diff --git a/services/api/src/features/aud/aud.module.ts b/services/api/src/features/aud/aud.module.ts new file mode 100644 index 00000000..906e60f1 --- /dev/null +++ b/services/api/src/features/aud/aud.module.ts @@ -0,0 +1,54 @@ +import { type DynamicModule, Module } from '@nestjs/common'; + +import { AuditLedgerService } from './application/audit-ledger.service.js'; +import { + AUDIT_REPOSITORY_PORT, + type AuditRepositoryPortV1, +} from './application/audit-repository.port.js'; +import { InMemoryAuditRepositoryAdapter } from './adapter/in-memory-audit-repository.adapter.js'; +import { + PrismaAuditRepositoryAdapter, + type AuditDatabaseClientV1, +} from './adapter/prisma-audit-repository.adapter.js'; +import { Sha256AuditDigestAdapter } from './adapter/sha256-audit-digest.adapter.js'; +import { AuditController } from './api/audit.controller.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, + UnavailableRequestTenantContextAdapter, +} from '../../platform/http/request-tenant-context.port.js'; + +export const AUDIT_LEDGER_SERVICE = Symbol('AUDIT_LEDGER_SERVICE'); + +export interface AudModuleOptions { + readonly auditRepository?: AuditRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly auditDatabase?: AuditDatabaseClientV1; + readonly requestTenantContext?: RequestTenantContextPortV1; +} + +@Module({}) +export class AudModule { + public static register(options: AudModuleOptions = {}): DynamicModule { + const digest = new Sha256AuditDigestAdapter(); + const repository = + options.auditRepository ?? + (options.auditDatabase === undefined + ? new InMemoryAuditRepositoryAdapter() + : new PrismaAuditRepositoryAdapter(options.auditDatabase, digest)); + const service = new AuditLedgerService(repository, digest); + return { + module: AudModule, + controllers: [AuditController], + providers: [ + { provide: AUDIT_REPOSITORY_PORT, useValue: repository }, + { provide: AUDIT_LEDGER_SERVICE, useValue: service }, + { + provide: REQUEST_TENANT_CONTEXT, + useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), + }, + ], + exports: [AUDIT_REPOSITORY_PORT, AUDIT_LEDGER_SERVICE], + }; + } +} diff --git a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts new file mode 100644 index 00000000..13d5019f --- /dev/null +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -0,0 +1,612 @@ +import { + createPlanV1, + type EntitlementPlanV1, + type EntitlementQuotaV1, + type EntitlementSnapshotV1, + type UsageBucketV1, + type UsageLedgerEntryV1, + type UsageLedgerStateV1, + type UsageMetricV1, + type UsageReservationV1, +} from '@databreeze/domain/entitlements/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + EntitlementRepositoryPortV1, + EntitlementTransactionPortV1, +} from '../application/entitlement-repository.port.js'; + +const planCodes = new Set(['free', 'development', 'admin_granted']); +const statuses = new Set(['ACTIVE', 'SUSPENDED', 'EXPIRED']); +const metrics = new Set([ + 'artifact_bytes', + 'processing_seconds', + 'job_count', + 'member_count', + 'ocr_pages', +]); +const buckets = new Set(['RESERVED', 'COMMITTED']); +const reservationStatuses = new Set(['ACTIVE', 'FINALIZED', 'RELEASED']); + +export interface EntitlementPlanDatabaseRowV1 { + readonly planCode: string; + readonly schemaVersion: number; + readonly displayNameKey: string; + readonly features: unknown; + readonly quotas: unknown; + readonly providerIndependent: boolean; + readonly createdAt: Date; +} + +export interface EntitlementSnapshotDatabaseRowV1 { + readonly id: string; + readonly schemaVersion: number; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly planCode: string; + readonly status: string; + readonly revision: number; + readonly securityEpoch: number; + readonly effectiveAt: Date; + readonly expiresAt: Date | null; + readonly features: unknown; + readonly quotas: unknown; + readonly createdAt: Date; +} + +export interface UsageLedgerEntryDatabaseRowV1 { + readonly id: string; + readonly schemaVersion: number; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly metric: string; + readonly bucket: string; + readonly deltaUnits: bigint | number; + readonly sequence: number; + readonly reservationId: string | null; + readonly idempotencyKey: string; + readonly occurredAt: Date; + readonly createdAt: Date; +} + +export interface UsageReservationDatabaseRowV1 { + readonly id: string; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly metric: string; + readonly reservedUnits: bigint | number; + readonly status: string; + readonly createdAt: Date; + readonly revision: number; + readonly updatedAt: Date; +} + +interface EntitlementPlanCreateDataV1 extends Omit { + readonly createdAt: Date; +} +interface EntitlementSnapshotCreateDataV1 + extends Omit { + readonly createdAt: Date; +} +interface UsageLedgerEntryCreateDataV1 extends Omit { + readonly createdAt: Date; +} +interface UsageReservationCreateDataV1 extends Omit { + readonly updatedAt: Date; +} + +interface DelegateV1 { + create(input: { readonly data: TCreate }): Promise; + findUnique(input: { + readonly where: { readonly id?: string; readonly planCode?: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }): Promise; + update?(input: { + readonly where: { readonly id: string }; + readonly data: Readonly>; + }): Promise; +} + +export interface EntitlementDatabaseClientV1 { + readonly entitlementPlanRecord: DelegateV1< + EntitlementPlanDatabaseRowV1, + EntitlementPlanCreateDataV1 + >; + readonly entitlementSnapshotRecord: DelegateV1< + EntitlementSnapshotDatabaseRowV1, + EntitlementSnapshotCreateDataV1 + >; + readonly usageLedgerEntryRecord: DelegateV1< + UsageLedgerEntryDatabaseRowV1, + UsageLedgerEntryCreateDataV1 + >; + readonly usageReservationRecord: DelegateV1< + UsageReservationDatabaseRowV1, + UsageReservationCreateDataV1 + >; + $transaction( + work: (transaction: EntitlementDatabaseClientV1) => Promise, + ): Promise; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function positiveInteger(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +function integerUnits(input: bigint | number): number { + const value = typeof input === 'bigint' ? Number(input) : input; + if (!Number.isSafeInteger(value)) throw new Error('BUA_PERSISTED_UNITS_INVALID'); + return value; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + } as const; +} + +function scopeKey(scope: TenantScopeV1): string { + if (scope.scopeType === 'organization') return `organization:${scope.organizationId}`; + if (scope.scopeType === 'workspace') + return `workspace:${scope.organizationId}:${scope.workspaceId}`; + return `project:${scope.organizationId}:${scope.workspaceId}:${scope.projectId}`; +} + +function persistedScope(row: { + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId?: string | null; +}): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === undefined || row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('BUA_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function parseQuotas(input: unknown): readonly EntitlementQuotaV1[] | undefined { + if (!Array.isArray(input)) return undefined; + const values: EntitlementQuotaV1[] = []; + for (const item of input) { + if (typeof item !== 'object' || item === null) return undefined; + const metric = (item as Record)['metric']; + const limit = (item as Record)['limit']; + const normalizedLimit = positiveInteger(limit); + if (typeof metric !== 'string' || !metrics.has(metric) || normalizedLimit === undefined) + return undefined; + values.push({ metric: metric as UsageMetricV1, limit: normalizedLimit }); + } + return Object.freeze(values.map((quota) => Object.freeze(quota))); +} + +function parseFeatures(input: unknown): readonly string[] | undefined { + if (!Array.isArray(input)) return undefined; + const values = input.map((value) => text(value, 120)); + return values.every((value): value is string => value !== undefined) + ? Object.freeze(values) + : undefined; +} + +function persistedPlan(row: EntitlementPlanDatabaseRowV1): EntitlementPlanV1 { + const features = parseFeatures(row.features); + const quotas = parseQuotas(row.quotas); + const created = createPlanV1({ + planCode: row.planCode, + displayNameKey: row.displayNameKey, + features, + quotas, + }); + if ( + row.schemaVersion !== 1 || + !row.providerIndependent || + !planCodes.has(row.planCode) || + !features || + !quotas || + !created.accepted + ) + throw new Error('BUA_PERSISTED_PLAN_INVALID'); + return created.value; +} + +function persistedSnapshot(row: EntitlementSnapshotDatabaseRowV1): EntitlementSnapshotV1 { + const snapshotId = parseStableIdentifierV1(row.id); + const organizationId = parseStableIdentifierV1(row.organizationId); + const workspaceId = + row.workspaceId === null ? undefined : parseStableIdentifierV1(row.workspaceId); + const scope = persistedScope({ ...row, projectId: null }); + const effectiveAt = parseStrictUtcTimestampV1(row.effectiveAt.toISOString()); + const expiresAt = + row.expiresAt === null ? undefined : parseStrictUtcTimestampV1(row.expiresAt.toISOString()); + const features = parseFeatures(row.features); + const quotas = parseQuotas(row.quotas); + if ( + row.schemaVersion !== 1 || + !snapshotId.accepted || + !organizationId.accepted || + (row.workspaceId !== null && !workspaceId?.accepted) || + !effectiveAt.accepted || + (row.expiresAt !== null && !expiresAt?.accepted) || + !features || + !quotas || + !planCodes.has(row.planCode) || + !statuses.has(row.status) || + !positiveInteger(row.revision) || + !positiveInteger(row.securityEpoch) + ) + throw new Error('BUA_PERSISTED_SNAPSHOT_INVALID'); + return Object.freeze({ + schemaVersion: 1, + snapshotId: snapshotId.value, + organizationId: organizationId.value, + ...(scope.scopeType === 'workspace' && workspaceId?.accepted + ? { workspaceId: workspaceId.value } + : {}), + planCode: row.planCode as EntitlementSnapshotV1['planCode'], + status: row.status as EntitlementSnapshotV1['status'], + revision: row.revision, + securityEpoch: row.securityEpoch, + effectiveAt: effectiveAt.value, + ...(expiresAt?.accepted ? { expiresAt: expiresAt.value } : {}), + features, + quotas, + }); +} + +function persistedEntry(row: UsageLedgerEntryDatabaseRowV1): UsageLedgerEntryV1 { + const entryId = parseStableIdentifierV1(row.id); + const organizationId = parseStableIdentifierV1(row.organizationId); + const workspaceId = + row.workspaceId === null ? undefined : parseStableIdentifierV1(row.workspaceId); + const reservationId = + row.reservationId === null ? undefined : parseStableIdentifierV1(row.reservationId); + const occurredAt = parseStrictUtcTimestampV1(row.occurredAt.toISOString()); + const scope = persistedScope({ ...row, projectId: null }); + if ( + row.schemaVersion !== 1 || + !entryId.accepted || + !organizationId.accepted || + (row.workspaceId !== null && !workspaceId?.accepted) || + (row.reservationId !== null && !reservationId?.accepted) || + !occurredAt.accepted || + !metrics.has(row.metric) || + !buckets.has(row.bucket) || + !positiveInteger(row.sequence) || + !text(row.idempotencyKey, 200) + ) + throw new Error('BUA_PERSISTED_USAGE_ENTRY_INVALID'); + return Object.freeze({ + schemaVersion: 1, + entryId: entryId.value, + tenantScope: scope, + metric: row.metric as UsageMetricV1, + bucket: row.bucket as UsageBucketV1, + deltaUnits: integerUnits(row.deltaUnits), + sequence: row.sequence, + ...(reservationId?.accepted ? { reservationId: reservationId.value } : {}), + idempotencyKey: row.idempotencyKey, + occurredAt: occurredAt.value, + }); +} + +function persistedReservation(row: UsageReservationDatabaseRowV1): UsageReservationV1 { + const reservationId = parseStableIdentifierV1(row.id); + const occurredAt = parseStrictUtcTimestampV1(row.createdAt.toISOString()); + const scope = persistedScope({ ...row, projectId: null }); + if ( + !reservationId.accepted || + !occurredAt.accepted || + !metrics.has(row.metric) || + !reservationStatuses.has(row.status) || + !positiveInteger(row.revision) || + !Number.isSafeInteger(integerUnits(row.reservedUnits)) + ) + throw new Error('BUA_PERSISTED_RESERVATION_INVALID'); + return Object.freeze({ + reservationId: reservationId.value, + tenantScope: scope, + metric: row.metric as UsageMetricV1, + reservedUnits: integerUnits(row.reservedUnits), + status: row.status as UsageReservationV1['status'], + createdAt: occurredAt.value, + revision: row.revision, + }); +} + +function planCreateData(plan: EntitlementPlanV1): EntitlementPlanCreateDataV1 { + return { + planCode: plan.planCode, + schemaVersion: plan.schemaVersion, + displayNameKey: plan.displayNameKey, + features: plan.features, + quotas: plan.quotas, + providerIndependent: plan.providerIndependent, + createdAt: new Date(), + }; +} + +function snapshotCreateData(snapshot: EntitlementSnapshotV1): EntitlementSnapshotCreateDataV1 { + return { + ...databaseScope( + snapshot.workspaceId + ? { + scopeType: 'workspace', + organizationId: snapshot.organizationId, + workspaceId: snapshot.workspaceId, + } + : { scopeType: 'organization', organizationId: snapshot.organizationId }, + ), + id: snapshot.snapshotId, + schemaVersion: snapshot.schemaVersion, + scopeKey: scopeKey( + snapshot.workspaceId + ? { + scopeType: 'workspace', + organizationId: snapshot.organizationId, + workspaceId: snapshot.workspaceId, + } + : { scopeType: 'organization', organizationId: snapshot.organizationId }, + ), + planCode: snapshot.planCode, + status: snapshot.status, + revision: snapshot.revision, + securityEpoch: snapshot.securityEpoch, + effectiveAt: new Date(snapshot.effectiveAt), + expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null, + features: snapshot.features, + quotas: snapshot.quotas, + createdAt: new Date(), + }; +} + +function entryCreateData(entry: UsageLedgerEntryV1): UsageLedgerEntryCreateDataV1 { + return { + ...databaseScope(entry.tenantScope), + id: entry.entryId, + schemaVersion: entry.schemaVersion, + scopeKey: scopeKey(entry.tenantScope), + metric: entry.metric, + bucket: entry.bucket, + deltaUnits: BigInt(entry.deltaUnits), + sequence: entry.sequence, + reservationId: entry.reservationId ?? null, + idempotencyKey: entry.idempotencyKey, + occurredAt: new Date(entry.occurredAt), + createdAt: new Date(), + }; +} + +function reservationCreateData(reservation: UsageReservationV1): UsageReservationCreateDataV1 { + return { + ...databaseScope(reservation.tenantScope), + id: reservation.reservationId, + scopeKey: scopeKey(reservation.tenantScope), + metric: reservation.metric, + reservedUnits: BigInt(reservation.reservedUnits), + status: reservation.status, + createdAt: new Date(reservation.createdAt), + revision: reservation.revision, + updatedAt: new Date(), + }; +} + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReservationV1): boolean { + return ( + left.reservationId === right.reservationId && + left.metric === right.metric && + left.reservedUnits === right.reservedUnits && + JSON.stringify(left.tenantScope) === JSON.stringify(right.tenantScope) && + left.createdAt === right.createdAt + ); +} + +class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV1 { + public constructor(private readonly client: EntitlementDatabaseClientV1) {} + + public async savePlan(plan: EntitlementPlanV1): Promise { + const existing = await this.client.entitlementPlanRecord.findUnique({ + where: { planCode: plan.planCode }, + }); + if (existing !== null) { + if (JSON.stringify(persistedPlan(existing)) !== JSON.stringify(plan)) + throw new Error('BUA_IMMUTABLE_PLAN'); + return; + } + await this.client.entitlementPlanRecord.create({ data: planCreateData(plan) }); + } + + public async findPlan( + planCode: EntitlementPlanV1['planCode'], + ): Promise { + const row = await this.client.entitlementPlanRecord.findUnique({ where: { planCode } }); + return row === null ? undefined : persistedPlan(row); + } + + public async saveSnapshot( + context: IamTenantContextV1, + snapshot: EntitlementSnapshotV1, + ): Promise { + const scope = snapshot.workspaceId + ? { + scopeType: 'workspace' as const, + organizationId: snapshot.organizationId, + workspaceId: snapshot.workspaceId, + } + : { scopeType: 'organization' as const, organizationId: snapshot.organizationId }; + if (!tenantScopeContainsV1(context.tenantScope, scope)) + throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.entitlementSnapshotRecord.findUnique({ + where: { id: snapshot.snapshotId }, + }); + if (existing !== null) { + if (JSON.stringify(persistedSnapshot(existing)) !== JSON.stringify(snapshot)) + throw new Error('BUA_IMMUTABLE_SNAPSHOT'); + return; + } + await this.client.entitlementSnapshotRecord.create({ data: snapshotCreateData(snapshot) }); + } + + public async findSnapshot( + context: IamTenantContextV1, + snapshotId: EntitlementSnapshotV1['snapshotId'], + ): Promise { + const row = await this.client.entitlementSnapshotRecord.findUnique({ + where: { id: snapshotId }, + }); + if (row === null) return undefined; + const snapshot = persistedSnapshot(row); + const scope = snapshot.workspaceId + ? { + scopeType: 'workspace' as const, + organizationId: snapshot.organizationId, + workspaceId: snapshot.workspaceId, + } + : { scopeType: 'organization' as const, organizationId: snapshot.organizationId }; + return visible(context.tenantScope, scope) ? snapshot : undefined; + } + + public async listUsageState(context: IamTenantContextV1): Promise { + const [entryRows, reservationRows] = await Promise.all([ + this.client.usageLedgerEntryRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { sequence: 'asc' }, + }), + this.client.usageReservationRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { createdAt: 'asc' }, + }), + ]); + return Object.freeze({ + entries: Object.freeze( + entryRows + .filter((row) => + visible(context.tenantScope, persistedScope({ ...row, projectId: null })), + ) + .map(persistedEntry), + ), + reservations: Object.freeze( + reservationRows + .filter((row) => + visible(context.tenantScope, persistedScope({ ...row, projectId: null })), + ) + .map(persistedReservation), + ), + }); + } + + public async persistUsageState( + context: IamTenantContextV1, + state: UsageLedgerStateV1, + ): Promise { + for (const entry of state.entries) { + if (!tenantScopeContainsV1(context.tenantScope, entry.tenantScope)) + throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.usageLedgerEntryRecord.findUnique({ + where: { id: entry.entryId }, + }); + if (existing !== null) { + if (JSON.stringify(persistedEntry(existing)) !== JSON.stringify(entry)) + throw new Error('BUA_IMMUTABLE_USAGE_ENTRY'); + continue; + } + await this.client.usageLedgerEntryRecord.create({ data: entryCreateData(entry) }); + } + for (const reservation of state.reservations) { + if (!tenantScopeContainsV1(context.tenantScope, reservation.tenantScope)) + throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.usageReservationRecord.findUnique({ + where: { id: reservation.reservationId }, + }); + if (existing === null) { + await this.client.usageReservationRecord.create({ + data: reservationCreateData(reservation), + }); + continue; + } + const current = persistedReservation(existing); + if (JSON.stringify(current) === JSON.stringify(reservation)) continue; + if ( + !sameReservationExceptStatus(current, reservation) || + reservation.revision !== current.revision + 1 + ) + throw new Error('BUA_RESERVATION_CONFLICT'); + if (!this.client.usageReservationRecord.update) throw new Error('BUA_UPDATE_UNAVAILABLE'); + await this.client.usageReservationRecord.update({ + where: { id: reservation.reservationId }, + data: { status: reservation.status, revision: reservation.revision, updatedAt: new Date() }, + }); + } + } +} + +export class PrismaEntitlementRepositoryAdapter implements EntitlementRepositoryPortV1 { + public constructor(private readonly client: EntitlementDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: EntitlementTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaEntitlementTransactionAdapter(transaction)), + ); + } + + public savePlan(plan: EntitlementPlanV1): Promise { + return new PrismaEntitlementTransactionAdapter(this.client).savePlan(plan); + } + + public findPlan(planCode: EntitlementPlanV1['planCode']): Promise { + return new PrismaEntitlementTransactionAdapter(this.client).findPlan(planCode); + } + + public saveSnapshot(context: IamTenantContextV1, snapshot: EntitlementSnapshotV1): Promise { + return new PrismaEntitlementTransactionAdapter(this.client).saveSnapshot(context, snapshot); + } + + public findSnapshot( + context: IamTenantContextV1, + snapshotId: EntitlementSnapshotV1['snapshotId'], + ): Promise { + return new PrismaEntitlementTransactionAdapter(this.client).findSnapshot(context, snapshotId); + } + + public listUsageState(context: IamTenantContextV1): Promise { + return new PrismaEntitlementTransactionAdapter(this.client).listUsageState(context); + } + + public persistUsageState(context: IamTenantContextV1, state: UsageLedgerStateV1): Promise { + return new PrismaEntitlementTransactionAdapter(this.client).persistUsageState(context, state); + } +} diff --git a/services/api/src/features/bua/api/entitlement.controller.ts b/services/api/src/features/bua/api/entitlement.controller.ts new file mode 100644 index 00000000..0813c5ea --- /dev/null +++ b/services/api/src/features/bua/api/entitlement.controller.ts @@ -0,0 +1,55 @@ +import { Controller, Get, Inject, Param, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { EntitlementSnapshotV1, UsageLedgerStateV1 } from '@databreeze/domain/entitlements/v1'; + +import { + ENTITLEMENT_REPOSITORY_PORT, + type EntitlementRepositoryPortV1, +} from '../application/entitlement-repository.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +type EntitlementNotFoundV1 = { readonly accepted: false; readonly code: 'ENTITLEMENT_NOT_FOUND' }; + +@ApiTags('entitlements') +@ApiBearerAuth() +@Controller('v1/entitlements') +export class EntitlementController { + public constructor( + @Inject(ENTITLEMENT_REPOSITORY_PORT) + private readonly repository: EntitlementRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext: RequestTenantContextPortV1, + ) {} + + @Get('snapshots/:snapshotId') + @ApiOperation({ summary: 'Read one immutable entitlement snapshot in the caller scope' }) + async snapshot( + @Req() request: unknown, + @Param('snapshotId') snapshotIdInput: string, + ): Promise< + | EntitlementSnapshotV1 + | EntitlementNotFoundV1 + | { readonly accepted: false; readonly code: 'INVALID_IDENTIFIER' } + > { + const context = await this.requestContext.resolve(request); + const parsed = parseStableIdentifierV1(snapshotIdInput); + if (!parsed.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' }; + return ( + (await this.repository.findSnapshot(context, parsed.value)) ?? { + accepted: false, + code: 'ENTITLEMENT_NOT_FOUND', + } + ); + } + + @Get('usage') + @ApiOperation({ summary: 'Read the append-only usage ledger state in the caller scope' }) + async usage(@Req() request: unknown): Promise { + const context = await this.requestContext.resolve(request); + return this.repository.listUsageState(context); + } +} diff --git a/services/api/src/features/bua/bua.module.ts b/services/api/src/features/bua/bua.module.ts new file mode 100644 index 00000000..5a956f6b --- /dev/null +++ b/services/api/src/features/bua/bua.module.ts @@ -0,0 +1,52 @@ +import { type DynamicModule, Module } from '@nestjs/common'; + +import { InMemoryEntitlementRepositoryAdapter } from './adapter/in-memory-entitlement-repository.adapter.js'; +import { + PrismaEntitlementRepositoryAdapter, + type EntitlementDatabaseClientV1, +} from './adapter/prisma-entitlement-repository.adapter.js'; +import { EntitlementAdmissionService } from './application/entitlement-admission.service.js'; +import { + ENTITLEMENT_REPOSITORY_PORT, + type EntitlementRepositoryPortV1, +} from './application/entitlement-repository.port.js'; +import { EntitlementController } from './api/entitlement.controller.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, + UnavailableRequestTenantContextAdapter, +} from '../../platform/http/request-tenant-context.port.js'; + +export const ENTITLEMENT_ADMISSION_SERVICE = Symbol('ENTITLEMENT_ADMISSION_SERVICE'); + +export interface BuaModuleOptions { + readonly entitlementRepository?: EntitlementRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly entitlementDatabase?: EntitlementDatabaseClientV1; + readonly requestTenantContext?: RequestTenantContextPortV1; +} + +@Module({}) +export class BuaModule { + public static register(options: BuaModuleOptions = {}): DynamicModule { + const repository = + options.entitlementRepository ?? + (options.entitlementDatabase === undefined + ? new InMemoryEntitlementRepositoryAdapter() + : new PrismaEntitlementRepositoryAdapter(options.entitlementDatabase)); + const service = new EntitlementAdmissionService(repository); + return { + module: BuaModule, + controllers: [EntitlementController], + providers: [ + { provide: ENTITLEMENT_REPOSITORY_PORT, useValue: repository }, + { provide: ENTITLEMENT_ADMISSION_SERVICE, useValue: service }, + { + provide: REQUEST_TENANT_CONTEXT, + useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), + }, + ], + exports: [ENTITLEMENT_REPOSITORY_PORT, ENTITLEMENT_ADMISSION_SERVICE], + }; + } +} diff --git a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts index 6cd843fe..d70f8378 100644 --- a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts @@ -71,6 +71,7 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { private readonly clock: () => Date; private readonly sessions = new Map(); private readonly refreshTokens = new Map(); + private readonly accessTokens = new Map(); public constructor(options: SessionLifecycleAdapterOptionsV1 = {}) { this.clock = options.clock ?? (() => new Date()); @@ -99,6 +100,7 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { }); if (!created.accepted) return Promise.reject(new Error(`IAM_${created.code}`)); const refreshToken = tokenFor(activeTokenIdentifier); + const accessToken = tokenFor(stableIdentifier(randomUUID())); this.sessions.set(sessionId, { record: created.value, principal: Object.freeze({ ...principal }), @@ -112,9 +114,10 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { expiresAt: created.value.absoluteExpiresAt, status: 'ACTIVE', }); + this.accessTokens.set(digestToken(accessToken), sessionId); return Promise.resolve({ sessionId, - accessToken: tokenFor(stableIdentifier(randomUUID())), + accessToken, refreshToken, accessExpiresAt: created.value.accessExpiresAt, }); @@ -156,6 +159,7 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { token.status = 'USED'; session.activeTokenId = nextTokenId; const nextRefreshToken = tokenFor(nextTokenId); + const nextAccessToken = tokenFor(stableIdentifier(randomUUID())); this.refreshTokens.set(digestToken(nextRefreshToken), { tokenId: nextTokenId, sessionId: session.record.sessionId, @@ -163,9 +167,10 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { expiresAt: session.record.absoluteExpiresAt, status: 'ACTIVE', }); + this.accessTokens.set(digestToken(nextAccessToken), token.sessionId); return acceptedSession({ sessionId: session.record.sessionId, - accessToken: tokenFor(stableIdentifier(randomUUID())), + accessToken: nextAccessToken, refreshToken: nextRefreshToken, accessExpiresAt: addSeconds(this.clock(), ACCESS_TOKEN_SECONDS_V1), }); @@ -180,13 +185,25 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { return true; } - public findPrincipal(sessionIdInput: unknown): AuthenticatedPrincipalV1 | undefined { + public async findPrincipal( + sessionIdInput: unknown, + ): Promise { + await Promise.resolve(); if (typeof sessionIdInput !== 'string') return undefined; const session = this.sessions.get(sessionIdInput); if (!session || session.familyStatus !== 'ACTIVE') return undefined; return session.principal; } + public async findPrincipalByAccessToken( + accessTokenInput: unknown, + ): Promise { + await Promise.resolve(); + if (typeof accessTokenInput !== 'string' || accessTokenInput.length < 80) return undefined; + const sessionId = this.accessTokens.get(digestToken(accessTokenInput)); + return sessionId === undefined ? undefined : this.findPrincipal(sessionId); + } + private revokeFamily(familyId: StableIdentifierV1): void { for (const session of this.sessions.values()) { if (session.record.familyId === familyId) session.familyStatus = 'REVOKED'; diff --git a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts new file mode 100644 index 00000000..4830cfc5 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts @@ -0,0 +1,191 @@ +import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { + AuthenticatedPrincipalV1, + CredentialLookupPortV1, +} from '../application/authentication.port.js'; + +export interface UserIdentityDatabaseRowV1 { + readonly id: string; + readonly email: string; + readonly status: string; + readonly securityEpoch: number; +} + +export interface PasswordCredentialDatabaseRowV1 { + readonly id: string; + readonly userId: string; + readonly algorithm: string; + readonly encodedHash: string; +} + +export interface MembershipIdentityDatabaseRowV1 { + readonly id: string; + readonly principalId: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly scopeType: string; + readonly status: string; +} + +export interface WorkspaceIdentityDatabaseRowV1 { + readonly id: string; + readonly organizationId: string; + readonly status: string; +} + +export interface OrganizationIdentityDatabaseRowV1 { + readonly id: string; + readonly status: string; +} + +export interface MfaFactorDatabaseRowV1 { + readonly id: string; +} + +interface UniqueDelegateV1 { + findUnique(input: { readonly where: Readonly> }): Promise; +} + +interface WorkspaceLookupDelegateV1 extends UniqueDelegateV1 { + readonly findMany?: (input: { + readonly where: Readonly>; + }) => Promise; +} + +interface ListDelegateV1 { + findMany(input: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }): Promise; +} + +export interface CredentialLookupDatabaseClientV1 { + readonly userIdentity: UniqueDelegateV1; + readonly passwordCredential: UniqueDelegateV1; + readonly membershipIdentity: ListDelegateV1; + readonly workspaceIdentity: WorkspaceLookupDelegateV1; + readonly organizationIdentity: UniqueDelegateV1; + readonly mfaFactor: ListDelegateV1; +} + +interface ActiveMembershipV1 { + readonly organizationId: string; + readonly workspaceId?: string; +} + +function stableId(input: unknown): string | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function activeMembership( + row: MembershipIdentityDatabaseRowV1, + userId: string, +): ActiveMembershipV1 | undefined { + if (row.principalId !== userId || row.status !== 'ACTIVE') return undefined; + const organizationId = stableId(row.organizationId); + if (!organizationId) return undefined; + if (row.scopeType === 'WORKSPACE') { + if (row.projectId !== null) return undefined; + const workspaceId = stableId(row.workspaceId); + return workspaceId ? { organizationId, workspaceId } : undefined; + } + if (row.scopeType === 'ORGANIZATION' && row.workspaceId === null && row.projectId === null) + return { organizationId }; + return undefined; +} + +/** + * PostgreSQL-backed credential lookup. The adapter deliberately performs the + * complete ancestry checks instead of trusting a membership row to establish + * tenant authority. + */ +export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { + public constructor(private readonly client: CredentialLookupDatabaseClientV1) {} + + public async findCredential(emailInput: string): Promise< + | { + readonly principal: AuthenticatedPrincipalV1; + readonly credential: { readonly algorithm: 'argon2id'; readonly encodedHash: string }; + } + | undefined + > { + const normalized = normalizeEmailAddressV1(emailInput); + if (!normalized.accepted) return undefined; + const user = await this.client.userIdentity.findUnique({ where: { email: normalized.value } }); + if (!user || user.status !== 'ACTIVE') return undefined; + const userId = stableId(user.id); + if (!userId || !Number.isSafeInteger(user.securityEpoch) || user.securityEpoch < 1) + return undefined; + + const [credential, memberships] = await Promise.all([ + this.client.passwordCredential.findUnique({ where: { userId } }), + this.client.membershipIdentity.findMany({ + where: { principalId: userId, status: 'ACTIVE' }, + orderBy: { createdAt: 'asc' }, + }), + ]); + if ( + !credential || + credential.userId !== userId || + credential.algorithm !== 'argon2id' || + typeof credential.encodedHash !== 'string' || + credential.encodedHash.length === 0 || + credential.encodedHash.length > 768 + ) + return undefined; + + const selected = memberships + .map((membership) => activeMembership(membership, userId)) + .find((membership): membership is ActiveMembershipV1 => membership !== undefined); + if (!selected) return undefined; + + const [organization, factors] = await Promise.all([ + this.client.organizationIdentity.findUnique({ where: { id: selected.organizationId } }), + this.client.mfaFactor.findMany({ where: { userId, status: 'ACTIVE' } }), + ]); + let workspaceId = selected.workspaceId; + if (!workspaceId) { + if (!this.client.workspaceIdentity.findMany) return undefined; + const workspaces = await this.client.workspaceIdentity.findMany({ + where: { organizationId: selected.organizationId, status: 'ACTIVE' }, + }); + const workspace = workspaces.find( + (candidate) => + candidate.organizationId === selected.organizationId && candidate.status === 'ACTIVE', + ); + workspaceId = workspace ? stableId(workspace.id) : undefined; + } + if (!workspaceId) return undefined; + const workspace = await this.client.workspaceIdentity.findUnique({ + where: { id: workspaceId }, + }); + if ( + !organization || + organization.id !== selected.organizationId || + organization.status !== 'ACTIVE' || + !workspace || + workspace.id !== workspaceId || + workspace.organizationId !== selected.organizationId || + workspace.status !== 'ACTIVE' + ) + return undefined; + + return Object.freeze({ + principal: Object.freeze({ + userId, + organizationId: selected.organizationId, + workspaceId, + securityEpoch: user.securityEpoch, + mfaRequired: factors.length > 0, + }), + credential: Object.freeze({ + algorithm: 'argon2id' as const, + encodedHash: credential.encodedHash, + }), + }); + } +} diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts new file mode 100644 index 00000000..ccf18823 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -0,0 +1,206 @@ +import { + tenantScopeContainsV1, + tenantScopesEqualV1, + parseStableIdentifierV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { validateMembershipV1, type MembershipIdentityV1 } from '@databreeze/domain/identity/v1'; + +import type { + IamMembershipRecordV1, + IamRepositoryPortV1, + IamTransactionPortV1, +} from '../application/iam-repository.port.js'; +import type { IamTenantContextV1 } from '../application/tenant-context.js'; + +export interface IamMembershipDatabaseRowV1 { + readonly id: string; + readonly principalType: string; + readonly principalId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly roleId: string; + readonly status: string; + readonly startsAt?: Date | null; + readonly expiresAt?: Date | null; + readonly revision: number; +} + +interface IamMembershipDelegateV1 { + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + }): Promise; + create(input: { readonly data: IamMembershipDatabaseRowV1 }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: Partial; + }): Promise; +} + +export interface IamDatabaseClientV1 { + readonly membershipIdentity: IamMembershipDelegateV1; + $transaction( + work: (transaction: IamDatabaseClientV1) => Promise, + ): Promise; +} + +function timestamp(input: Date | null | undefined): string | undefined { + return input?.toISOString(); +} + +function scopeFromRow(row: IamMembershipDatabaseRowV1): TenantScopeV1 | undefined { + const organizationId = parseStableIdentifierV1(row.organizationId); + if (!organizationId.accepted) return undefined; + if (row.scopeType === 'ORGANIZATION' && row.workspaceId === null && row.projectId === null) + return { scopeType: 'organization', organizationId: organizationId.value }; + const workspaceId = parseStableIdentifierV1(row.workspaceId); + if (!workspaceId.accepted) return undefined; + if (row.scopeType === 'WORKSPACE' && row.projectId === null) + return { + scopeType: 'workspace', + organizationId: organizationId.value, + workspaceId: workspaceId.value, + }; + const projectId = parseStableIdentifierV1(row.projectId); + if (!projectId.accepted || row.scopeType !== 'PROJECT') return undefined; + return { + scopeType: 'project', + organizationId: organizationId.value, + workspaceId: workspaceId.value, + projectId: projectId.value, + }; +} + +function membershipFromRow(row: IamMembershipDatabaseRowV1): IamMembershipRecordV1 { + const scope = scopeFromRow(row); + const validated = validateMembershipV1({ + id: row.id, + principalType: row.principalType, + principalId: row.principalId, + scope, + roleId: row.roleId, + status: row.status, + ...(row.startsAt ? { startsAt: timestamp(row.startsAt) } : {}), + ...(row.expiresAt ? { expiresAt: timestamp(row.expiresAt) } : {}), + revision: row.revision, + }); + if (!validated.accepted) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); + return validated.value; +} + +function membershipRow(membership: MembershipIdentityV1): IamMembershipDatabaseRowV1 { + return { + id: membership.id, + principalType: membership.principalType, + principalId: membership.principalId, + scopeType: membership.scope.scopeType.toUpperCase(), + organizationId: membership.scope.organizationId, + workspaceId: + membership.scope.scopeType === 'organization' ? null : membership.scope.workspaceId, + projectId: membership.scope.scopeType === 'project' ? membership.scope.projectId : null, + roleId: membership.roleId, + status: membership.status, + startsAt: membership.startsAt ? new Date(membership.startsAt) : null, + expiresAt: membership.expiresAt ? new Date(membership.expiresAt) : null, + revision: membership.revision, + }; +} + +function visibleInScope(context: TenantScopeV1, membership: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, membership) || tenantScopeContainsV1(membership, context); +} + +class PrismaIamTransactionAdapter implements IamTransactionPortV1 { + public constructor(private readonly client: IamDatabaseClientV1) {} + + public async findMembership( + context: IamTenantContextV1, + principalId: StableIdentifierV1, + ): Promise { + const rows = await this.client.membershipIdentity.findMany({ where: { principalId } }); + return rows + .map(membershipFromRow) + .find( + (membership) => + membership.principalId === principalId && + membership.status === 'ACTIVE' && + visibleInScope(context.tenantScope, membership.scope), + ); + } + + public async listMemberships( + context: IamTenantContextV1, + ): Promise { + const rows = await this.client.membershipIdentity.findMany({ where: {} }); + return rows + .map(membershipFromRow) + .filter((membership) => visibleInScope(context.tenantScope, membership.scope)); + } + + public async saveMembership( + context: IamTenantContextV1, + membership: IamMembershipRecordV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, membership.scope)) + throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); + const validated = validateMembershipV1({ ...membership, principalType: 'USER' }); + if (!validated.accepted) throw new Error(`IAM_${validated.code}`); + const existingRow = await this.client.membershipIdentity.findUnique({ + where: { id: membership.id }, + }); + if (!existingRow) { + if (context.expectedRevision !== undefined) throw new Error('IAM_REVISION_CONFLICT'); + await this.client.membershipIdentity.create({ data: membershipRow(validated.value) }); + return; + } + const existing = membershipFromRow(existingRow); + if (context.expectedRevision !== existing.revision) throw new Error('IAM_REVISION_CONFLICT'); + if (membership.revision !== existing.revision + 1) throw new Error('IAM_REVISION_CONFLICT'); + if ( + existing.principalId !== membership.principalId || + !tenantScopesEqualV1(existing.scope, membership.scope) + ) + throw new Error('IAM_MEMBERSHIP_SCOPE_IMMUTABLE'); + await this.client.membershipIdentity.update({ + where: { id: membership.id }, + data: { + roleId: membership.roleId, + status: membership.status, + revision: membership.revision, + }, + }); + } +} + +export class PrismaIamRepositoryAdapter implements IamRepositoryPortV1 { + public constructor(private readonly client: IamDatabaseClientV1) {} + + public findMembership(context: IamTenantContextV1, principalId: StableIdentifierV1) { + return new PrismaIamTransactionAdapter(this.client).findMembership(context, principalId); + } + + public listMemberships(context: IamTenantContextV1) { + return new PrismaIamTransactionAdapter(this.client).listMemberships(context); + } + + public saveMembership(context: IamTenantContextV1, membership: IamMembershipRecordV1) { + return this.client.$transaction((transaction) => + new PrismaIamTransactionAdapter(transaction).saveMembership(context, membership), + ); + } + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: IamTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaIamTransactionAdapter(transaction)), + ); + } +} diff --git a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts new file mode 100644 index 00000000..f30ecced --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts @@ -0,0 +1,318 @@ +import { + bootstrapPersonalOrganizationV1, + createUserIdentityV1, + type MembershipIdentityV1, + type PersonalOrganizationBootstrapV1, + type UserIdentityV1, +} from '@databreeze/domain/identity/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { + IdentityBootstrapRepositoryPortV1, + IdentityBootstrapTransactionPortV1, +} from '../application/identity-bootstrap-repository.port.js'; + +export interface UserIdentityDatabaseRowV1 { + readonly id: string; + readonly email: string; + readonly displayName: string; + readonly locale: string; + readonly status: string; + readonly securityEpoch: number; + readonly createdAt: Date; +} + +export interface OrganizationIdentityDatabaseRowV1 { + readonly id: string; + readonly name: string; + readonly personal: boolean; + readonly status: string; + readonly createdAt: Date; +} + +export interface WorkspaceIdentityDatabaseRowV1 { + readonly id: string; + readonly organizationId: string; + readonly name: string; + readonly status: string; + readonly authorizationEpoch: number; + readonly createdAt: Date; +} + +export interface ProjectIdentityDatabaseRowV1 { + readonly id: string; + readonly organizationId: string; + readonly workspaceId: string; + readonly kind: string; + readonly name: string; + readonly status: string; + readonly createdAt: Date; +} + +export interface MembershipIdentityDatabaseRowV1 { + readonly id: string; + readonly principalType: string; + readonly principalId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly roleId: string; + readonly status: string; + readonly startsAt?: Date | null; + readonly expiresAt?: Date | null; + readonly revision: number; +} + +interface IdentityDelegateV1 { + findUnique(input: { readonly where: { readonly id: string } }): Promise; + create(input: { readonly data: TRow }): Promise; +} + +interface ListDelegateV1 { + findMany(input: { readonly where: Readonly> }): Promise; +} + +interface UserDelegateV1 { + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; +} + +interface MembershipDelegateV1 extends IdentityDelegateV1 { + findMany(input: { + readonly where: Readonly>; + }): Promise; +} + +export interface IdentityBootstrapDatabaseClientV1 { + readonly userIdentity: UserDelegateV1; + readonly organizationIdentity: IdentityDelegateV1; + readonly workspaceIdentity: IdentityDelegateV1 & + ListDelegateV1; + readonly projectIdentity: IdentityDelegateV1 & + ListDelegateV1; + readonly membershipIdentity: MembershipDelegateV1; + $transaction( + work: (transaction: IdentityBootstrapDatabaseClientV1) => Promise, + ): Promise; +} + +function stableId(input: unknown): string | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: Date | null | undefined): string | undefined { + if (!input) return undefined; + const parsed = parseStrictUtcTimestampV1(input.toISOString()); + return parsed.accepted ? parsed.value : undefined; +} + +function userFromRow(row: UserIdentityDatabaseRowV1): UserIdentityV1 { + const created = createUserIdentityV1({ + id: row.id, + displayName: row.displayName, + locale: row.locale, + securityEpoch: row.securityEpoch, + status: row.status, + createdAt: timestamp(row.createdAt), + }); + if (!created.accepted) throw new Error('IAM_PERSISTED_USER_INVALID'); + return created.value; +} + +function membershipMatches( + row: MembershipIdentityDatabaseRowV1, + expected: MembershipIdentityV1, +): boolean { + return ( + row.id === expected.id && + row.principalType === expected.principalType && + row.principalId === expected.principalId && + row.scopeType === 'ORGANIZATION' && + row.organizationId === expected.scope.organizationId && + row.workspaceId === null && + row.projectId === null && + row.roleId === expected.roleId && + row.status === expected.status && + row.revision === expected.revision && + row.startsAt === null && + row.expiresAt === null + ); +} + +function bootstrapRowsMatch( + bootstrap: PersonalOrganizationBootstrapV1, + organization: OrganizationIdentityDatabaseRowV1, + workspace: WorkspaceIdentityDatabaseRowV1, + project: ProjectIdentityDatabaseRowV1, + membership: MembershipIdentityDatabaseRowV1, +): boolean { + return ( + organization.id === bootstrap.organization.id && + organization.name === bootstrap.organization.name && + organization.personal === bootstrap.organization.personal && + organization.status === bootstrap.organization.status && + timestamp(organization.createdAt) === bootstrap.organization.createdAt && + workspace.id === bootstrap.workspace.id && + workspace.organizationId === bootstrap.workspace.organizationId && + workspace.name === bootstrap.workspace.name && + workspace.status === bootstrap.workspace.status && + workspace.authorizationEpoch === bootstrap.workspace.authorizationEpoch && + timestamp(workspace.createdAt) === bootstrap.workspace.createdAt && + project.id === bootstrap.project.id && + project.organizationId === bootstrap.project.organizationId && + project.workspaceId === bootstrap.project.workspaceId && + project.kind === bootstrap.project.kind && + project.name === bootstrap.project.name && + project.status === bootstrap.project.status && + timestamp(project.createdAt) === bootstrap.project.createdAt && + membershipMatches(membership, bootstrap.membership) + ); +} + +class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTransactionPortV1 { + public constructor(private readonly client: IdentityBootstrapDatabaseClientV1) {} + + public async findByUserId( + userId: PersonalOrganizationBootstrapV1['user']['id'], + ): Promise { + const userRow = await this.client.userIdentity.findUnique({ where: { id: userId } }); + if (!userRow) return undefined; + const user = userFromRow(userRow); + const memberships = await this.client.membershipIdentity.findMany({ + where: { principalId: user.id, status: 'ACTIVE', scopeType: 'ORGANIZATION' }, + }); + const membershipRow = memberships.find( + (candidate) => + candidate.principalId === user.id && + candidate.scopeType === 'ORGANIZATION' && + candidate.workspaceId === null && + candidate.projectId === null && + candidate.roleId === 'owner', + ); + if (!membershipRow) return undefined; + const organizationId = stableId(membershipRow.organizationId); + if (!organizationId) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); + const organization = await this.client.organizationIdentity.findUnique({ + where: { id: organizationId }, + }); + if (!organization || !organization.personal) + throw new Error('IAM_PERSISTED_ORGANIZATION_INVALID'); + const workspaceRows = await this.client.workspaceIdentity.findMany({ + where: { organizationId, status: 'ACTIVE' }, + }); + const workspace = workspaceRows.find((candidate) => candidate.name === 'Personal workspace'); + if (!workspace) throw new Error('IAM_PERSISTED_WORKSPACE_INVALID'); + const projectRows = await this.client.projectIdentity.findMany({ + where: { organizationId, workspaceId: workspace.id, status: 'ACTIVE' }, + }); + const project = projectRows.find((candidate) => candidate.kind === 'INTERNAL'); + if (!project) throw new Error('IAM_PERSISTED_PROJECT_INVALID'); + const canonical = bootstrapPersonalOrganizationV1({ + user: { + id: user.id, + displayName: user.displayName, + locale: user.locale, + securityEpoch: user.securityEpoch, + status: user.status, + createdAt: user.createdAt, + }, + organizationId, + workspaceId: workspace.id, + projectId: project.id, + membershipId: membershipRow.id, + createdAt: organization.createdAt.toISOString(), + }); + if (!canonical.accepted) throw new Error('IAM_PERSISTED_BOOTSTRAP_INVALID'); + if (!bootstrapRowsMatch(canonical.value, organization, workspace, project, membershipRow)) + throw new Error('IAM_PERSISTED_BOOTSTRAP_INVALID'); + return canonical.value; + } + + public async save(bootstrap: PersonalOrganizationBootstrapV1): Promise { + const userRow = await this.client.userIdentity.findUnique({ where: { id: bootstrap.user.id } }); + if (!userRow) throw new Error('IAM_USER_NOT_FOUND'); + if (JSON.stringify(userFromRow(userRow)) !== JSON.stringify(bootstrap.user)) + throw new Error('IAM_BOOTSTRAP_CONFLICT'); + const organizationData: OrganizationIdentityDatabaseRowV1 = { + id: bootstrap.organization.id, + name: bootstrap.organization.name, + personal: bootstrap.organization.personal, + status: bootstrap.organization.status, + createdAt: new Date(bootstrap.organization.createdAt), + }; + const workspaceData: WorkspaceIdentityDatabaseRowV1 = { + id: bootstrap.workspace.id, + organizationId: bootstrap.workspace.organizationId, + name: bootstrap.workspace.name, + status: bootstrap.workspace.status, + authorizationEpoch: bootstrap.workspace.authorizationEpoch, + createdAt: new Date(bootstrap.workspace.createdAt), + }; + const projectData: ProjectIdentityDatabaseRowV1 = { + id: bootstrap.project.id, + organizationId: bootstrap.project.organizationId, + workspaceId: bootstrap.project.workspaceId, + kind: bootstrap.project.kind, + name: bootstrap.project.name, + status: bootstrap.project.status, + createdAt: new Date(bootstrap.project.createdAt), + }; + const membershipData: MembershipIdentityDatabaseRowV1 = { + id: bootstrap.membership.id, + principalType: bootstrap.membership.principalType, + principalId: bootstrap.membership.principalId, + scopeType: 'ORGANIZATION', + organizationId: bootstrap.membership.scope.organizationId, + workspaceId: null, + projectId: null, + roleId: bootstrap.membership.roleId, + status: bootstrap.membership.status, + startsAt: null, + expiresAt: null, + revision: bootstrap.membership.revision, + }; + await this.saveImmutable(this.client.organizationIdentity, organizationData); + await this.saveImmutable(this.client.workspaceIdentity, workspaceData); + await this.saveImmutable(this.client.projectIdentity, projectData); + await this.saveImmutable(this.client.membershipIdentity, membershipData); + } + + private async saveImmutable( + delegate: IdentityDelegateV1, + expected: TRow, + ): Promise { + const existing = await delegate.findUnique({ where: { id: expected.id } }); + if (existing) { + if (JSON.stringify(existing) !== JSON.stringify(expected)) + throw new Error('IAM_BOOTSTRAP_CONFLICT'); + return; + } + await delegate.create({ data: expected }); + } +} + +export class PrismaIdentityBootstrapRepositoryAdapter implements IdentityBootstrapRepositoryPortV1 { + public constructor(private readonly client: IdentityBootstrapDatabaseClientV1) {} + + public findByUserId(userId: PersonalOrganizationBootstrapV1['user']['id']) { + return new PrismaIdentityBootstrapTransactionAdapter(this.client).findByUserId(userId); + } + + public save(bootstrap: PersonalOrganizationBootstrapV1) { + return new PrismaIdentityBootstrapTransactionAdapter(this.client).save(bootstrap); + } + + public withTransaction( + work: (transaction: IdentityBootstrapTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaIdentityBootstrapTransactionAdapter(transaction)), + ); + } +} diff --git a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts new file mode 100644 index 00000000..199897cc --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts @@ -0,0 +1,288 @@ +import { + createMfaFactorV1, + createRecoveryCodeV1, + type MfaFactorV1, + type MfaStateV1, + type RecoveryCodeV1, +} from '@databreeze/domain/mfa/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { + MfaRepositoryPortV1, + MfaTransactionPortV1, +} from '../application/mfa-repository.port.js'; + +export interface MfaFactorDatabaseRowV1 { + readonly id: string; + readonly userId: string; + readonly factorType: string; + readonly secretReference: string; + readonly status: string; + readonly enrolledAt: Date; + readonly verifiedAt: Date | null; + readonly revokedAt: Date | null; + readonly revision: number; +} + +export interface MfaRecoveryCodeDatabaseRowV1 { + readonly id: string; + readonly userId: string; + readonly digest: string; + readonly status: string; + readonly createdAt: Date; + readonly usedAt: Date | null; + readonly revision: number; +} + +interface MfaFactorDelegateV1 { + findMany(input: { + readonly where: Readonly>; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + create(input: { readonly data: MfaFactorDatabaseRowV1 }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: Partial; + }): Promise; +} + +interface MfaRecoveryCodeDelegateV1 { + findMany(input: { + readonly where: Readonly>; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + create(input: { + readonly data: MfaRecoveryCodeDatabaseRowV1; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: Partial; + }): Promise; +} + +export interface MfaDatabaseClientV1 { + readonly mfaFactor: MfaFactorDelegateV1; + readonly mfaRecoveryCode: MfaRecoveryCodeDelegateV1; + $transaction( + work: (transaction: MfaDatabaseClientV1) => Promise, + ): Promise; +} + +function timestamp(input: Date | null | undefined): StrictUtcTimestampV1 | undefined { + if (!input) return undefined; + const parsed = parseStrictUtcTimestampV1(input.toISOString()); + return parsed.accepted ? parsed.value : undefined; +} + +function stable(input: unknown): string | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function factorFromRow(row: MfaFactorDatabaseRowV1): MfaFactorV1 { + const created = createMfaFactorV1({ + id: row.id, + userId: row.userId, + method: row.factorType, + secretReference: row.secretReference, + enrolledAt: timestamp(row.enrolledAt), + }); + if (!created.accepted) throw new Error('IAM_PERSISTED_MFA_FACTOR_INVALID'); + if ( + (row.status !== 'PENDING' && row.status !== 'ACTIVE' && row.status !== 'REVOKED') || + !Number.isSafeInteger(row.revision) || + row.revision < 1 + ) + throw new Error('IAM_PERSISTED_MFA_FACTOR_INVALID'); + const verifiedAt = timestamp(row.verifiedAt); + const revokedAt = timestamp(row.revokedAt); + if ((row.verifiedAt && !verifiedAt) || (row.revokedAt && !revokedAt)) + throw new Error('IAM_PERSISTED_MFA_FACTOR_INVALID'); + return Object.freeze({ + ...created.value, + status: row.status, + revision: row.revision, + ...(verifiedAt ? { verifiedAt } : {}), + ...(revokedAt ? { revokedAt } : {}), + }); +} + +function recoveryCodeFromRow(row: MfaRecoveryCodeDatabaseRowV1): RecoveryCodeV1 { + const created = createRecoveryCodeV1({ + id: row.id, + userId: row.userId, + digest: row.digest, + createdAt: timestamp(row.createdAt), + }); + if (!created.accepted) throw new Error('IAM_PERSISTED_RECOVERY_CODE_INVALID'); + if ( + (row.status !== 'AVAILABLE' && row.status !== 'USED' && row.status !== 'REVOKED') || + !Number.isSafeInteger(row.revision) || + row.revision < 1 + ) + throw new Error('IAM_PERSISTED_RECOVERY_CODE_INVALID'); + const usedAt = timestamp(row.usedAt); + if (row.usedAt && !usedAt) throw new Error('IAM_PERSISTED_RECOVERY_CODE_INVALID'); + return Object.freeze({ + ...created.value, + status: row.status, + revision: row.revision, + ...(usedAt ? { usedAt } : {}), + }); +} + +function freezeState(state: MfaStateV1): MfaStateV1 { + return Object.freeze({ + factors: Object.freeze(state.factors.map((factor) => Object.freeze({ ...factor }))), + recoveryCodes: Object.freeze(state.recoveryCodes.map((code) => Object.freeze({ ...code }))), + }); +} + +function factorRow(factor: MfaFactorV1): MfaFactorDatabaseRowV1 { + return { + id: factor.id, + userId: factor.userId, + factorType: factor.method, + secretReference: factor.secretReference, + status: factor.status, + enrolledAt: new Date(factor.enrolledAt), + verifiedAt: factor.verifiedAt ? new Date(factor.verifiedAt) : null, + revokedAt: factor.revokedAt ? new Date(factor.revokedAt) : null, + revision: factor.revision, + }; +} + +function recoveryRow(code: RecoveryCodeV1): MfaRecoveryCodeDatabaseRowV1 { + return { + id: code.id, + userId: code.userId, + digest: code.digest, + status: code.status, + createdAt: new Date(code.createdAt), + usedAt: code.usedAt ? new Date(code.usedAt) : null, + revision: code.revision, + }; +} + +function immutableState(existing: MfaStateV1, next: MfaStateV1): boolean { + const existingFactors = new Map(existing.factors.map((factor) => [factor.id, factor])); + const existingCodes = new Map(existing.recoveryCodes.map((code) => [code.id, code])); + if ( + existing.factors.some((factor) => !next.factors.some((candidate) => candidate.id === factor.id)) + ) + return false; + if ( + existing.recoveryCodes.some( + (code) => !next.recoveryCodes.some((candidate) => candidate.id === code.id), + ) + ) + return false; + for (const factor of next.factors) { + const prior = existingFactors.get(factor.id); + if ( + prior && + (prior.userId !== factor.userId || prior.secretReference !== factor.secretReference) + ) + return false; + if (prior && factor.revision !== prior.revision && factor.revision !== prior.revision + 1) + return false; + if (!prior && factor.revision !== 1) return false; + } + for (const code of next.recoveryCodes) { + const prior = existingCodes.get(code.id); + if (prior && (prior.userId !== code.userId || prior.digest !== code.digest)) return false; + if (prior && code.revision !== prior.revision && code.revision !== prior.revision + 1) + return false; + if (!prior && code.revision !== 1) return false; + } + return true; +} + +class PrismaMfaTransactionAdapter implements MfaTransactionPortV1 { + public constructor(private readonly client: MfaDatabaseClientV1) {} + + public async findState(userId: string): Promise { + const [factors, recoveryCodes] = await Promise.all([ + this.client.mfaFactor.findMany({ where: { userId } }), + this.client.mfaRecoveryCode.findMany({ where: { userId } }), + ]); + return freezeState({ + factors: factors.map(factorFromRow), + recoveryCodes: recoveryCodes.map(recoveryCodeFromRow), + }); + } + + public async saveState(userId: string, state: MfaStateV1): Promise { + if (!stable(userId)) throw new Error('MFA_INVALID_USER'); + if ( + !state.factors.every((factor) => factor.userId === userId) || + !state.recoveryCodes.every((code) => code.userId === userId) + ) + throw new Error('MFA_SCOPE_MISMATCH'); + const existing = await this.findState(userId); + if (!immutableState(existing, state)) throw new Error('IAM_MFA_REVISION_CONFLICT'); + for (const factor of state.factors) { + const prior = existing.factors.find((candidate) => candidate.id === factor.id); + if (!prior) { + await this.client.mfaFactor.create({ data: factorRow(factor) }); + continue; + } + if (JSON.stringify(prior) === JSON.stringify(factor)) continue; + await this.client.mfaFactor.update({ + where: { id: factor.id }, + data: { + status: factor.status, + verifiedAt: factor.verifiedAt ? new Date(factor.verifiedAt) : null, + revokedAt: factor.revokedAt ? new Date(factor.revokedAt) : null, + revision: factor.revision, + }, + }); + } + for (const code of state.recoveryCodes) { + const prior = existing.recoveryCodes.find((candidate) => candidate.id === code.id); + if (!prior) { + await this.client.mfaRecoveryCode.create({ data: recoveryRow(code) }); + continue; + } + if (JSON.stringify(prior) === JSON.stringify(code)) continue; + await this.client.mfaRecoveryCode.update({ + where: { id: code.id }, + data: { + status: code.status, + usedAt: code.usedAt ? new Date(code.usedAt) : null, + revision: code.revision, + }, + }); + } + } +} + +export class PrismaMfaRepositoryAdapter implements MfaRepositoryPortV1 { + public constructor(private readonly client: MfaDatabaseClientV1) {} + + public findState(userId: string) { + return new PrismaMfaTransactionAdapter(this.client).findState(userId); + } + + public saveState(userId: string, state: MfaStateV1) { + return this.client.$transaction((transaction) => + new PrismaMfaTransactionAdapter(transaction).saveState(userId, state), + ); + } + + public withTransaction( + work: (transaction: MfaTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaMfaTransactionAdapter(transaction)), + ); + } +} diff --git a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts new file mode 100644 index 00000000..e5de3a6c --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -0,0 +1,526 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; + +import { + createSessionRecordV1, + rotateRefreshFamilyV1, + type SessionRecordV1, +} from '@databreeze/domain/identity/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { + AuthenticationSessionV1, + AuthenticatedPrincipalV1, + SessionIssuerPortV1, +} from '../application/authentication.port.js'; +import type { + SessionLifecyclePortV1, + SessionRefreshFailureCodeV1, + SessionRefreshResultV1, +} from '../application/session-lifecycle.port.js'; + +export interface SessionRecordDatabaseRowV1 { + readonly id: string; + readonly userId: string; + readonly familyId: string; + readonly issuedAt: Date; + readonly accessExpiresAt: Date; + readonly inactivityExpiresAt: Date; + readonly absoluteExpiresAt: Date; + readonly status: string; + readonly revokedAt?: Date | null; +} + +export interface RefreshTokenDatabaseRowV1 { + readonly id: string; + readonly sessionId: string; + readonly familyId: string; + readonly tokenDigest: string; + readonly status: string; + readonly issuedAt: Date; + readonly expiresAt: Date; + readonly usedAt?: Date | null; +} + +export interface AccessTokenDatabaseRowV1 { + readonly id: string; + readonly sessionId: string; + readonly tokenDigest: string; + readonly issuedAt: Date; + readonly expiresAt: Date; + readonly status: string; + readonly revokedAt?: Date | null; +} + +export interface SessionUserDatabaseRowV1 { + readonly id: string; + readonly status: string; + readonly securityEpoch: number; +} + +export interface SessionMembershipDatabaseRowV1 { + readonly id: string; + readonly principalId: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly scopeType: string; + readonly status: string; +} + +export interface SessionWorkspaceDatabaseRowV1 { + readonly id: string; + readonly organizationId: string; + readonly status: string; +} + +export interface SessionOrganizationDatabaseRowV1 { + readonly id: string; + readonly status: string; +} + +export interface SessionMfaFactorDatabaseRowV1 { + readonly id: string; +} + +interface SessionDelegateV1 { + create(input: { readonly data: SessionRecordDatabaseRowV1 }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: Partial; + }): Promise; +} + +interface RefreshTokenDelegateV1 { + create(input: { readonly data: RefreshTokenDatabaseRowV1 }): Promise; + findUnique(input: { + readonly where: { readonly tokenDigest: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Partial; + }): Promise<{ readonly count: number }>; +} + +interface AccessTokenDelegateV1 { + create(input: { readonly data: AccessTokenDatabaseRowV1 }): Promise; + findUnique(input: { + readonly where: { readonly tokenDigest: string }; + }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Partial; + }): Promise<{ readonly count: number }>; +} + +interface UniqueDelegateV1 { + findUnique(input: { readonly where: Readonly> }): Promise; +} + +interface ListDelegateV1 { + findMany(input: { readonly where: Readonly> }): Promise; +} + +export interface SessionLifecycleDatabaseClientV1 { + readonly sessionRecord: SessionDelegateV1; + readonly refreshTokenRecord: RefreshTokenDelegateV1; + readonly accessTokenRecord: AccessTokenDelegateV1; + readonly userIdentity: UniqueDelegateV1; + readonly membershipIdentity: ListDelegateV1; + readonly workspaceIdentity: UniqueDelegateV1; + readonly organizationIdentity: UniqueDelegateV1; + readonly mfaFactor: ListDelegateV1; + $transaction( + work: (transaction: SessionLifecycleDatabaseClientV1) => Promise, + ): Promise; +} + +export interface SessionLifecycleAdapterOptionsV1 { + readonly clock?: () => Date; +} + +const ACCESS_TOKEN_SECONDS_V1 = 15 * 60; +const INACTIVITY_SECONDS_V1 = 60 * 60; +const ABSOLUTE_SECONDS_V1 = 30 * 24 * 60 * 60; + +function stableIdentifier(input: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(input); + if (!parsed.accepted) throw new Error('IAM_INVALID_IDENTIFIER'); + return parsed.value; +} + +function timestamp(input: Date | null | undefined): StrictUtcTimestampV1 | undefined { + if (!input) return undefined; + const parsed = parseStrictUtcTimestampV1(input.toISOString()); + return parsed.accepted ? parsed.value : undefined; +} + +function addSeconds(now: Date, seconds: number, upperBound?: string): string { + const candidate = new Date(now.getTime() + seconds * 1_000); + if (!upperBound || candidate.toISOString() <= upperBound) return candidate.toISOString(); + return upperBound; +} + +function digestToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('base64url'); +} + +function tokenFor(tokenId: string): string { + return `${tokenId}.${randomBytes(32).toString('base64url')}`; +} + +function sessionFromRow(row: SessionRecordDatabaseRowV1): SessionRecordV1 { + const created = createSessionRecordV1({ + sessionId: row.id, + userId: row.userId, + familyId: row.familyId, + issuedAt: timestamp(row.issuedAt), + accessExpiresAt: timestamp(row.accessExpiresAt), + inactivityExpiresAt: timestamp(row.inactivityExpiresAt), + absoluteExpiresAt: timestamp(row.absoluteExpiresAt), + }); + if (!created.accepted) throw new Error('IAM_PERSISTED_SESSION_INVALID'); + if (row.status !== 'ACTIVE' && row.status !== 'REVOKED' && row.status !== 'EXPIRED') + throw new Error('IAM_PERSISTED_SESSION_INVALID'); + return Object.freeze({ ...created.value, status: row.status }); +} + +function tokenFromRow(row: RefreshTokenDatabaseRowV1): { + readonly id: StableIdentifierV1; + readonly sessionId: StableIdentifierV1; + readonly familyId: StableIdentifierV1; + readonly expiresAt: StrictUtcTimestampV1; + readonly status: 'ACTIVE' | 'USED' | 'REVOKED' | 'EXPIRED'; +} { + const id = stableIdentifier(row.id); + const sessionId = stableIdentifier(row.sessionId); + const familyId = stableIdentifier(row.familyId); + const expiresAt = timestamp(row.expiresAt); + if (!expiresAt || row.tokenDigest.length < 32 || row.tokenDigest.length > 128) + throw new Error('IAM_PERSISTED_REFRESH_TOKEN_INVALID'); + if ( + row.status !== 'ACTIVE' && + row.status !== 'USED' && + row.status !== 'REVOKED' && + row.status !== 'EXPIRED' + ) + throw new Error('IAM_PERSISTED_REFRESH_TOKEN_INVALID'); + return { id, sessionId, familyId, expiresAt, status: row.status }; +} + +function successfulSession(session: AuthenticationSessionV1): SessionRefreshResultV1 { + return Object.freeze({ accepted: true, value: Object.freeze(session) }); +} + +/** PostgreSQL-backed, transactional session and refresh-token family lifecycle. */ +export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { + private readonly clock: () => Date; + + public constructor( + private readonly client: SessionLifecycleDatabaseClientV1, + options: SessionLifecycleAdapterOptionsV1 = {}, + ) { + this.clock = options.clock ?? (() => new Date()); + } + + public async issue( + principal: AuthenticatedPrincipalV1, + clientPlatform: 'android' | 'desktop' | 'web', + ): Promise { + void clientPlatform; + const now = this.clock(); + const sessionId = stableIdentifier(randomUUID()); + const familyId = stableIdentifier(randomUUID()); + const refreshTokenId = stableIdentifier(randomUUID()); + const created = createSessionRecordV1({ + sessionId, + userId: principal.userId, + familyId, + issuedAt: now.toISOString(), + accessExpiresAt: addSeconds(now, ACCESS_TOKEN_SECONDS_V1), + inactivityExpiresAt: addSeconds(now, INACTIVITY_SECONDS_V1), + absoluteExpiresAt: addSeconds(now, ABSOLUTE_SECONDS_V1), + }); + if (!created.accepted) throw new Error(`IAM_${created.code}`); + const refreshToken = tokenFor(refreshTokenId); + const accessTokenId = stableIdentifier(randomUUID()); + const accessToken = tokenFor(accessTokenId); + const record = created.value; + await this.client.$transaction(async (transaction) => { + await transaction.sessionRecord.create({ + data: { + id: record.sessionId, + userId: record.userId, + familyId: record.familyId, + issuedAt: new Date(record.issuedAt), + accessExpiresAt: new Date(record.accessExpiresAt), + inactivityExpiresAt: new Date(record.inactivityExpiresAt), + absoluteExpiresAt: new Date(record.absoluteExpiresAt), + status: 'ACTIVE', + revokedAt: null, + }, + }); + await transaction.refreshTokenRecord.create({ + data: { + id: refreshTokenId, + sessionId: record.sessionId, + familyId: record.familyId, + tokenDigest: digestToken(refreshToken), + status: 'ACTIVE', + issuedAt: new Date(record.issuedAt), + expiresAt: new Date(record.absoluteExpiresAt), + usedAt: null, + }, + }); + await transaction.accessTokenRecord.create({ + data: { + id: accessTokenId, + sessionId: record.sessionId, + tokenDigest: digestToken(accessToken), + issuedAt: new Date(record.issuedAt), + expiresAt: new Date(record.accessExpiresAt), + status: 'ACTIVE', + revokedAt: null, + }, + }); + }); + return { + sessionId: record.sessionId, + accessToken, + refreshToken, + accessExpiresAt: record.accessExpiresAt, + }; + } + + public async refresh( + refreshTokenInput: unknown, + clientPlatform: 'android' | 'desktop' | 'web', + ): Promise { + void clientPlatform; + if (typeof refreshTokenInput !== 'string' || refreshTokenInput.length < 80) + return { accepted: false, code: 'INVALID_REFRESH_TOKEN' }; + const digest = digestToken(refreshTokenInput); + const now = this.clock(); + return this.client.$transaction(async (transaction) => { + const persisted = await transaction.refreshTokenRecord.findUnique({ + where: { tokenDigest: digest }, + }); + if (!persisted) return { accepted: false, code: 'INVALID_REFRESH_TOKEN' }; + const token = tokenFromRow(persisted); + const sessionRow = await transaction.sessionRecord.findUnique({ + where: { id: token.sessionId }, + }); + if (!sessionRow) return { accepted: false, code: 'INVALID_REFRESH_TOKEN' }; + const session = sessionFromRow(sessionRow); + const active = await transaction.refreshTokenRecord.findMany({ + where: { sessionId: token.sessionId, familyId: token.familyId, status: 'ACTIVE' }, + }); + const activeToken = active[0] ? tokenFromRow(active[0]) : undefined; + const rotated = rotateRefreshFamilyV1({ + now: now.toISOString(), + presentedTokenId: token.id, + activeTokenId: activeToken?.id ?? token.id, + nextTokenId: stableIdentifier(randomUUID()), + familyStatus: session.status === 'ACTIVE' ? 'ACTIVE' : 'REVOKED', + tokenExpiresAt: token.expiresAt, + }); + if (!rotated.accepted || !rotated.nextTokenId) { + if (rotated.code === 'REUSE_DETECTED') { + await transaction.refreshTokenRecord.updateMany({ + where: { familyId: token.familyId, status: 'ACTIVE' }, + data: { status: 'REVOKED' }, + }); + await transaction.sessionRecord.update({ + where: { id: token.sessionId }, + data: { status: 'REVOKED', revokedAt: now }, + }); + await transaction.accessTokenRecord.updateMany({ + where: { sessionId: token.sessionId, status: 'ACTIVE' }, + data: { status: 'REVOKED', revokedAt: now }, + }); + } else if (rotated.code === 'EXPIRED' && token.status === 'ACTIVE') { + await transaction.refreshTokenRecord.updateMany({ + where: { id: token.id, status: 'ACTIVE' }, + data: { status: 'EXPIRED' }, + }); + } + const failureCode: SessionRefreshFailureCodeV1 = + rotated.code === 'EXPIRED' + ? 'EXPIRED' + : rotated.code === 'REUSE_DETECTED' + ? 'REUSE_DETECTED' + : 'REVOKED_FAMILY'; + return { + accepted: false, + code: failureCode, + }; + } + const consumed = await transaction.refreshTokenRecord.updateMany({ + where: { id: token.id, status: 'ACTIVE' }, + data: { status: 'USED', usedAt: now }, + }); + if (consumed.count !== 1) return { accepted: false, code: 'INVALID_REFRESH_TOKEN' }; + const accessExpiresAt = addSeconds(now, ACCESS_TOKEN_SECONDS_V1, session.absoluteExpiresAt); + const inactivityExpiresAt = addSeconds(now, INACTIVITY_SECONDS_V1, session.absoluteExpiresAt); + await transaction.sessionRecord.update({ + where: { id: session.sessionId }, + data: { + accessExpiresAt: new Date(accessExpiresAt), + inactivityExpiresAt: new Date(inactivityExpiresAt), + }, + }); + const nextRefreshToken = tokenFor(rotated.nextTokenId); + const nextAccessTokenId = stableIdentifier(randomUUID()); + const nextAccessToken = tokenFor(nextAccessTokenId); + await transaction.refreshTokenRecord.create({ + data: { + id: rotated.nextTokenId, + sessionId: session.sessionId, + familyId: session.familyId, + tokenDigest: digestToken(nextRefreshToken), + status: 'ACTIVE', + issuedAt: now, + expiresAt: new Date(session.absoluteExpiresAt), + usedAt: null, + }, + }); + await transaction.accessTokenRecord.create({ + data: { + id: nextAccessTokenId, + sessionId: session.sessionId, + tokenDigest: digestToken(nextAccessToken), + issuedAt: now, + expiresAt: new Date(accessExpiresAt), + status: 'ACTIVE', + revokedAt: null, + }, + }); + return successfulSession({ + sessionId: session.sessionId, + accessToken: nextAccessToken, + refreshToken: nextRefreshToken, + accessExpiresAt, + }); + }); + } + + public async revoke(sessionIdInput: unknown): Promise { + if (typeof sessionIdInput !== 'string') return false; + const sessionId = parseStableIdentifierV1(sessionIdInput); + if (!sessionId.accepted) return false; + const now = this.clock(); + return this.client.$transaction(async (transaction) => { + const session = await transaction.sessionRecord.findUnique({ + where: { id: sessionId.value }, + }); + if (!session) return false; + await transaction.sessionRecord.update({ + where: { id: sessionId.value }, + data: { status: 'REVOKED', revokedAt: session.revokedAt ?? now }, + }); + await transaction.refreshTokenRecord.updateMany({ + where: { familyId: session.familyId, status: 'ACTIVE' }, + data: { status: 'REVOKED' }, + }); + await transaction.accessTokenRecord.updateMany({ + where: { sessionId: session.id, status: 'ACTIVE' }, + data: { status: 'REVOKED', revokedAt: now }, + }); + return true; + }); + } + + public async findPrincipalByAccessToken( + accessTokenInput: unknown, + ): Promise { + if (typeof accessTokenInput !== 'string' || accessTokenInput.length < 80) return undefined; + try { + const row = await this.client.accessTokenRecord.findUnique({ + where: { tokenDigest: digestToken(accessTokenInput) }, + }); + if (!row || row.status !== 'ACTIVE' || row.expiresAt.getTime() <= this.clock().getTime()) + return undefined; + return this.findPrincipal(row.sessionId); + } catch { + return undefined; + } + } + + public async findPrincipal( + sessionIdInput: unknown, + ): Promise { + if (typeof sessionIdInput !== 'string') return undefined; + const parsed = parseStableIdentifierV1(sessionIdInput); + if (!parsed.accepted) return undefined; + try { + const sessionRow = await this.client.sessionRecord.findUnique({ + where: { id: parsed.value }, + }); + if (!sessionRow) return undefined; + const session = sessionFromRow(sessionRow); + const now = Date.parse(this.clock().toISOString()); + if ( + session.status !== 'ACTIVE' || + now >= Date.parse(session.inactivityExpiresAt) || + now >= Date.parse(session.absoluteExpiresAt) + ) + return undefined; + const user = await this.client.userIdentity.findUnique({ where: { id: session.userId } }); + if (!user || user.status !== 'ACTIVE' || user.id !== session.userId) return undefined; + if (!Number.isSafeInteger(user.securityEpoch) || user.securityEpoch < 1) return undefined; + const memberships = await this.client.membershipIdentity.findMany({ + where: { principalId: session.userId, status: 'ACTIVE' }, + }); + const membership = memberships.find( + (candidate) => + candidate.principalId === session.userId && + candidate.scopeType === 'WORKSPACE' && + candidate.projectId === null && + parseStableIdentifierV1(candidate.organizationId).accepted && + parseStableIdentifierV1(candidate.workspaceId).accepted, + ); + if (!membership || !membership.workspaceId) return undefined; + const organizationId = parseStableIdentifierV1(membership.organizationId); + const workspaceId = parseStableIdentifierV1(membership.workspaceId); + if (!organizationId.accepted || !workspaceId.accepted) return undefined; + const [organization, workspace, factors] = await Promise.all([ + this.client.organizationIdentity.findUnique({ where: { id: organizationId.value } }), + this.client.workspaceIdentity.findUnique({ where: { id: workspaceId.value } }), + this.client.mfaFactor.findMany({ where: { userId: session.userId, status: 'ACTIVE' } }), + ]); + if ( + !organization || + organization.id !== organizationId.value || + organization.status !== 'ACTIVE' || + !workspace || + workspace.id !== workspaceId.value || + workspace.organizationId !== organizationId.value || + workspace.status !== 'ACTIVE' + ) + return undefined; + return Object.freeze({ + userId: session.userId, + organizationId: organizationId.value, + workspaceId: workspaceId.value, + securityEpoch: user.securityEpoch, + mfaRequired: factors.length > 0, + }); + } catch { + return undefined; + } + } +} + +export const asSessionIssuerPortV1 = ( + adapter: PrismaSessionLifecycleAdapter, +): SessionIssuerPortV1 => adapter; diff --git a/services/api/src/features/iam/api/auth-session.dto.ts b/services/api/src/features/iam/api/auth-session.dto.ts index 0fe5876c..1f181768 100644 --- a/services/api/src/features/iam/api/auth-session.dto.ts +++ b/services/api/src/features/iam/api/auth-session.dto.ts @@ -1,5 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsISO8601, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; +import { + IsBoolean, + IsISO8601, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; /** IAM-005, IAM-006, IAM-012: public response shape for browser and native clients. */ export class AuthSessionDto { @@ -25,11 +33,12 @@ export class AuthSessionDto { @MaxLength(4096) accessToken!: string; - @ApiProperty({ minLength: 1, maxLength: 4096 }) + @ApiProperty({ minLength: 1, maxLength: 4096, required: false }) + @IsOptional() @IsString() @MinLength(1) @MaxLength(4096) - refreshToken!: string; + refreshToken?: string; @ApiProperty({ format: 'date-time' }) @IsISO8601() diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 270b2b19..594f5d44 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -1,4 +1,6 @@ -import { Body, Controller, HttpCode, Inject, Post } from '@nestjs/common'; +import { randomBytes } from 'node:crypto'; + +import { Body, Controller, Get, HttpCode, Inject, Optional, Post, Req, Res } from '@nestjs/common'; import { ApiBody, ApiOkResponse, @@ -13,8 +15,29 @@ import { type AuthenticationUseCaseV1, } from '../application/authentication.port.js'; import { AuthenticationProblemError } from '../application/authentication-problem.error.js'; +import { + SESSION_LIFECYCLE_PORT, + type SessionLifecyclePortV1, +} from '../application/session-lifecycle.port.js'; +import { SessionProblemError } from '../application/session-problem.error.js'; +import { + CSRF_COOKIE_NAME_V1, + REFRESH_COOKIE_NAME_V1, + clearCookieV1, + readCookieValueV1, + serializeCookieV1, +} from './session-cookies.js'; import { AuthSessionDto } from './auth-session.dto.js'; import { SignInDto } from './sign-in.dto.js'; +import { SessionRefreshDto } from './session-refresh.dto.js'; +import { SessionRefreshResponseDto } from './session-refresh-response.dto.js'; +import { SessionSignOutDto } from './session-sign-out.dto.js'; +import { CurrentSessionDto } from './current-session.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; +import type { FastifyReply, FastifyRequest } from 'fastify'; @ApiTags('auth') @Controller('v1/auth') @@ -22,8 +45,30 @@ export class AuthenticationController { constructor( @Inject(AUTHENTICATION_USE_CASE) private readonly authentication: AuthenticationUseCaseV1, + @Optional() + @Inject(SESSION_LIFECYCLE_PORT) + private readonly sessions?: SessionLifecyclePortV1, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext?: RequestTenantContextPortV1, ) {} + @Get('me') + @ApiOperation({ summary: 'Read the redacted authenticated session identity' }) + @ApiOkResponse({ type: CurrentSessionDto }) + async me(@Req() request: FastifyRequest): Promise { + if (this.requestContext === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); + const context = await this.requestContext.resolve(request); + return { + userId: context.actorId, + organizationId: context.tenantScope.organizationId, + ...(context.tenantScope.scopeType === 'organization' + ? {} + : { workspaceId: context.tenantScope.workspaceId }), + authorizationEpoch: context.authorizationEpoch, + mfaRequired: context.mfaRequired ?? false, + }; + } + @Post('sign-in') @HttpCode(200) @ApiOperation({ summary: 'Sign in and issue a short-lived session' }) @@ -31,19 +76,103 @@ export class AuthenticationController { @ApiOkResponse({ type: AuthSessionDto }) @ApiUnauthorizedResponse({ description: 'Credentials were rejected.' }) @ApiServiceUnavailableResponse({ description: 'Authentication provider is unavailable.' }) - async signIn(@Body() input: SignInDto): Promise { + async signIn( + @Body() input: SignInDto, + @Res({ passthrough: true }) reply: FastifyReply, + ): Promise { const result = await this.authentication.signIn(input); if (!result.accepted) throw new AuthenticationProblemError(result.code); + if (input.clientPlatform === 'web') { + const csrfToken = randomBytes(32).toString('base64url'); + reply.header('Set-Cookie', [ + serializeCookieV1(REFRESH_COOKIE_NAME_V1, result.value.session.refreshToken, { + httpOnly: true, + maxAgeSeconds: 2_592_000, + }), + serializeCookieV1(CSRF_COOKIE_NAME_V1, csrfToken, { + httpOnly: false, + maxAgeSeconds: 2_592_000, + }), + ]); + } return { sessionId: result.value.session.sessionId, userId: result.value.principal.userId, organizationId: result.value.principal.organizationId, workspaceId: result.value.principal.workspaceId, accessToken: result.value.session.accessToken, - refreshToken: result.value.session.refreshToken, + ...(input.clientPlatform === 'web' + ? {} + : { refreshToken: result.value.session.refreshToken }), accessExpiresAt: result.value.session.accessExpiresAt, securityEpoch: result.value.principal.securityEpoch, mfaRequired: result.value.principal.mfaRequired, }; } + + @Post('refresh') + @HttpCode(200) + @ApiOperation({ summary: 'Rotate a short-lived session' }) + @ApiBody({ type: SessionRefreshDto }) + @ApiOkResponse({ type: SessionRefreshResponseDto }) + @ApiUnauthorizedResponse({ description: 'The refresh session was rejected.' }) + @ApiServiceUnavailableResponse({ description: 'Session persistence is unavailable.' }) + async refresh( + @Body() input: SessionRefreshDto, + @Req() request: FastifyRequest, + @Res({ passthrough: true }) reply: FastifyReply, + ): Promise { + if (this.sessions === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); + const refreshToken = + input.clientPlatform === 'web' + ? readCookieValueV1(request.headers.cookie, REFRESH_COOKIE_NAME_V1) + : input.refreshToken; + if ( + refreshToken === undefined || + (input.clientPlatform === 'web' && input.refreshToken !== undefined) + ) { + throw new SessionProblemError('SESSION_INVALID'); + } + const result = await this.sessions.refresh(refreshToken, input.clientPlatform); + if (!result.accepted) throw new SessionProblemError('SESSION_INVALID'); + if (input.clientPlatform === 'web') { + const csrfToken = randomBytes(32).toString('base64url'); + reply.header('Set-Cookie', [ + serializeCookieV1(REFRESH_COOKIE_NAME_V1, result.value.refreshToken, { + httpOnly: true, + maxAgeSeconds: 2_592_000, + }), + serializeCookieV1(CSRF_COOKIE_NAME_V1, csrfToken, { + httpOnly: false, + maxAgeSeconds: 2_592_000, + }), + ]); + } + return { + sessionId: result.value.sessionId, + accessToken: result.value.accessToken, + accessExpiresAt: result.value.accessExpiresAt, + ...(input.clientPlatform === 'web' ? {} : { refreshToken: result.value.refreshToken }), + }; + } + + @Post('sign-out') + @HttpCode(204) + @ApiOperation({ summary: 'Revoke a session and clear browser credentials' }) + @ApiBody({ type: SessionSignOutDto }) + @ApiUnauthorizedResponse({ description: 'The session could not be authenticated.' }) + @ApiServiceUnavailableResponse({ description: 'Session persistence is unavailable.' }) + async signOut( + @Body() input: SessionSignOutDto, + @Res({ passthrough: true }) reply: FastifyReply, + ): Promise { + if (this.sessions === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); + await this.sessions.revoke(input.sessionId); + if (input.clientPlatform === 'web') { + reply.header('Set-Cookie', [ + clearCookieV1(REFRESH_COOKIE_NAME_V1, { httpOnly: true }), + clearCookieV1(CSRF_COOKIE_NAME_V1, { httpOnly: false }), + ]); + } + } } diff --git a/services/api/src/features/iam/api/current-session.dto.ts b/services/api/src/features/iam/api/current-session.dto.ts new file mode 100644 index 00000000..bf333dd3 --- /dev/null +++ b/services/api/src/features/iam/api/current-session.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsOptional, IsUUID } from 'class-validator'; + +/** IAM-009: the redacted identity projection used to hydrate an authenticated client. */ +export class CurrentSessionDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + userId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + organizationId!: string; + + @ApiProperty({ format: 'uuid', required: false }) + @IsOptional() + @IsUUID() + workspaceId?: string; + + @ApiProperty({ minimum: 1 }) + authorizationEpoch!: number; + + @ApiProperty() + @IsBoolean() + mfaRequired!: boolean; +} diff --git a/services/api/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts new file mode 100644 index 00000000..df7e017d --- /dev/null +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -0,0 +1,66 @@ +import { Body, Controller, HttpCode, Inject, Optional, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { MFA_SERVICE, MfaService } from '../application/mfa.service.js'; +import { MfaProblemError } from '../application/mfa-problem.error.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; +import { EnrollMfaFactorDto, RedeemMfaRecoveryCodeDto, VerifyMfaFactorDto } from './mfa.dto.js'; + +@ApiTags('auth') +@ApiBearerAuth() +@Controller('v1/auth/mfa') +export class MfaController { + public constructor( + @Optional() @Inject(MFA_SERVICE) private readonly mfa: MfaService | undefined, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) {} + + @Post('factors') + @HttpCode(200) + @ApiOperation({ summary: 'Enroll a pending MFA factor for the authenticated user' }) + @ApiBody({ type: EnrollMfaFactorDto }) + async enroll(@Req() request: unknown, @Body() input: EnrollMfaFactorDto): Promise { + if (this.mfa === undefined) throw new MfaProblemError('MFA_UNAVAILABLE'); + const context = await this.requestContext.resolve(request); + const result = await this.mfa.enroll({ + ...input, + userId: context.actorId, + }); + if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); + return result.value; + } + + @Post('factors/:factorId/verify') + @HttpCode(200) + @ApiOperation({ summary: 'Verify a pending MFA factor' }) + @ApiBody({ type: VerifyMfaFactorDto }) + async verify( + @Req() request: unknown, + @Param('factorId') factorId: string, + @Body() input: VerifyMfaFactorDto, + ): Promise { + if (this.mfa === undefined) throw new MfaProblemError('MFA_UNAVAILABLE'); + const context = await this.requestContext.resolve(request); + const result = await this.mfa.verifyFactor(context.actorId, factorId, input.at); + if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); + return result.value; + } + + @Post('recovery/redeem') + @HttpCode(200) + @ApiOperation({ summary: 'Redeem one hashed MFA recovery code' }) + @ApiBody({ type: RedeemMfaRecoveryCodeDto }) + async redeemRecovery( + @Req() request: unknown, + @Body() input: RedeemMfaRecoveryCodeDto, + ): Promise { + if (this.mfa === undefined) throw new MfaProblemError('MFA_UNAVAILABLE'); + const context = await this.requestContext.resolve(request); + const result = await this.mfa.redeemRecovery(context.actorId, input.presentedDigest, input.at); + if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); + return result.value; + } +} diff --git a/services/api/src/features/iam/api/mfa.dto.ts b/services/api/src/features/iam/api/mfa.dto.ts new file mode 100644 index 00000000..db58a085 --- /dev/null +++ b/services/api/src/features/iam/api/mfa.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsIn, + IsISO8601, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; + +export class EnrollMfaFactorDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + id!: string; + + @ApiProperty({ enum: ['TOTP', 'WEBAUTHN'] }) + @IsIn(['TOTP', 'WEBAUTHN']) + method!: 'TOTP' | 'WEBAUTHN'; + + @ApiProperty({ maxLength: 512, writeOnly: true }) + @IsString() + @MinLength(1) + @MaxLength(512) + secretReference!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + enrolledAt!: string; + + @ApiProperty({ minimum: 1, required: false }) + @IsOptional() + revision?: number; +} + +export class VerifyMfaFactorDto { + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + at!: string; +} + +export class RedeemMfaRecoveryCodeDto { + @ApiProperty({ maxLength: 256, writeOnly: true }) + @IsString() + @MinLength(1) + @MaxLength(256) + presentedDigest!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + at!: string; +} diff --git a/services/api/src/features/iam/api/session-cookies.ts b/services/api/src/features/iam/api/session-cookies.ts new file mode 100644 index 00000000..5feab2a3 --- /dev/null +++ b/services/api/src/features/iam/api/session-cookies.ts @@ -0,0 +1,70 @@ +const COOKIE_NAME_PATTERN_V1 = /^[A-Za-z0-9_]+$/u; +const COOKIE_VALUE_PATTERN_V1 = /^[A-Za-z0-9._~-]+$/u; + +export const REFRESH_COOKIE_NAME_V1 = 'databreeze_refresh'; +export const CSRF_COOKIE_NAME_V1 = 'databreeze_csrf'; + +export interface CookieOptionsV1 { + readonly httpOnly: boolean; + readonly maxAgeSeconds: number; +} + +function validCookieNameV1(name: string): boolean { + return COOKIE_NAME_PATTERN_V1.test(name); +} + +function validCookieValueV1(value: string): boolean { + return COOKIE_VALUE_PATTERN_V1.test(value); +} + +export function serializeCookieV1(name: string, value: string, options: CookieOptionsV1): string { + if (!validCookieNameV1(name) || !validCookieValueV1(value)) { + throw new Error('Cookie name or value is invalid'); + } + if (!Number.isSafeInteger(options.maxAgeSeconds) || options.maxAgeSeconds < 0) { + throw new Error('Cookie max age is invalid'); + } + return [ + `${name}=${value}`, + `Max-Age=${options.maxAgeSeconds}`, + 'Path=/', + options.httpOnly ? 'HttpOnly' : undefined, + 'Secure', + 'SameSite=Lax', + ] + .filter((part): part is string => part !== undefined) + .join('; '); +} + +export function clearCookieV1(name: string, options: Pick): string { + if (!validCookieNameV1(name)) throw new Error('Cookie name is invalid'); + return [ + `${name}=`, + 'Max-Age=0', + 'Path=/', + options.httpOnly ? 'HttpOnly' : undefined, + 'Secure', + 'SameSite=Lax', + ] + .filter((part): part is string => part !== undefined) + .join('; '); +} + +/** Read one unencoded, token-shaped cookie without accepting duplicate names. */ +export function readCookieValueV1(rawCookie: unknown, name: string): string | undefined { + if (typeof rawCookie !== 'string' || !validCookieNameV1(name)) return undefined; + let found: string | undefined; + for (const segment of rawCookie.split(';')) { + const trimmed = segment.trim(); + if (trimmed.length === 0) continue; + const equals = trimmed.indexOf('='); + if (equals <= 0) return undefined; + const segmentName = trimmed.slice(0, equals).trim(); + const segmentValue = trimmed.slice(equals + 1).trim(); + if (!validCookieNameV1(segmentName) || !validCookieValueV1(segmentValue)) return undefined; + if (segmentName !== name) continue; + if (found !== undefined) return undefined; + found = segmentValue; + } + return found; +} diff --git a/services/api/src/features/iam/api/session-refresh-response.dto.ts b/services/api/src/features/iam/api/session-refresh-response.dto.ts new file mode 100644 index 00000000..a440e029 --- /dev/null +++ b/services/api/src/features/iam/api/session-refresh-response.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsISO8601, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; + +export class SessionRefreshResponseDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + sessionId!: string; + + @ApiProperty({ minLength: 1, maxLength: 4096 }) + @IsString() + @MinLength(1) + @MaxLength(4096) + accessToken!: string; + + @ApiProperty({ minLength: 1, maxLength: 4096, required: false, writeOnly: true }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(4096) + refreshToken?: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + accessExpiresAt!: string; +} diff --git a/services/api/src/features/iam/api/session-refresh.dto.ts b/services/api/src/features/iam/api/session-refresh.dto.ts new file mode 100644 index 00000000..dab25789 --- /dev/null +++ b/services/api/src/features/iam/api/session-refresh.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class SessionRefreshDto { + @ApiProperty({ enum: ['android', 'desktop', 'web'] }) + @IsIn(['android', 'desktop', 'web']) + clientPlatform!: 'android' | 'desktop' | 'web'; + + @ApiProperty({ minLength: 1, maxLength: 4096, required: false, writeOnly: true }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(4096) + refreshToken?: string; +} diff --git a/services/api/src/features/iam/api/session-sign-out.dto.ts b/services/api/src/features/iam/api/session-sign-out.dto.ts new file mode 100644 index 00000000..47de34ed --- /dev/null +++ b/services/api/src/features/iam/api/session-sign-out.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsUUID } from 'class-validator'; + +export class SessionSignOutDto { + @ApiProperty({ enum: ['android', 'desktop', 'web'] }) + @IsIn(['android', 'desktop', 'web']) + clientPlatform!: 'android' | 'desktop' | 'web'; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + sessionId!: string; +} diff --git a/services/api/src/features/iam/application/authentication.port.ts b/services/api/src/features/iam/application/authentication.port.ts index 1d007b4d..766dff77 100644 --- a/services/api/src/features/iam/application/authentication.port.ts +++ b/services/api/src/features/iam/application/authentication.port.ts @@ -2,6 +2,7 @@ import type { PasswordCredentialService } from './password-credential.service.js export const AUTHENTICATION_PORT = Symbol('AUTHENTICATION_PORT'); export const AUTHENTICATION_USE_CASE = Symbol('AUTHENTICATION_USE_CASE'); +export const CREDENTIAL_LOOKUP_PORT = Symbol('CREDENTIAL_LOOKUP_PORT'); export interface AuthenticatedPrincipalV1 { readonly userId: string; diff --git a/services/api/src/features/iam/application/iam-repository.port.ts b/services/api/src/features/iam/application/iam-repository.port.ts index ae8cd688..f52be91b 100644 --- a/services/api/src/features/iam/application/iam-repository.port.ts +++ b/services/api/src/features/iam/application/iam-repository.port.ts @@ -2,6 +2,8 @@ import type { StableIdentifierV1, TenantScopeV1 } from '@databreeze/domain/tenan import type { IamTenantContextV1 } from './tenant-context.js'; +export const IAM_REPOSITORY_PORT = Symbol('IAM_REPOSITORY_PORT'); + export interface IamMembershipRecordV1 { readonly id: StableIdentifierV1; readonly principalId: StableIdentifierV1; diff --git a/services/api/src/features/iam/application/mfa-problem.error.ts b/services/api/src/features/iam/application/mfa-problem.error.ts new file mode 100644 index 00000000..723029a5 --- /dev/null +++ b/services/api/src/features/iam/application/mfa-problem.error.ts @@ -0,0 +1,8 @@ +export type MfaProblemCodeV1 = 'MFA_REQUEST_REJECTED' | 'MFA_UNAVAILABLE'; + +export class MfaProblemError extends Error { + constructor(readonly code: MfaProblemCodeV1) { + super(code); + this.name = 'MfaProblemError'; + } +} diff --git a/services/api/src/features/iam/application/mfa.service.ts b/services/api/src/features/iam/application/mfa.service.ts index df43a426..b7916b47 100644 --- a/services/api/src/features/iam/application/mfa.service.ts +++ b/services/api/src/features/iam/application/mfa.service.ts @@ -13,6 +13,8 @@ import { import type { MfaRepositoryPortV1 } from './mfa-repository.port.js'; +export const MFA_SERVICE = Symbol('MFA_SERVICE'); + function invalidState(): MfaResultV1 { return Object.freeze({ accepted: false, code: 'INVALID_STATE' }); } diff --git a/services/api/src/features/iam/application/session-lifecycle.port.ts b/services/api/src/features/iam/application/session-lifecycle.port.ts index 99060783..a1105a21 100644 --- a/services/api/src/features/iam/application/session-lifecycle.port.ts +++ b/services/api/src/features/iam/application/session-lifecycle.port.ts @@ -5,6 +5,7 @@ import type { } from './authentication.port.js'; export const SESSION_LIFECYCLE_PORT = Symbol('SESSION_LIFECYCLE_PORT'); +export const SESSION_ACCESS_TOKEN_LOOKUP_PORT = Symbol('SESSION_ACCESS_TOKEN_LOOKUP_PORT'); export type SessionRefreshFailureCodeV1 = | 'INVALID_REFRESH_TOKEN' @@ -28,5 +29,11 @@ export interface SessionLifecyclePortV1 extends SessionIssuerPortV1 { clientPlatform: 'android' | 'desktop' | 'web', ): Promise; revoke(sessionId: unknown): Promise; - findPrincipal(sessionId: unknown): AuthenticatedPrincipalV1 | undefined; + findPrincipal(sessionId: unknown): Promise; + /** Optional until a host enables authenticated request-context resolution. */ + findPrincipalByAccessToken?(accessToken: unknown): Promise; +} + +export interface SessionAccessTokenLookupPortV1 { + findPrincipalByAccessToken(accessToken: unknown): Promise; } diff --git a/services/api/src/features/iam/application/session-problem.error.ts b/services/api/src/features/iam/application/session-problem.error.ts new file mode 100644 index 00000000..99b6cac0 --- /dev/null +++ b/services/api/src/features/iam/application/session-problem.error.ts @@ -0,0 +1,8 @@ +export type SessionProblemCodeV1 = 'SESSION_INVALID' | 'SESSION_UNAVAILABLE'; + +export class SessionProblemError extends Error { + constructor(readonly code: SessionProblemCodeV1) { + super(code); + this.name = 'SessionProblemError'; + } +} diff --git a/services/api/src/features/iam/application/tenant-context.ts b/services/api/src/features/iam/application/tenant-context.ts index 1fde89a7..fac5078d 100644 --- a/services/api/src/features/iam/application/tenant-context.ts +++ b/services/api/src/features/iam/application/tenant-context.ts @@ -11,6 +11,7 @@ export interface IamTenantContextV1 { readonly correlationId: StableIdentifierV1; readonly idempotencyKey: string; readonly authorizationEpoch: number; + readonly mfaRequired?: boolean; readonly expectedRevision?: number; } @@ -35,6 +36,7 @@ export function createIamTenantContextV1(input: { readonly correlationId: unknown; readonly idempotencyKey: unknown; readonly authorizationEpoch: unknown; + readonly mfaRequired?: unknown; readonly expectedRevision?: unknown; }): IamContextResultV1 { const tenantScope = parseTenantScopeV1(input.tenantScope); @@ -55,6 +57,8 @@ export function createIamTenantContextV1(input: { input.authorizationEpoch < 1 ) return rejected('INVALID_EPOCH'); + if (input.mfaRequired !== undefined && typeof input.mfaRequired !== 'boolean') + return rejected('INVALID_TEXT'); if ( input.expectedRevision !== undefined && (typeof input.expectedRevision !== 'number' || @@ -70,6 +74,7 @@ export function createIamTenantContextV1(input: { correlationId: correlationId.value, idempotencyKey: input.idempotencyKey, authorizationEpoch: input.authorizationEpoch, + ...(input.mfaRequired === undefined ? {} : { mfaRequired: input.mfaRequired }), ...(input.expectedRevision === undefined ? {} : { expectedRevision: input.expectedRevision }), }), }); diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 3d389873..6bf6257e 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -1,15 +1,53 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { AuthenticationController } from './api/authentication.controller.js'; +import { MfaController } from './api/mfa.controller.js'; import { AuthenticationService } from './application/authentication.service.js'; import { AUTHENTICATION_USE_CASE, + CREDENTIAL_LOOKUP_PORT, type CredentialLookupPortV1, type AuthenticationUseCaseV1, - type SessionIssuerPortV1, } from './application/authentication.port.js'; +import { + SESSION_LIFECYCLE_PORT, + type SessionLifecyclePortV1, +} from './application/session-lifecycle.port.js'; +import { + IDENTITY_BOOTSTRAP_REPOSITORY_PORT, + type IdentityBootstrapRepositoryPortV1, +} from './application/identity-bootstrap-repository.port.js'; +import { + MFA_REPOSITORY_PORT, + type MfaRepositoryPortV1, +} from './application/mfa-repository.port.js'; +import { MFA_SERVICE, MfaService } from './application/mfa.service.js'; +import { + IAM_REPOSITORY_PORT, + type IamRepositoryPortV1, +} from './application/iam-repository.port.js'; import type { PasswordCredentialService } from './application/password-credential.service.js'; import { UnavailableAuthenticationAdapter } from './adapter/unavailable-authentication.adapter.js'; +import { + PrismaCredentialLookupAdapter, + type CredentialLookupDatabaseClientV1, +} from './adapter/prisma-credential-lookup.adapter.js'; +import { + PrismaSessionLifecycleAdapter, + type SessionLifecycleDatabaseClientV1, +} from './adapter/prisma-session-lifecycle.adapter.js'; +import { + PrismaIdentityBootstrapRepositoryAdapter, + type IdentityBootstrapDatabaseClientV1, +} from './adapter/prisma-identity-bootstrap-repository.adapter.js'; +import { + PrismaMfaRepositoryAdapter, + type MfaDatabaseClientV1, +} from './adapter/prisma-mfa-repository.adapter.js'; +import { + PrismaIamRepositoryAdapter, + type IamDatabaseClientV1, +} from './adapter/prisma-iam-repository.adapter.js'; import { DeviceIdentityController } from './api/device-identity.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { @@ -35,8 +73,20 @@ import { export interface IamModuleOptions { readonly authentication?: AuthenticationUseCaseV1; readonly credentials?: CredentialLookupPortV1; + readonly credentialDatabase?: CredentialLookupDatabaseClientV1; readonly passwordCredentials?: PasswordCredentialService; - readonly sessions?: SessionIssuerPortV1; + readonly sessions?: SessionLifecyclePortV1; + readonly sessionDatabase?: SessionLifecycleDatabaseClientV1; + readonly identityBootstrapRepository?: IdentityBootstrapRepositoryPortV1; + readonly identityBootstrapDatabase?: IdentityBootstrapDatabaseClientV1; + readonly mfaRepository?: MfaRepositoryPortV1; + readonly mfaDatabase?: MfaDatabaseClientV1; + readonly mfaService?: MfaService; + readonly recoveryCodeMatcher?: { + matches(presentedDigest: string, storedDigest: string): boolean; + }; + readonly iamRepository?: IamRepositoryPortV1; + readonly iamDatabase?: IamDatabaseClientV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -59,6 +109,53 @@ export function composeAuthenticationUseCase(options: IamModuleOptions): Authent @Module({}) export class IamModule { static register(options: IamModuleOptions = {}): DynamicModule { + const credentials = + options.credentials ?? + (options.credentialDatabase === undefined + ? undefined + : new PrismaCredentialLookupAdapter(options.credentialDatabase)); + const sessions = + options.sessions ?? + (options.sessionDatabase === undefined + ? undefined + : new PrismaSessionLifecycleAdapter(options.sessionDatabase)); + const identityBootstrapRepository = + options.identityBootstrapRepository ?? + (options.identityBootstrapDatabase === undefined + ? undefined + : new PrismaIdentityBootstrapRepositoryAdapter(options.identityBootstrapDatabase)); + const mfaRepository = + options.mfaRepository ?? + (options.mfaDatabase === undefined + ? undefined + : new PrismaMfaRepositoryAdapter(options.mfaDatabase)); + const mfaService = + options.mfaService ?? + (mfaRepository === undefined + ? undefined + : new MfaService( + mfaRepository, + options.recoveryCodeMatcher ?? { + matches: (presentedDigest, storedDigest) => { + if (presentedDigest.length !== storedDigest.length) return false; + let difference = 0; + for (let index = 0; index < presentedDigest.length; index += 1) { + difference |= presentedDigest.charCodeAt(index) ^ storedDigest.charCodeAt(index); + } + return difference === 0; + }, + }, + )); + const iamRepository = + options.iamRepository ?? + (options.iamDatabase === undefined + ? undefined + : new PrismaIamRepositoryAdapter(options.iamDatabase)); + const authentication = + options.authentication ?? + (credentials && sessions + ? composeAuthenticationUseCase({ ...options, credentials, sessions }) + : composeAuthenticationUseCase(options)); const deviceIdentityRepository = options.deviceIdentityRepository ?? (options.deviceIdentityDatabase === undefined @@ -70,14 +167,69 @@ export class IamModule { deviceIdentityRepository, options.deviceEnrollmentProofVerifier ?? new UnavailableDeviceEnrollmentProofVerifier(), ); + const exports = [DEVICE_IDENTITY_REPOSITORY_PORT, DEVICE_IDENTITY_SERVICE]; + if (credentials) exports.unshift(CREDENTIAL_LOOKUP_PORT); + if (sessions) exports.unshift(SESSION_LIFECYCLE_PORT); + if (identityBootstrapRepository) exports.unshift(IDENTITY_BOOTSTRAP_REPOSITORY_PORT); + if (mfaRepository) exports.unshift(MFA_REPOSITORY_PORT); + if (mfaService) exports.unshift(MFA_SERVICE); + if (iamRepository) exports.unshift(IAM_REPOSITORY_PORT); return { module: IamModule, - controllers: [AuthenticationController, DeviceIdentityController], + controllers: [AuthenticationController, DeviceIdentityController, MfaController], providers: [ { provide: AUTHENTICATION_USE_CASE, - useValue: composeAuthenticationUseCase(options), + useValue: authentication, }, + ...(credentials + ? [ + { + provide: CREDENTIAL_LOOKUP_PORT, + useValue: credentials, + }, + ] + : []), + ...(sessions + ? [ + { + provide: SESSION_LIFECYCLE_PORT, + useValue: sessions, + }, + ] + : []), + ...(identityBootstrapRepository + ? [ + { + provide: IDENTITY_BOOTSTRAP_REPOSITORY_PORT, + useValue: identityBootstrapRepository, + }, + ] + : []), + ...(mfaRepository + ? [ + { + provide: MFA_REPOSITORY_PORT, + useValue: mfaRepository, + }, + ] + : []), + ...(mfaService + ? [ + { + provide: MFA_SERVICE, + useValue: mfaService, + }, + ] + : []), + ...(iamRepository + ? [ + { + provide: IAM_REPOSITORY_PORT, + useValue: iamRepository, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, @@ -91,7 +243,7 @@ export class IamModule { useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), }, ], - exports: [DEVICE_IDENTITY_REPOSITORY_PORT, DEVICE_IDENTITY_SERVICE], + exports, }; } } diff --git a/services/api/src/platform/http/csrf-protection.ts b/services/api/src/platform/http/csrf-protection.ts new file mode 100644 index 00000000..af209493 --- /dev/null +++ b/services/api/src/platform/http/csrf-protection.ts @@ -0,0 +1,133 @@ +import { compareCsrfTokensV1 } from '@databreeze/domain/v1'; + +export const DEFAULT_CSRF_ALLOWED_ORIGINS_V1 = Object.freeze([ + 'http://localhost:3000', + 'http://127.0.0.1:3000', +]); + +export interface CsrfRequestV1 { + readonly method: string; + readonly headers: Readonly>; +} + +export interface CsrfProtectionOptionsV1 { + readonly allowedOrigins: readonly string[]; +} + +export type CsrfRequestResultV1 = + | { readonly accepted: true } + | { + readonly accepted: false; + readonly code: 'CSRF_REQUIRED' | 'CSRF_INVALID' | 'ORIGIN_INVALID'; + }; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); +const COOKIE_AUTH_NAMES = new Set([ + 'databreeze_access', + 'databreeze_refresh', + 'databreeze_session', +]); +const CSRF_COOKIE_NAME = 'databreeze_csrf'; + +function oneHeader( + headers: CsrfRequestV1['headers'], + name: string, +): + | { readonly present: false } + | { readonly present: true; readonly value: string } + | { readonly present: true; readonly ambiguous: true } { + const matching = Object.entries(headers) + .filter(([key]) => key.toLowerCase() === name) + .map(([, value]) => value) + .filter((value): value is string | readonly string[] => value !== undefined); + if (matching.length !== 1) + return matching.length === 0 ? { present: false } : { present: true, ambiguous: true }; + const value = matching[0]; + if (typeof value !== 'string') return { present: true, ambiguous: true }; + return { present: true, value }; +} + +function parseCookies(raw: string): { + readonly values: ReadonlyMap; + readonly duplicateNames: ReadonlySet; + readonly malformed: boolean; +} { + const values = new Map(); + const duplicateNames = new Set(); + let malformed = false; + for (const segment of raw.split(';')) { + const trimmed = segment.trim(); + if (trimmed.length === 0) continue; + const equals = trimmed.indexOf('='); + if (equals <= 0) { + malformed = true; + continue; + } + const name = trimmed.slice(0, equals).trim(); + const value = trimmed.slice(equals + 1).trim(); + if (!/^[A-Za-z0-9_]+$/u.test(name) || value.includes('\r') || value.includes('\n')) { + malformed = true; + continue; + } + if (values.has(name)) duplicateNames.add(name); + values.set(name, value); + } + return { values, duplicateNames, malformed }; +} + +function hasCookieAuth(cookies: ReturnType): boolean { + for (const name of COOKIE_AUTH_NAMES) { + if (cookies.values.has(name)) return true; + } + return false; +} + +function originAccepted( + headers: CsrfRequestV1['headers'], + options: CsrfProtectionOptionsV1, +): boolean { + const origin = oneHeader(headers, 'origin'); + if (origin.present && 'ambiguous' in origin) return false; + if (origin.present) return options.allowedOrigins.includes(origin.value); + + const fetchSite = oneHeader(headers, 'sec-fetch-site'); + if (!fetchSite.present || 'ambiguous' in fetchSite) return false; + return fetchSite.value === 'same-origin' || fetchSite.value === 'same-site'; +} + +/** + * Enforce CSRF only at the browser-cookie boundary. Native clients use bearer + * or device proof-of-possession credentials and must not be forced to invent a + * browser token. Every ambiguous header/cookie state fails closed. + */ +export function evaluateCsrfRequestV1( + request: CsrfRequestV1, + options: CsrfProtectionOptionsV1, +): CsrfRequestResultV1 { + const method = request.method.toUpperCase(); + if (SAFE_METHODS.has(method)) return Object.freeze({ accepted: true as const }); + + const cookie = oneHeader(request.headers, 'cookie'); + if (!cookie.present) return Object.freeze({ accepted: true as const }); + if ('ambiguous' in cookie) + return Object.freeze({ accepted: false as const, code: 'CSRF_INVALID' as const }); + + const cookies = parseCookies(cookie.value); + if (!hasCookieAuth(cookies)) return Object.freeze({ accepted: true as const }); + if (!originAccepted(request.headers, options)) { + return Object.freeze({ accepted: false as const, code: 'ORIGIN_INVALID' as const }); + } + if (cookies.malformed || cookies.duplicateNames.has(CSRF_COOKIE_NAME)) { + return Object.freeze({ accepted: false as const, code: 'CSRF_INVALID' as const }); + } + + const csrfCookie = cookies.values.get(CSRF_COOKIE_NAME); + const csrfHeader = oneHeader(request.headers, 'x-csrf-token'); + if (csrfCookie === undefined || !csrfHeader.present || 'ambiguous' in csrfHeader) { + return Object.freeze({ accepted: false as const, code: 'CSRF_REQUIRED' as const }); + } + if (!compareCsrfTokensV1(csrfCookie, csrfHeader.value)) { + return Object.freeze({ accepted: false as const, code: 'CSRF_INVALID' as const }); + } + return Object.freeze({ accepted: true as const }); +} diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 284cbb53..75a9262b 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -8,6 +8,9 @@ import { import type { FastifyReply, FastifyRequest } from 'fastify'; import { AuthenticationProblemError } from '../../features/iam/application/authentication-problem.error.js'; +import { SessionProblemError } from '../../features/iam/application/session-problem.error.js'; +import { MfaProblemError } from '../../features/iam/application/mfa-problem.error.js'; +import { RequestTenantContextProblemError } from './session-tenant-context.adapter.js'; import { NotReadyError } from '../../features/system/application/not-ready.error.js'; import { InputValidationException } from './input-validation.exception.js'; import { createProblem, type ProblemInput } from './problem-details.js'; @@ -32,6 +35,36 @@ function describe(error: unknown, correlationId: string): ProblemInput { status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.UNAUTHORIZED, }; } + if (error instanceof SessionProblemError) { + const unavailable = error.code === 'SESSION_UNAVAILABLE'; + return { + code: error.code, + correlationId, + messageKey: unavailable ? 'api.error.session_unavailable' : 'api.error.session_invalid', + retryable: unavailable, + status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.UNAUTHORIZED, + }; + } + if (error instanceof MfaProblemError) { + const unavailable = error.code === 'MFA_UNAVAILABLE'; + return { + code: error.code, + correlationId, + messageKey: unavailable ? 'api.error.mfa_unavailable' : 'api.error.mfa_request_rejected', + retryable: unavailable, + status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST, + }; + } + if (error instanceof RequestTenantContextProblemError) { + const invalidContext = error.code === 'CONTEXT_INVALID'; + return { + code: invalidContext ? 'CONTEXT_INVALID' : 'AUTHENTICATION_FAILED', + correlationId, + messageKey: invalidContext ? 'api.error.context_invalid' : 'api.error.authentication_failed', + retryable: false, + status: invalidContext ? HttpStatus.BAD_REQUEST : HttpStatus.UNAUTHORIZED, + }; + } if (error instanceof InputValidationException) { return { code: 'VALIDATION_FAILED', diff --git a/services/api/src/platform/http/request-context.ts b/services/api/src/platform/http/request-context.ts index 51343853..3b9068d2 100644 --- a/services/api/src/platform/http/request-context.ts +++ b/services/api/src/platform/http/request-context.ts @@ -2,6 +2,11 @@ import { randomUUID } from 'node:crypto'; import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { + DEFAULT_CSRF_ALLOWED_ORIGINS_V1, + evaluateCsrfRequestV1, + type CsrfProtectionOptionsV1, +} from './csrf-protection.js'; import { createProblem } from './problem-details.js'; export interface RequestContext { @@ -16,6 +21,10 @@ export type CorrelationHeaderResult = | { readonly accepted: true; readonly correlationId: string } | { readonly accepted: false }; +export interface RequestContextOptions { + readonly csrf?: Partial; +} + export function parseCorrelationHeader( values: readonly string[], requestId: string, @@ -35,7 +44,10 @@ export function getRequestContext(request: FastifyRequest): RequestContext { return context; } -export function installRequestContext(fastify: FastifyInstance): void { +export function installRequestContext( + fastify: FastifyInstance, + options: RequestContextOptions = {}, +): void { fastify.addHook('onRequest', (request, reply, done) => { const requestId = randomUUID(); const context: RequestContext = { correlationId: requestId, requestId }; @@ -68,6 +80,30 @@ export function installRequestContext(fastify: FastifyInstance): void { const acceptedContext = { correlationId: parsed.correlationId, requestId }; requestContexts.set(request, acceptedContext); reply.header('X-Correlation-Id', acceptedContext.correlationId); + const csrf = evaluateCsrfRequestV1( + { method: request.method, headers: request.headers }, + { allowedOrigins: options.csrf?.allowedOrigins ?? DEFAULT_CSRF_ALLOWED_ORIGINS_V1 }, + ); + if (!csrf.accepted) { + reply + .code(403) + .type('application/problem+json') + .send( + createProblem({ + code: csrf.code, + correlationId: acceptedContext.correlationId, + messageKey: + csrf.code === 'ORIGIN_INVALID' + ? 'api.error.origin_invalid' + : csrf.code === 'CSRF_REQUIRED' + ? 'api.error.csrf_required' + : 'api.error.csrf_invalid', + retryable: false, + status: 403, + }), + ); + return; + } done(); }); } diff --git a/services/api/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts new file mode 100644 index 00000000..c7d2f4ef --- /dev/null +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto'; + +import { type AuthenticatedPrincipalV1 } from '../../features/iam/application/authentication.port.js'; +import { createIamTenantContextV1 } from '../../features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from './request-tenant-context.port.js'; +import { getRequestContext } from './request-context.js'; + +export type RequestTenantContextProblemCodeV1 = 'AUTHENTICATION_FAILED' | 'CONTEXT_INVALID'; + +export class RequestTenantContextProblemError extends Error { + constructor(readonly code: RequestTenantContextProblemCodeV1) { + super(code); + this.name = 'RequestTenantContextProblemError'; + } +} + +type HeaderValueV1 = string | readonly string[] | undefined; + +interface RequestLikeV1 { + readonly id?: unknown; + readonly method?: unknown; + readonly headers?: Readonly>; +} + +export interface SessionPrincipalLookupV1 { + findPrincipalByAccessToken(accessToken: unknown): Promise; +} + +function requestLike(input: unknown): RequestLikeV1 | undefined { + if (typeof input !== 'object' || input === null) return undefined; + const candidate = input as RequestLikeV1; + return candidate.headers !== undefined && typeof candidate.headers === 'object' + ? candidate + : undefined; +} + +function oneHeader(request: RequestLikeV1, name: string): string | undefined { + const headers = request.headers ?? {}; + const values = Object.entries(headers) + .filter(([key]) => key.toLowerCase() === name) + .map(([, value]) => value) + .filter((value): value is string | readonly string[] => value !== undefined); + if (values.length !== 1 || typeof values[0] !== 'string') return undefined; + return values[0]; +} + +function correlationId(request: RequestLikeV1): string { + try { + return getRequestContext(request as never).correlationId; + } catch { + return oneHeader(request, 'x-correlation-id') ?? randomUUID(); + } +} + +function idempotencyKey(request: RequestLikeV1): string { + const header = oneHeader(request, 'idempotency-key'); + if (header !== undefined) return header; + if (typeof request.id === 'string' && request.id.length > 0) return request.id; + return randomUUID(); +} + +function bearerToken(request: RequestLikeV1): string | undefined { + const value = oneHeader(request, 'authorization'); + if (value === undefined) return undefined; + const match = /^Bearer ([A-Za-z0-9._~-]{20,4096})$/u.exec(value); + return match?.[1]; +} + +/** Resolve every protected request from the live IAM session, never from body scope hints. */ +export class SessionRequestTenantContextAdapter implements RequestTenantContextPortV1 { + public constructor(private readonly sessions: SessionPrincipalLookupV1) {} + + public async resolve(request: unknown) { + const input = requestLike(request); + const token = input === undefined ? undefined : bearerToken(input); + if (input === undefined || token === undefined) { + throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + } + let principal: AuthenticatedPrincipalV1 | undefined; + try { + principal = await this.sessions.findPrincipalByAccessToken(token); + } catch { + throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + } + if (principal === undefined) + throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + const context = createIamTenantContextV1({ + tenantScope: { + scopeType: 'workspace', + organizationId: principal.organizationId, + workspaceId: principal.workspaceId, + }, + actorId: principal.userId, + correlationId: correlationId(input), + idempotencyKey: idempotencyKey(input), + authorizationEpoch: principal.securityEpoch, + mfaRequired: principal.mfaRequired, + }); + if (!context.accepted) throw new RequestTenantContextProblemError('CONTEXT_INVALID'); + return context.value; + } +} diff --git a/services/api/test/features/aud/prisma-audit-repository.test.ts b/services/api/test/features/aud/prisma-audit-repository.test.ts new file mode 100644 index 00000000..dd5e3d83 --- /dev/null +++ b/services/api/test/features/aud/prisma-audit-repository.test.ts @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { AuditLedgerService } from '../../../src/features/aud/application/audit-ledger.service.js'; +import { appendAuditEventV1 } from '@databreeze/domain/audit/v1'; +import { + PrismaAuditRepositoryAdapter, + type AuditDatabaseClientV1, +} from '../../../src/features/aud/adapter/prisma-audit-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000101'; +const workspaceId = '00000000-0000-4000-8000-000000000102'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000103'; +const actorId = '00000000-0000-4000-8000-000000000110'; +const correlationId = '00000000-0000-4000-8000-000000000111'; + +function context(workspace = workspaceId, idempotencyKey = 'audit') { + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspace }, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid audit context'); + return result.value; +} + +function delegate>(rows: TRow[]) { + return { + create({ data }: { readonly data: TRow }) { + const persisted = { ...data }; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique({ where }: { readonly where: { readonly id: string } }) { + return Promise.resolve(rows.find((row) => row['id'] === where.id) ?? null); + }, + findFirst({ where }: { readonly where: Readonly> }) { + return Promise.resolve( + rows.find((row) => Object.entries(where).every(([key, value]) => row[key] === value)) ?? + null, + ); + }, + findMany({ + where, + orderBy, + }: { + readonly where: Readonly>; + readonly orderBy: Readonly>; + }) { + const filtered = rows.filter((row) => + Object.entries(where).every(([key, value]) => row[key] === value), + ); + const [field, direction] = Object.entries(orderBy)[0] ?? []; + return Promise.resolve( + [...filtered].sort((left, right) => { + if (!field) return 0; + const leftValue = left[field]; + const rightValue = right[field]; + if (leftValue === rightValue) return 0; + const comparison = leftValue! < rightValue! ? -1 : 1; + return direction === 'desc' ? -comparison : comparison; + }), + ); + }, + }; +} + +function client(): AuditDatabaseClientV1 { + const eventRows: Record[] = []; + const sealRows: Record[] = []; + const database = { + auditEventRecord: delegate(eventRows), + auditSealRecord: delegate(sealRows), + async $transaction( + work: (transaction: AuditDatabaseClientV1) => Promise, + ): Promise { + const eventCount = eventRows.length; + const sealCount = sealRows.length; + try { + return await work(database as unknown as AuditDatabaseClientV1); + } catch (error) { + eventRows.splice(eventCount); + sealRows.splice(sealCount); + throw error; + } + }, + }; + return database as unknown as AuditDatabaseClientV1; +} + +const digest = { + digest: (value: string) => createHash('sha256').update(value, 'utf8').digest('base64url'), +}; + +function input(eventId: string, action: 'job.started' | 'job.completed') { + return { + eventId, + actorType: 'USER' as const, + action, + entityType: 'job', + entityId: '00000000-0000-4000-8000-000000000120', + entityRevision: action === 'job.started' ? 1 : 2, + occurredAt: action === 'job.started' ? '2026-01-01T00:00:00.000Z' : '2026-01-01T00:00:01.000Z', + summary: { outcome: 'accepted' }, + }; +} + +void test('[AUD-001, AUD-003, AUD-008, IAM-009] Prisma audit adapter persists an immutable scoped chain', async () => { + const repository = new PrismaAuditRepositoryAdapter(client(), digest); + const service = new AuditLedgerService(repository, digest); + const first = await service.append( + context(workspaceId, 'event-1'), + input('00000000-0000-4000-8000-000000000121', 'job.started'), + ); + assert.equal(first.accepted, true); + const second = await service.append( + context(workspaceId, 'event-2'), + input('00000000-0000-4000-8000-000000000122', 'job.completed'), + ); + assert.equal(second.accepted, true); + assert.equal((await repository.listEvents(context(workspaceId, 'read'))).length, 2); + assert.equal((await repository.listEvents(context(siblingWorkspaceId, 'sibling'))).length, 0); + assert.equal((await repository.listEvents(context(organizationId, 'organization'))).length, 0); +}); + +void test('[AUD-015, AUD-018] Prisma audit adapter persists and reads immutable seals', async () => { + const repository = new PrismaAuditRepositoryAdapter(client(), digest); + const service = new AuditLedgerService(repository, digest); + await service.append( + context(workspaceId, 'event-3'), + input('00000000-0000-4000-8000-000000000123', 'job.started'), + ); + const sealed = await service.seal(context(workspaceId, 'seal-1'), '2026-01-01T00:01:00.000Z'); + assert.equal(sealed.accepted, true); + assert.equal((await repository.listSeals(context(workspaceId, 'read'))).length, 1); + assert.equal((await repository.listSeals(context(siblingWorkspaceId, 'sibling'))).length, 0); +}); + +void test('[AUD-002] Prisma audit transactions do not retain an event when the unit of work fails', async () => { + const repository = new PrismaAuditRepositoryAdapter(client(), digest); + await assert.rejects( + repository.withTransaction(context(workspaceId, 'rollback'), async (transaction) => { + const created = appendAuditEventV1( + { events: [] }, + { + ...input('00000000-0000-4000-8000-000000000124', 'job.started'), + tenantScope: context(workspaceId, 'nested').tenantScope, + actor: { actorType: 'USER', actorId }, + correlationId, + idempotencyKey: 'nested', + }, + digest, + ); + assert.equal(created.accepted, true); + if (!created.accepted) return; + await transaction.appendEvent(context(workspaceId, 'nested'), created.value.event); + await transaction.listEvents(context(workspaceId, 'inspect')); + throw new Error('rollback-me'); + }), + /rollback-me/u, + ); + assert.equal((await repository.listEvents(context(workspaceId, 'after'))).length, 0); +}); diff --git a/services/api/test/features/bua/prisma-entitlement-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-repository.test.ts new file mode 100644 index 00000000..de4527ec --- /dev/null +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createPlanV1, + type EntitlementPlanV1, + type EntitlementSnapshotV1, +} from '@databreeze/domain/entitlements/v1'; +import { + parseStableIdentifierV1, + type StrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { + PrismaEntitlementRepositoryAdapter, + type EntitlementDatabaseClientV1, +} from '../../../src/features/bua/adapter/prisma-entitlement-repository.adapter.js'; +import { EntitlementAdmissionService } from '../../../src/features/bua/application/entitlement-admission.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000201'; +const workspaceId = '00000000-0000-4000-8000-000000000202'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000203'; +const actorId = '00000000-0000-4000-8000-000000000210'; +const correlationId = '00000000-0000-4000-8000-000000000211'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function context(workspace = workspaceId, idempotencyKey = 'bua') { + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspace }, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid entitlement context'); + return result.value; +} + +function plan(): EntitlementPlanV1 { + const result = createPlanV1({ + planCode: 'development', + displayNameKey: 'plan.development', + features: ['job.execute'], + quotas: [{ metric: 'job_count', limit: 3 }], + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid plan'); + return result.value; +} + +function snapshot(): EntitlementSnapshotV1 { + return { + schemaVersion: 1, + snapshotId: stable('00000000-0000-4000-8000-000000000220'), + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + planCode: 'development', + status: 'ACTIVE', + revision: 1, + securityEpoch: 1, + effectiveAt: '2026-01-01T00:00:00.000Z' as StrictUtcTimestampV1, + features: ['job.execute'], + quotas: [{ metric: 'job_count', limit: 3 }], + }; +} + +function delegate>(rows: TRow[]) { + return { + create({ data }: { readonly data: TRow }) { + const persisted = { ...data }; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique({ + where, + }: { + readonly where: { readonly id?: string; readonly planCode?: string }; + }) { + const key = where.id ?? where.planCode; + return Promise.resolve( + rows.find((row) => row['id'] === key || row['planCode'] === key) ?? null, + ); + }, + findMany({ + where, + orderBy, + }: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }) { + const filtered = rows.filter((row) => + Object.entries(where).every(([key, value]) => row[key] === value), + ); + const [field, direction] = Object.entries(orderBy ?? {})[0] ?? []; + return Promise.resolve( + [...filtered].sort((left, right) => { + if (!field) return 0; + const comparison = + left[field]! < right[field]! ? -1 : left[field]! > right[field]! ? 1 : 0; + return direction === 'desc' ? -comparison : comparison; + }), + ); + }, + update({ + where, + data, + }: { + readonly where: { readonly id: string }; + readonly data: Record; + }) { + const index = rows.findIndex((row) => row['id'] === where.id); + if (index < 0) throw new Error('row not found'); + rows[index] = { ...rows[index], ...data } as TRow; + return Promise.resolve(rows[index]); + }, + }; +} + +function client(): EntitlementDatabaseClientV1 { + const planRows: Record[] = []; + const snapshotRows: Record[] = []; + const entryRows: Record[] = []; + const reservationRows: Record[] = []; + const database = { + entitlementPlanRecord: delegate(planRows), + entitlementSnapshotRecord: delegate(snapshotRows), + usageLedgerEntryRecord: delegate(entryRows), + usageReservationRecord: delegate(reservationRows), + async $transaction( + work: (transaction: EntitlementDatabaseClientV1) => Promise, + ): Promise { + return work(database as unknown as EntitlementDatabaseClientV1); + }, + }; + return database as unknown as EntitlementDatabaseClientV1; +} + +function admissionInput(idempotencyKey: string, suffix: string) { + const ids: Record = { + '1': { + reservationId: '00000000-0000-4000-8000-000000000221', + entryId: '00000000-0000-4000-8000-000000000231', + }, + '2': { + reservationId: '00000000-0000-4000-8000-000000000222', + entryId: '00000000-0000-4000-8000-000000000232', + }, + }; + const selected = ids[suffix]; + if (!selected) throw new Error('unknown fixture suffix'); + return { + snapshotId: snapshot().snapshotId, + feature: 'job.execute', + reservationId: stable(selected.reservationId), + entryId: stable(selected.entryId), + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + metric: 'job_count', + requestedUnits: 1, + idempotencyKey, + now: '2026-01-01T00:01:00.000Z', + }; +} + +void test('[BUA-001, BUA-002, BUA-008, IAM-009] Prisma entitlement adapter persists immutable plans, snapshots, and scoped usage', async () => { + const repository = new PrismaEntitlementRepositoryAdapter(client()); + await repository.savePlan(plan()); + await repository.saveSnapshot(context(workspaceId, 'seed-1'), snapshot()); + const service = new EntitlementAdmissionService(repository); + const result = await service.admit( + context(workspaceId, 'admit-1'), + admissionInput('admit-1', '1'), + ); + assert.equal(result.accepted, true); + assert.equal((await repository.listUsageState(context(workspaceId, 'read'))).entries.length, 1); + assert.equal( + (await repository.listUsageState(context(siblingWorkspaceId, 'sibling'))).entries.length, + 0, + ); + assert.equal( + await repository.findSnapshot( + context(siblingWorkspaceId, 'snapshot-sibling'), + snapshot().snapshotId, + ), + undefined, + ); +}); + +void test('[BUA-012] Prisma entitlement adapter applies reservation status revisions and preserves idempotent settlement', async () => { + const repository = new PrismaEntitlementRepositoryAdapter(client()); + await repository.saveSnapshot(context(workspaceId, 'seed-2'), snapshot()); + const service = new EntitlementAdmissionService(repository); + const admitted = await service.admit( + context(workspaceId, 'admit-2'), + admissionInput('admit-2', '2'), + ); + assert.equal(admitted.accepted, true); + if (!admitted.accepted) return; + const finalized = await service.finalize(context(workspaceId, 'finish-2'), { + reservationId: admitted.value.reservation.reservationId, + releaseEntryId: stable('00000000-0000-4000-8000-000000000322'), + commitEntryId: stable('00000000-0000-4000-8000-000000000323'), + committedUnits: 1, + now: '2026-01-01T00:02:00.000Z', + idempotencyKey: 'finish-2', + }); + assert.equal(finalized.accepted, true); + assert.deepEqual( + await service.finalize(context(workspaceId, 'finish-2'), { + reservationId: admitted.value.reservation.reservationId, + releaseEntryId: stable('00000000-0000-4000-8000-000000000322'), + commitEntryId: stable('00000000-0000-4000-8000-000000000323'), + committedUnits: 1, + now: '2026-01-01T00:02:00.000Z', + idempotencyKey: 'finish-2', + }), + finalized, + ); +}); diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts new file mode 100644 index 00000000..d644d1bb --- /dev/null +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { AppModule } from '../../src/app.module.js'; +import { IamModule } from '../../src/features/iam/iam.module.js'; +import { + AUTHENTICATION_USE_CASE, + CREDENTIAL_LOOKUP_PORT, +} from '../../src/features/iam/application/authentication.port.js'; +import { PrismaCredentialLookupAdapter } from '../../src/features/iam/adapter/prisma-credential-lookup.adapter.js'; +import { PrismaSessionLifecycleAdapter } from '../../src/features/iam/adapter/prisma-session-lifecycle.adapter.js'; +import { SESSION_LIFECYCLE_PORT } from '../../src/features/iam/application/session-lifecycle.port.js'; +import { IDENTITY_BOOTSTRAP_REPOSITORY_PORT } from '../../src/features/iam/application/identity-bootstrap-repository.port.js'; +import { PrismaIdentityBootstrapRepositoryAdapter } from '../../src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.js'; +import { MFA_REPOSITORY_PORT } from '../../src/features/iam/application/mfa-repository.port.js'; +import { PrismaMfaRepositoryAdapter } from '../../src/features/iam/adapter/prisma-mfa-repository.adapter.js'; +import { MFA_SERVICE, MfaService } from '../../src/features/iam/application/mfa.service.js'; +import { IAM_REPOSITORY_PORT } from '../../src/features/iam/application/iam-repository.port.js'; +import { PrismaIamRepositoryAdapter } from '../../src/features/iam/adapter/prisma-iam-repository.adapter.js'; +import { AudModule } from '../../src/features/aud/aud.module.js'; +import { AUDIT_REPOSITORY_PORT } from '../../src/features/aud/application/audit-repository.port.js'; +import { PrismaAuditRepositoryAdapter } from '../../src/features/aud/adapter/prisma-audit-repository.adapter.js'; +import { BuaModule } from '../../src/features/bua/bua.module.js'; +import { ENTITLEMENT_REPOSITORY_PORT } from '../../src/features/bua/application/entitlement-repository.port.js'; +import { PrismaEntitlementRepositoryAdapter } from '../../src/features/bua/adapter/prisma-entitlement-repository.adapter.js'; +import { REQUEST_TENANT_CONTEXT } from '../../src/platform/http/request-tenant-context.port.js'; +import { SessionRequestTenantContextAdapter } from '../../src/platform/http/session-tenant-context.adapter.js'; + +function moduleTypes(): readonly unknown[] { + const registered = AppModule.register(); + return (registered.imports ?? []).map((entry) => + typeof entry === 'object' && entry !== null && 'module' in entry + ? (entry as { readonly module: unknown }).module + : entry, + ); +} + +void test('[IAM-001, AUD-001, BUA-001] application composition includes identity, audit, and entitlements modules', () => { + const types = moduleTypes(); + assert.ok(types.includes(AudModule)); + assert.ok(types.includes(BuaModule)); +}); + +void test('[AUD-001] configured audit persistence uses the Prisma adapter instead of the local fallback', () => { + const database = {} as never; + const registered = AudModule.register({ auditDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === AUDIT_REPOSITORY_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaAuditRepositoryAdapter); +}); + +void test('[IAM-001] configured credential persistence uses the Prisma adapter boundary', () => { + const database = {} as never; + const registered = IamModule.register({ credentialDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === CREDENTIAL_LOOKUP_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaCredentialLookupAdapter); + const authentication = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === AUTHENTICATION_USE_CASE, + ); + assert.ok(authentication && 'useValue' in authentication); +}); + +void test('[IAM-005] configured session persistence uses the Prisma lifecycle adapter', () => { + const database = {} as never; + const registered = IamModule.register({ sessionDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === SESSION_LIFECYCLE_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaSessionLifecycleAdapter); +}); + +void test('[IAM-009] a session access-token lookup composes one live tenant-context adapter across feature modules', () => { + const sessions = { + findPrincipalByAccessToken: () => Promise.resolve(undefined), + } as never; + const registered = AppModule.register({ sessions }); + const iam = registered.imports?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'module' in candidate && + candidate.module === IamModule, + ); + assert.ok(iam && typeof iam === 'object' && 'providers' in iam); + if (!iam || typeof iam !== 'object' || !('providers' in iam)) return; + const provider = iam.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === REQUEST_TENANT_CONTEXT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof SessionRequestTenantContextAdapter); +}); + +void test('[IAM-001, IAM-011] configured identity bootstrap persistence uses the Prisma adapter', () => { + const database = {} as never; + const registered = IamModule.register({ identityBootstrapDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === IDENTITY_BOOTSTRAP_REPOSITORY_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaIdentityBootstrapRepositoryAdapter); +}); + +void test('[IAM-012, IAM-014] configured MFA persistence uses the Prisma adapter', () => { + const database = {} as never; + const registered = IamModule.register({ mfaDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === MFA_REPOSITORY_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaMfaRepositoryAdapter); +}); + +void test('[IAM-012, IAM-014] configured MFA persistence composes the application service', () => { + const database = {} as never; + const registered = IamModule.register({ mfaDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === MFA_SERVICE, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof MfaService); +}); + +void test('[IAM-009, IAM-019] configured IAM membership persistence uses the Prisma adapter', () => { + const database = {} as never; + const registered = IamModule.register({ iamDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === IAM_REPOSITORY_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaIamRepositoryAdapter); +}); + +void test('[BUA-001] configured entitlement persistence uses the Prisma adapter instead of the local fallback', () => { + const database = {} as never; + const registered = BuaModule.register({ entitlementDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === ENTITLEMENT_REPOSITORY_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaEntitlementRepositoryAdapter); +}); diff --git a/services/api/test/features/iam/prisma-credential-lookup.test.ts b/services/api/test/features/iam/prisma-credential-lookup.test.ts new file mode 100644 index 00000000..2636c30a --- /dev/null +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -0,0 +1,143 @@ +/* eslint-disable @typescript-eslint/require-await -- Prisma delegate doubles intentionally mirror async client signatures. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + PrismaCredentialLookupAdapter, + type CredentialLookupDatabaseClientV1, +} from '../../../src/features/iam/adapter/prisma-credential-lookup.adapter.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const organizationId = '00000000-0000-4000-8000-000000000002'; +const workspaceId = '00000000-0000-4000-8000-000000000003'; +const membershipId = '00000000-0000-4000-8000-000000000004'; + +function database( + overrides: Partial = {}, +): CredentialLookupDatabaseClientV1 { + return { + userIdentity: { + findUnique: async () => ({ + id: userId, + email: 'user@example.com', + status: 'ACTIVE', + securityEpoch: 3, + }), + }, + passwordCredential: { + findUnique: async () => ({ + id: '00000000-0000-4000-8000-000000000005', + userId, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=19456,t=2,p=1$hash', + }), + }, + membershipIdentity: { + findMany: async () => [ + { + id: membershipId, + principalId: userId, + organizationId, + workspaceId, + projectId: null, + scopeType: 'WORKSPACE', + status: 'ACTIVE', + }, + ], + }, + workspaceIdentity: { + findUnique: async () => ({ id: workspaceId, organizationId, status: 'ACTIVE' }), + findMany: async () => [{ id: workspaceId, organizationId, status: 'ACTIVE' }], + }, + organizationIdentity: { + findUnique: async () => ({ id: organizationId, status: 'ACTIVE' }), + }, + mfaFactor: { + findMany: async () => [{ id: '00000000-0000-4000-8000-000000000006' }], + }, + ...overrides, + }; +} + +void test('[IAM-001, IAM-002, IAM-009] credential lookup returns only an active, scoped principal', async () => { + const adapter = new PrismaCredentialLookupAdapter(database()); + const result = await adapter.findCredential('USER@EXAMPLE.COM'); + + assert.deepEqual(result, { + principal: { + userId, + organizationId, + workspaceId, + securityEpoch: 3, + mfaRequired: true, + }, + credential: { + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=19456,t=2,p=1$hash', + }, + }); +}); + +void test('[IAM-001, IAM-009] lookup fails closed when persisted tenancy is inactive or malformed', async () => { + const inactive = new PrismaCredentialLookupAdapter( + database({ + organizationIdentity: { + findUnique: async () => ({ id: organizationId, status: 'SUSPENDED' }), + }, + }), + ); + assert.equal(await inactive.findCredential('user@example.com'), undefined); + + const malformed = new PrismaCredentialLookupAdapter( + database({ + membershipIdentity: { + findMany: async () => [ + { + id: membershipId, + principalId: userId, + organizationId: 'not-a-uuid', + workspaceId, + projectId: null, + scopeType: 'WORKSPACE', + status: 'ACTIVE', + }, + ], + }, + }), + ); + assert.equal(await malformed.findCredential('user@example.com'), undefined); +}); + +void test('[IAM-001, IAM-002] lookup does not authenticate users without an active workspace membership', async () => { + const adapter = new PrismaCredentialLookupAdapter( + database({ + membershipIdentity: { findMany: async () => [] }, + }), + ); + + assert.equal(await adapter.findCredential('user@example.com'), undefined); +}); + +void test('[IAM-001, IAM-009] an organization owner resolves the canonical active workspace', async () => { + const adapter = new PrismaCredentialLookupAdapter( + database({ + membershipIdentity: { + findMany: async () => [ + { + id: membershipId, + principalId: userId, + organizationId, + workspaceId: null, + projectId: null, + scopeType: 'ORGANIZATION', + status: 'ACTIVE', + }, + ], + }, + }), + ); + + const result = await adapter.findCredential('user@example.com'); + assert.equal(result?.principal.organizationId, organizationId); + assert.equal(result?.principal.workspaceId, workspaceId); +}); diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts new file mode 100644 index 00000000..935ed118 --- /dev/null +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -0,0 +1,189 @@ +/* eslint-disable @typescript-eslint/require-await -- Prisma delegate doubles intentionally mirror async client signatures. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + parseStableIdentifierV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { + PrismaIamRepositoryAdapter, + type IamDatabaseClientV1, + type IamMembershipDatabaseRowV1, +} from '../../../src/features/iam/adapter/prisma-iam-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const id = (tail: string): string => `00000000-0000-4000-8000-${tail.padStart(12, '0')}`; +const stable = (tail: string): StableIdentifierV1 => { + const parsed = parseStableIdentifierV1(id(tail)); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid test identifier'); + return parsed.value; +}; +const organizationId = stable('1'); +const workspaceId = stable('2'); +const siblingWorkspaceId = stable('3'); +const principalId = stable('4'); + +function context(scope: TenantScopeV1, expectedRevision?: number) { + const result = createIamTenantContextV1({ + tenantScope: scope, + actorId: principalId, + correlationId: id('5'), + idempotencyKey: 'membership-update-1', + authorizationEpoch: 1, + expectedRevision, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid test context'); + return result.value; +} + +function row( + idValue: string, + scope: 'WORKSPACE' | 'ORGANIZATION', + workspace: string | null, + roleId: string, +): IamMembershipDatabaseRowV1 { + return { + id: idValue, + principalType: 'USER', + principalId, + scopeType: scope, + organizationId, + workspaceId: workspace, + projectId: null, + roleId, + status: 'ACTIVE', + startsAt: null, + expiresAt: null, + revision: 1, + }; +} + +function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { + readonly client: IamDatabaseClientV1; + readonly memberships: Map; +} { + const memberships = new Map(rows.map((value) => [value.id, value])); + const client = { + membershipIdentity: { + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + memberships.get(where.id) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => + [...memberships.values()].filter((candidate) => + Object.entries(where).every( + ([key, value]) => candidate[key as keyof IamMembershipDatabaseRowV1] === value, + ), + ), + create: async ({ data }: { readonly data: IamMembershipDatabaseRowV1 }) => { + memberships.set(data.id, data); + return data; + }, + update: async ({ + where, + data, + }: { + readonly where: { readonly id: string }; + readonly data: Partial; + }) => { + const current = memberships.get(where.id); + if (!current) throw new Error('MEMBERSHIP_NOT_FOUND'); + const updated = { ...current, ...data }; + memberships.set(where.id, updated); + return updated; + }, + }, + $transaction: async (work: (transaction: IamDatabaseClientV1) => Promise) => { + const before = new Map(memberships); + try { + return await work(client); + } catch (error) { + memberships.clear(); + for (const [key, value] of before) memberships.set(key, value); + throw error; + } + }, + } as unknown as IamDatabaseClientV1; + return { client, memberships }; +} + +void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and hide siblings', async () => { + const { client } = createDatabase([ + row(id('10'), 'WORKSPACE', workspaceId, 'viewer'), + row(id('11'), 'WORKSPACE', siblingWorkspaceId, 'owner'), + row(id('12'), 'ORGANIZATION', null, 'admin'), + ]); + const repository = new PrismaIamRepositoryAdapter(client); + const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; + assert.equal((await repository.listMemberships(context(workspaceScope))).length, 2); + assert.equal( + (await repository.listMemberships(context({ scopeType: 'organization', organizationId }))) + .length, + 3, + ); + assert.equal( + (await repository.findMembership(context(workspaceScope), principalId))?.id, + stable('10'), + ); +}); + +void test('[IAM-009, IAM-019] Prisma IAM writes require narrowing and enforce optimistic revisions', async () => { + const { client, memberships } = createDatabase(); + const repository = new PrismaIamRepositoryAdapter(client); + const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; + await assert.rejects( + repository.saveMembership(context(workspaceScope), { + id: stable('20'), + principalId, + scope: { scopeType: 'organization', organizationId }, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }), + /IAM_SCOPE_NARROWING_REQUIRED/u, + ); + await repository.saveMembership(context(workspaceScope), { + id: stable('21'), + principalId, + scope: workspaceScope, + roleId: 'viewer', + status: 'ACTIVE', + revision: 1, + }); + await assert.rejects( + repository.saveMembership(context(workspaceScope, 1), { + id: stable('21'), + principalId, + scope: workspaceScope, + roleId: 'operator', + status: 'ACTIVE', + revision: 3, + }), + /IAM_REVISION_CONFLICT/u, + ); + assert.equal(memberships.get(id('21'))?.roleId, 'viewer'); +}); + +void test('[IAM-009] Prisma IAM transaction rollback leaves no staged membership', async () => { + const { client, memberships } = createDatabase(); + const repository = new PrismaIamRepositoryAdapter(client); + const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; + await assert.rejects( + repository.withTransaction(context(workspaceScope), async (transaction) => { + await transaction.saveMembership(context(workspaceScope), { + id: stable('22'), + principalId, + scope: workspaceScope, + roleId: 'viewer', + status: 'ACTIVE', + revision: 1, + }); + throw new Error('rollback'); + }), + /rollback/u, + ); + assert.equal(memberships.size, 0); +}); diff --git a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts new file mode 100644 index 00000000..4a187435 --- /dev/null +++ b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts @@ -0,0 +1,191 @@ +/* eslint-disable @typescript-eslint/require-await -- Prisma delegate doubles intentionally mirror async client signatures. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { bootstrapPersonalOrganizationV1 } from '@databreeze/domain/identity/v1'; + +import { + PrismaIdentityBootstrapRepositoryAdapter, + type IdentityBootstrapDatabaseClientV1, + type UserIdentityDatabaseRowV1, + type OrganizationIdentityDatabaseRowV1, + type WorkspaceIdentityDatabaseRowV1, + type ProjectIdentityDatabaseRowV1, + type MembershipIdentityDatabaseRowV1, +} from '../../../src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const organizationId = '00000000-0000-4000-8000-000000000002'; +const workspaceId = '00000000-0000-4000-8000-000000000003'; +const projectId = '00000000-0000-4000-8000-000000000004'; +const membershipId = '00000000-0000-4000-8000-000000000005'; +const createdAt = new Date('2026-01-01T00:00:00.000Z'); +const input = { + user: { + id: userId, + displayName: 'Nguyen An', + createdAt: createdAt.toISOString(), + }, + organizationId, + workspaceId, + projectId, + membershipId, + createdAt: createdAt.toISOString(), +}; + +function createDatabase(): { + readonly client: IdentityBootstrapDatabaseClientV1; + readonly users: Map; + readonly organizations: Map; + readonly workspaces: Map; + readonly projects: Map; + readonly memberships: Map; +} { + const users = new Map([ + [ + userId, + { + id: userId, + email: 'an@example.com', + displayName: 'Nguyen An', + locale: 'vi-VN', + status: 'ACTIVE', + securityEpoch: 1, + createdAt, + }, + ], + ]); + const organizations = new Map(); + const workspaces = new Map(); + const projects = new Map(); + const memberships = new Map(); + const client = { + userIdentity: { + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + users.get(where.id) ?? null, + }, + organizationIdentity: { + create: async ({ data }: { readonly data: OrganizationIdentityDatabaseRowV1 }) => { + organizations.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + organizations.get(where.id) ?? null, + }, + workspaceIdentity: { + create: async ({ data }: { readonly data: WorkspaceIdentityDatabaseRowV1 }) => { + workspaces.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + workspaces.get(where.id) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => + [...workspaces.values()].filter((row) => + Object.entries(where).every( + ([key, value]) => row[key as keyof WorkspaceIdentityDatabaseRowV1] === value, + ), + ), + }, + projectIdentity: { + create: async ({ data }: { readonly data: ProjectIdentityDatabaseRowV1 }) => { + projects.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + projects.get(where.id) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => + [...projects.values()].filter((row) => + Object.entries(where).every( + ([key, value]) => row[key as keyof ProjectIdentityDatabaseRowV1] === value, + ), + ), + }, + membershipIdentity: { + create: async ({ data }: { readonly data: MembershipIdentityDatabaseRowV1 }) => { + memberships.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + memberships.get(where.id) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => + [...memberships.values()].filter((row) => + Object.entries(where).every( + ([key, value]) => row[key as keyof MembershipIdentityDatabaseRowV1] === value, + ), + ), + }, + $transaction: async ( + work: (transaction: IdentityBootstrapDatabaseClientV1) => Promise, + ) => { + const before = { + organizations: new Map(organizations), + workspaces: new Map(workspaces), + projects: new Map(projects), + memberships: new Map(memberships), + }; + try { + return await work(client); + } catch (error) { + organizations.clear(); + workspaces.clear(); + projects.clear(); + memberships.clear(); + for (const [id, row] of before.organizations) organizations.set(id, row); + for (const [id, row] of before.workspaces) workspaces.set(id, row); + for (const [id, row] of before.projects) projects.set(id, row); + for (const [id, row] of before.memberships) memberships.set(id, row); + throw error; + } + }, + } as unknown as IdentityBootstrapDatabaseClientV1; + return { client, users, organizations, workspaces, projects, memberships }; +} + +void test('[IAM-001, IAM-009, IAM-011] Prisma bootstrap persists and reconstructs a personal owner hierarchy', async () => { + const { client, organizations, workspaces, projects, memberships } = createDatabase(); + const adapter = new PrismaIdentityBootstrapRepositoryAdapter(client); + const validated = bootstrapPersonalOrganizationV1(input); + assert.equal(validated.accepted, true); + if (!validated.accepted) return; + + await adapter.save(validated.value); + assert.equal(organizations.size, 1); + assert.equal(workspaces.size, 1); + assert.equal(projects.size, 1); + assert.equal(memberships.size, 1); + assert.deepEqual(await adapter.findByUserId(validated.value.user.id), validated.value); +}); + +void test('[IAM-011] repeated bootstrap is immutable and conflicting hierarchy is rejected', async () => { + const { client } = createDatabase(); + const adapter = new PrismaIdentityBootstrapRepositoryAdapter(client); + const validated = bootstrapPersonalOrganizationV1(input); + assert.equal(validated.accepted, true); + if (!validated.accepted) return; + + await adapter.save(validated.value); + await assert.doesNotReject(() => adapter.save(validated.value)); + await assert.rejects( + adapter.save({ + ...validated.value, + organization: { ...validated.value.organization, name: 'Changed' }, + }), + /IAM_BOOTSTRAP_CONFLICT/, + ); +}); + +void test('[IAM-001] bootstrap transaction rollback does not retain a partially written hierarchy', async () => { + const state = createDatabase(); + const adapter = new PrismaIdentityBootstrapRepositoryAdapter(state.client); + const validated = bootstrapPersonalOrganizationV1(input); + assert.equal(validated.accepted, true); + if (!validated.accepted) return; + await assert.rejects( + adapter.withTransaction(async (transaction) => { + await transaction.save(validated.value); + throw new Error('rollback'); + }), + /rollback/, + ); + assert.equal(state.organizations.size, 0); +}); diff --git a/services/api/test/features/iam/prisma-mfa-repository.test.ts b/services/api/test/features/iam/prisma-mfa-repository.test.ts new file mode 100644 index 00000000..05c5a0c6 --- /dev/null +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -0,0 +1,175 @@ +/* eslint-disable @typescript-eslint/require-await -- Prisma delegate doubles intentionally mirror async client signatures. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createMfaFactorV1, + createRecoveryCodeV1, + transitionMfaFactorV1, +} from '@databreeze/domain/mfa/v1'; +import type { MfaStateV1 } from '@databreeze/domain/mfa/v1'; + +import { + PrismaMfaRepositoryAdapter, + type MfaDatabaseClientV1, + type MfaFactorDatabaseRowV1, + type MfaRecoveryCodeDatabaseRowV1, +} from '../../../src/features/iam/adapter/prisma-mfa-repository.adapter.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const factorId = '00000000-0000-4000-8000-000000000002'; +const recoveryId = '00000000-0000-4000-8000-000000000003'; +const createdAt = new Date('2026-01-01T00:00:00.000Z'); + +function createDatabase(): { + readonly client: MfaDatabaseClientV1; + readonly factors: Map; + readonly recoveryCodes: Map; +} { + const factors = new Map(); + const recoveryCodes = new Map(); + const client = { + mfaFactor: { + findMany: async ({ where }: { readonly where: Readonly> }) => + [...factors.values()].filter((row) => + Object.entries(where).every( + ([key, value]) => row[key as keyof MfaFactorDatabaseRowV1] === value, + ), + ), + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + factors.get(where.id) ?? null, + create: async ({ data }: { readonly data: MfaFactorDatabaseRowV1 }) => { + factors.set(data.id, data); + return data; + }, + update: async ({ + where, + data, + }: { + readonly where: { readonly id: string }; + readonly data: Partial; + }) => { + const current = factors.get(where.id); + if (!current) throw new Error('MFA_FACTOR_NOT_FOUND'); + const updated = { ...current, ...data }; + factors.set(where.id, updated); + return updated; + }, + }, + mfaRecoveryCode: { + findMany: async ({ where }: { readonly where: Readonly> }) => + [...recoveryCodes.values()].filter((row) => + Object.entries(where).every( + ([key, value]) => row[key as keyof MfaRecoveryCodeDatabaseRowV1] === value, + ), + ), + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + recoveryCodes.get(where.id) ?? null, + create: async ({ data }: { readonly data: MfaRecoveryCodeDatabaseRowV1 }) => { + recoveryCodes.set(data.id, data); + return data; + }, + update: async ({ + where, + data, + }: { + readonly where: { readonly id: string }; + readonly data: Partial; + }) => { + const current = recoveryCodes.get(where.id); + if (!current) throw new Error('MFA_RECOVERY_CODE_NOT_FOUND'); + const updated = { ...current, ...data }; + recoveryCodes.set(where.id, updated); + return updated; + }, + }, + $transaction: async (work: (transaction: MfaDatabaseClientV1) => Promise) => { + const beforeFactors = new Map(factors); + const beforeCodes = new Map(recoveryCodes); + try { + return await work(client); + } catch (error) { + factors.clear(); + recoveryCodes.clear(); + for (const [id, row] of beforeFactors) factors.set(id, row); + for (const [id, row] of beforeCodes) recoveryCodes.set(id, row); + throw error; + } + }, + } as unknown as MfaDatabaseClientV1; + return { client, factors, recoveryCodes }; +} + +function state(): MfaStateV1 { + const factor = createMfaFactorV1({ + id: factorId, + userId, + method: 'TOTP', + secretReference: 'kms://mfa/secret/1', + enrolledAt: createdAt.toISOString(), + }); + const code = createRecoveryCodeV1({ + id: recoveryId, + userId, + digest: 'digest-1', + createdAt: createdAt.toISOString(), + }); + assert.equal(factor.accepted, true); + assert.equal(code.accepted, true); + if (!factor.accepted || !code.accepted) throw new Error('fixture invalid'); + return { factors: [factor.value], recoveryCodes: [code.value] }; +} + +void test('[IAM-012, IAM-014] Prisma MFA persistence round-trips opaque factors and recovery digests', async () => { + const { client, factors, recoveryCodes } = createDatabase(); + const adapter = new PrismaMfaRepositoryAdapter(client); + const input = state(); + const factor = input.factors[0]; + if (!factor) throw new Error('fixture missing factor'); + await adapter.saveState(factor.userId, input); + assert.equal(factors.size, 1); + assert.equal(recoveryCodes.size, 1); + assert.deepEqual(await adapter.findState(factor.userId), input); +}); + +void test('[IAM-012, IAM-014] status transitions persist by revision while immutable secrets and digests remain fixed', async () => { + const { client } = createDatabase(); + const adapter = new PrismaMfaRepositoryAdapter(client); + const input = state(); + const factor = input.factors[0]; + if (!factor) throw new Error('fixture missing factor'); + await adapter.saveState(factor.userId, input); + const transitioned = transitionMfaFactorV1(factor, 'VERIFY', '2026-01-01T00:01:00.000Z'); + assert.equal(transitioned.accepted, true); + if (!transitioned.accepted) return; + await adapter.saveState(factor.userId, { + factors: [transitioned.value], + recoveryCodes: input.recoveryCodes, + }); + const stored = await adapter.findState(factor.userId); + const storedFactor = stored.factors[0]; + if (!storedFactor) throw new Error('stored factor missing'); + assert.equal(storedFactor.status, 'ACTIVE'); + assert.equal(storedFactor.revision, 2); + assert.equal(storedFactor.secretReference, 'kms://mfa/secret/1'); +}); + +void test('[IAM-009, IAM-012] MFA state cannot cross users and failed transactions roll back', async () => { + const { client, factors } = createDatabase(); + const adapter = new PrismaMfaRepositoryAdapter(client); + const input = state(); + const factor = input.factors[0]; + if (!factor) throw new Error('fixture missing factor'); + await assert.rejects( + adapter.saveState('00000000-0000-4000-8000-000000000099', input), + /MFA_SCOPE_MISMATCH/, + ); + await assert.rejects( + adapter.withTransaction(async (transaction) => { + await transaction.saveState(factor.userId, input); + throw new Error('rollback'); + }), + /rollback/, + ); + assert.equal(factors.size, 0); +}); diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts new file mode 100644 index 00000000..f4eee384 --- /dev/null +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -0,0 +1,213 @@ +/* eslint-disable @typescript-eslint/require-await -- Prisma delegate doubles intentionally mirror async client signatures. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + PrismaSessionLifecycleAdapter, + type SessionLifecycleDatabaseClientV1, + type SessionRecordDatabaseRowV1, + type RefreshTokenDatabaseRowV1, + type AccessTokenDatabaseRowV1, +} from '../../../src/features/iam/adapter/prisma-session-lifecycle.adapter.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const organizationId = '00000000-0000-4000-8000-000000000002'; +const workspaceId = '00000000-0000-4000-8000-000000000003'; +const principal = { + userId, + organizationId, + workspaceId, + securityEpoch: 4, + mfaRequired: true, +}; + +function createDatabase(): { + readonly client: SessionLifecycleDatabaseClientV1; + readonly sessions: Map; + readonly refreshTokens: Map; + readonly accessTokens: Map; +} { + const sessions = new Map(); + const refreshTokens = new Map(); + const accessTokens = new Map(); + const client = { + sessionRecord: { + create: async ({ data }: { readonly data: SessionRecordDatabaseRowV1 }) => { + sessions.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + sessions.get(where.id) ?? null, + update: async ({ + where, + data, + }: { + readonly where: { readonly id: string }; + readonly data: Partial; + }) => { + const current = sessions.get(where.id); + if (!current) throw new Error('SESSION_NOT_FOUND'); + const updated = { ...current, ...data }; + sessions.set(where.id, updated); + return updated; + }, + }, + refreshTokenRecord: { + create: async ({ data }: { readonly data: RefreshTokenDatabaseRowV1 }) => { + refreshTokens.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly tokenDigest: string } }) => + [...refreshTokens.values()].find((row) => row.tokenDigest === where.tokenDigest) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => + [...refreshTokens.values()].filter((row) => + Object.entries(where).every( + ([key, value]) => row[key as keyof RefreshTokenDatabaseRowV1] === value, + ), + ), + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Partial; + }) => { + let count = 0; + for (const [id, row] of refreshTokens) { + if ( + !Object.entries(where).every( + ([key, value]) => row[key as keyof RefreshTokenDatabaseRowV1] === value, + ) + ) + continue; + refreshTokens.set(id, { ...row, ...data }); + count += 1; + } + return { count }; + }, + }, + accessTokenRecord: { + create: async ({ data }: { readonly data: AccessTokenDatabaseRowV1 }) => { + accessTokens.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly tokenDigest: string } }) => + [...accessTokens.values()].find((row) => row.tokenDigest === where.tokenDigest) ?? null, + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Partial; + }) => { + let count = 0; + for (const [id, row] of accessTokens) { + if ( + !Object.entries(where).every( + ([key, value]) => row[key as keyof AccessTokenDatabaseRowV1] === value, + ) + ) + continue; + accessTokens.set(id, { ...row, ...data }); + count += 1; + } + return { count }; + }, + }, + userIdentity: { + findUnique: async () => ({ id: userId, status: 'ACTIVE', securityEpoch: 4 }), + }, + membershipIdentity: { + findMany: async () => [ + { + id: '00000000-0000-4000-8000-000000000004', + principalId: userId, + organizationId, + workspaceId, + projectId: null, + scopeType: 'WORKSPACE', + status: 'ACTIVE', + }, + ], + }, + workspaceIdentity: { + findUnique: async () => ({ id: workspaceId, organizationId, status: 'ACTIVE' }), + }, + organizationIdentity: { + findUnique: async () => ({ id: organizationId, status: 'ACTIVE' }), + }, + mfaFactor: { + findMany: async () => [{ id: '00000000-0000-4000-8000-000000000005' }], + }, + $transaction: async ( + work: (transaction: SessionLifecycleDatabaseClientV1) => Promise, + ) => work(client), + } as unknown as SessionLifecycleDatabaseClientV1; + return { client, sessions, refreshTokens, accessTokens }; +} + +void test('[IAM-005, IAM-006] Prisma sessions persist opaque bounded access and refresh credentials', async () => { + const { client, sessions, refreshTokens, accessTokens } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client, { + clock: () => new Date('2026-01-01T00:00:00.000Z'), + }); + + const session = await adapter.issue(principal, 'web'); + assert.match(session.sessionId, /^[0-9a-f-]{36}$/u); + assert.match(session.accessToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/u); + assert.match(session.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/u); + assert.equal(sessions.size, 1); + assert.equal(refreshTokens.size, 1); + assert.equal(accessTokens.size, 1); + assert.equal((await adapter.findPrincipal(session.sessionId))?.userId, userId); + assert.equal((await adapter.findPrincipalByAccessToken(session.accessToken))?.userId, userId); + assert.equal(await adapter.findPrincipalByAccessToken('not-a-token'), undefined); +}); + +void test('[IAM-005] refresh rotation is transactional and reuse revokes the complete family', async () => { + const { client, refreshTokens } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client, { + clock: () => new Date('2026-01-01T00:00:00.000Z'), + }); + const first = await adapter.issue(principal, 'desktop'); + const rotated = await adapter.refresh(first.refreshToken, 'desktop'); + assert.equal(rotated.accepted, true); + if (!rotated.accepted) return; + assert.notEqual(rotated.value.refreshToken, first.refreshToken); + assert.equal([...refreshTokens.values()].filter((row) => row.status === 'USED').length, 1); + assert.deepEqual(await adapter.refresh(first.refreshToken, 'desktop'), { + accepted: false, + code: 'REUSE_DETECTED', + }); + assert.deepEqual(await adapter.refresh(rotated.value.refreshToken, 'desktop'), { + accepted: false, + code: 'REVOKED_FAMILY', + }); + assert.equal(await adapter.findPrincipal(first.sessionId), undefined); +}); + +void test('[IAM-005] expired refresh tokens fail closed without returning token material', async () => { + let now = new Date('2026-01-01T00:00:00.000Z'); + const { client } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client, { clock: () => new Date(now) }); + const session = await adapter.issue(principal, 'android'); + now = new Date('2026-02-01T00:00:00.000Z'); + assert.deepEqual(await adapter.refresh(session.refreshToken, 'android'), { + accepted: false, + code: 'EXPIRED', + }); + assert.deepEqual(await adapter.refresh('not-a-token', 'android'), { + accepted: false, + code: 'INVALID_REFRESH_TOKEN', + }); +}); + +void test('[IAM-005] revocation is idempotent and hides session principals afterward', async () => { + const { client } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client); + const session = await adapter.issue(principal, 'web'); + assert.equal(await adapter.revoke(session.sessionId), true); + assert.equal(await adapter.revoke(session.sessionId), true); + assert.equal(await adapter.findPrincipal(session.sessionId), undefined); + assert.equal(await adapter.findPrincipalByAccessToken(session.accessToken), undefined); +}); diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts new file mode 100644 index 00000000..8b686e8f --- /dev/null +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + CSRF_COOKIE_NAME_V1, + REFRESH_COOKIE_NAME_V1, + clearCookieV1, + readCookieValueV1, + serializeCookieV1, +} from '../../../src/features/iam/api/session-cookies.js'; + +const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; +const refreshToken = `00000000-0000-4000-8000-000000000001.${token}`; + +void test('serializes bounded session cookies with explicit browser security attributes', () => { + assert.equal( + serializeCookieV1(REFRESH_COOKIE_NAME_V1, refreshToken, { + httpOnly: true, + maxAgeSeconds: 2_592_000, + }), + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=Lax`, + ); + assert.equal( + serializeCookieV1(CSRF_COOKIE_NAME_V1, token, { httpOnly: false, maxAgeSeconds: 900 }), + `${CSRF_COOKIE_NAME_V1}=${token}; Max-Age=900; Path=/; Secure; SameSite=Lax`, + ); +}); + +void test('reads one exact cookie value and fails closed for ambiguity or malformed input', () => { + assert.equal( + readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${refreshToken}`, REFRESH_COOKIE_NAME_V1), + refreshToken, + ); + assert.equal( + readCookieValueV1( + `other=value; ${REFRESH_COOKIE_NAME_V1}=${refreshToken}`, + REFRESH_COOKIE_NAME_V1, + ), + refreshToken, + ); + assert.equal( + readCookieValueV1( + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; ${REFRESH_COOKIE_NAME_V1}=other`, + REFRESH_COOKIE_NAME_V1, + ), + undefined, + ); + assert.equal(readCookieValueV1('broken-cookie', REFRESH_COOKIE_NAME_V1), undefined); + assert.equal(readCookieValueV1(undefined, REFRESH_COOKIE_NAME_V1), undefined); +}); + +void test('creates deletion cookies without weakening the original security attributes', () => { + assert.equal( + clearCookieV1(REFRESH_COOKIE_NAME_V1, { httpOnly: true }), + `${REFRESH_COOKIE_NAME_V1}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`, + ); + assert.equal( + clearCookieV1(CSRF_COOKIE_NAME_V1, { httpOnly: false }), + `${CSRF_COOKIE_NAME_V1}=; Max-Age=0; Path=/; Secure; SameSite=Lax`, + ); +}); diff --git a/services/api/test/features/iam/session-lifecycle.test.ts b/services/api/test/features/iam/session-lifecycle.test.ts index 94ce3438..396edc60 100644 --- a/services/api/test/features/iam/session-lifecycle.test.ts +++ b/services/api/test/features/iam/session-lifecycle.test.ts @@ -19,7 +19,7 @@ void test('[IAM-005, IAM-006] session issuer creates opaque bounded sessions', a assert.match(session.accessToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/u); assert.match(session.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/u); assert.equal(session.accessExpiresAt, '2026-01-01T00:15:00.000Z'); - assert.equal(adapter.findPrincipal(session.sessionId)?.userId, principal.userId); + assert.equal((await adapter.findPrincipal(session.sessionId))?.userId, principal.userId); }); void test('[IAM-005] refresh rotation is single-use and reuse revokes the family', async () => { @@ -32,7 +32,7 @@ void test('[IAM-005] refresh rotation is single-use and reuse revokes the family assert.notEqual(rotated.value.refreshToken, first.refreshToken); const reuse = await adapter.refresh(first.refreshToken, 'desktop'); assert.deepEqual(reuse, { accepted: false, code: 'REUSE_DETECTED' }); - assert.equal(adapter.findPrincipal(first.sessionId), undefined); + assert.equal(await adapter.findPrincipal(first.sessionId), undefined); assert.deepEqual(await adapter.refresh(rotated.value.refreshToken, 'desktop'), { accepted: false, code: 'REVOKED_FAMILY', diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 97b22f86..c2e39d03 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -6,6 +6,9 @@ import { parseV1Contract } from '@databreeze/contracts/v1'; import type { NestFastifyApplication } from '@nestjs/platform-fastify'; import { createApiApplication } from '../src/bootstrap.js'; +import { createIamTenantContextV1 } from '../src/features/iam/application/tenant-context.js'; +import { InMemoryMfaRepositoryAdapter } from '../src/features/iam/adapter/in-memory-mfa-repository.adapter.js'; +import { MfaService } from '../src/features/iam/application/mfa.service.js'; interface InjectResponse { readonly body: string; @@ -23,6 +26,7 @@ const problemSchemaId = 'https://schemas.databreeze.dev/contracts/v1/problem-det const correlationId = '123e4567-e89b-42d3-a456-426614174000'; const secondCorrelationId = '018f1f08-7b2c-7c74-8e12-f639c7c92b15'; const leakedMarker = 'do-not-leak-7f6290'; +const csrfToken = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; async function withApp( options: Parameters[0], @@ -54,6 +58,11 @@ function assertProblem(response: InjectResponse, status: number, code: string): ); } +function parsedBody(response: { readonly body: string }): TValue { + const parsed: unknown = JSON.parse(response.body); + return parsed as TValue; +} + void test('reports ready only through the injectable readiness port and minimizes failed-check details', async () => { await withApp({ readinessPort: { check: () => Promise.resolve(true) } }, async (app) => { const response = await app.inject({ method: 'GET', url: '/health/ready' }); @@ -178,6 +187,54 @@ void test('validates closed compatibility bodies without implicit scalar coercio }); }); +void test('enforces CSRF only for browser-cookie mutations and keeps token values out of errors', async () => { + await withApp({}, async (app) => { + const missing = await app.inject({ + method: 'POST', + url: '/v1/system/compatibility/check', + headers: { + cookie: 'databreeze_refresh=session-value', + origin: 'http://localhost:3000', + }, + payload: { clientPlatform: 'web', clientVersion: '1.0.0' }, + }); + assertProblem(missing, 403, 'CSRF_REQUIRED'); + assert.doesNotMatch(missing.body, new RegExp(csrfToken)); + + const hostile = await app.inject({ + method: 'POST', + url: '/v1/system/compatibility/check', + headers: { + cookie: `databreeze_refresh=session-value; databreeze_csrf=${csrfToken}`, + 'x-csrf-token': csrfToken, + origin: 'https://evil.example', + }, + payload: { clientPlatform: 'web', clientVersion: '1.0.0' }, + }); + assertProblem(hostile, 403, 'ORIGIN_INVALID'); + assert.doesNotMatch(hostile.body, new RegExp(csrfToken)); + + const accepted = await app.inject({ + method: 'POST', + url: '/v1/system/compatibility/check', + headers: { + cookie: `databreeze_refresh=session-value; databreeze_csrf=${csrfToken}`, + 'x-csrf-token': csrfToken, + origin: 'http://localhost:3000', + }, + payload: { clientPlatform: 'web', clientVersion: '1.0.0' }, + }); + assert.equal(accepted.statusCode, 200); + + const safeRead = await app.inject({ + method: 'GET', + url: '/health/live', + headers: { cookie: 'databreeze_refresh=session-value' }, + }); + assert.equal(safeRead.statusCode, 200); + }); +}); + void test('maps an oversized JSON body to safe Problem Details', async () => { await withApp({}, async (app) => { const response = await app.inject({ @@ -251,11 +308,18 @@ void test('sign-in returns a session DTO and maps authentication failures withou organizationId: '00000000-0000-4000-8000-000000000002', workspaceId: '00000000-0000-4000-8000-000000000003', accessToken: 'access-token', - refreshToken: 'refresh-token', accessExpiresAt: '2026-01-01T00:15:00.000Z', securityEpoch: 2, mfaRequired: true, }); + const setCookies = response.headers['set-cookie']; + assert.ok(Array.isArray(setCookies)); + assert.equal(setCookies.length, 2); + assert.match( + setCookies[0] ?? '', + /^databreeze_refresh=refresh-token; .*HttpOnly; Secure; SameSite=Lax$/, + ); + assert.match(setCookies[1] ?? '', /^databreeze_csrf=[A-Za-z0-9_-]+; .*Secure; SameSite=Lax$/); assertResponseIdentifiers(response); }, ); @@ -295,3 +359,291 @@ void test('sign-in returns a session DTO and maps authentication failures withou assertProblem(response, 503, 'AUTHENTICATION_UNAVAILABLE'); }); }); + +void test('refresh rotates Web cookies without returning the refresh token and preserves native delivery', async () => { + const refreshed = { + sessionId: '00000000-0000-4000-8000-000000000020', + accessToken: 'next-access-token', + refreshToken: 'next-refresh-token', + accessExpiresAt: '2026-01-01T00:15:00.000Z', + }; + const presented: string[] = []; + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: (token, platform) => { + if (platform === 'web') presented.push(String(token)); + return Promise.resolve({ accepted: true as const, value: refreshed }); + }, + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(undefined), + }, + }, + async (app) => { + const web = await app.inject({ + method: 'POST', + url: '/v1/auth/refresh', + headers: { + cookie: `databreeze_refresh=current-refresh-token; databreeze_csrf=${csrfToken}`, + 'x-csrf-token': csrfToken, + origin: 'http://localhost:3000', + }, + payload: { clientPlatform: 'web' }, + }); + assert.equal(web.statusCode, 200); + assert.deepEqual(web.json(), { + sessionId: refreshed.sessionId, + accessToken: refreshed.accessToken, + accessExpiresAt: refreshed.accessExpiresAt, + }); + const webCookies = web.headers['set-cookie']; + assert.ok(Array.isArray(webCookies)); + assert.equal(webCookies.length, 2); + assert.match( + webCookies[0] ?? '', + /^databreeze_refresh=next-refresh-token; .*HttpOnly; Secure; SameSite=Lax$/, + ); + assert.match(webCookies[1] ?? '', /^databreeze_csrf=[A-Za-z0-9_-]+; .*Secure; SameSite=Lax$/); + + const native = await app.inject({ + method: 'POST', + url: '/v1/auth/refresh', + payload: { clientPlatform: 'desktop', refreshToken: 'desktop-refresh-token' }, + }); + assert.equal(native.statusCode, 200); + const nativeBody = parsedBody<{ readonly refreshToken?: unknown }>(native); + assert.equal(nativeBody['refreshToken'], refreshed.refreshToken); + assert.equal(native.headers['set-cookie'], undefined); + assert.deepEqual(presented, ['current-refresh-token']); + }, + ); + + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => + Promise.resolve({ accepted: false as const, code: 'REUSE_DETECTED' as const }), + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(undefined), + }, + }, + async (app) => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/refresh', + payload: { clientPlatform: 'desktop', refreshToken: 'reused-token' }, + }); + assertProblem(response, 401, 'SESSION_INVALID'); + assert.doesNotMatch(response.body, /REUSE_DETECTED/); + }, + ); +}); + +void test('sign-out revokes idempotently and clears browser credentials', async () => { + const revoked: string[] = []; + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: (sessionId) => { + revoked.push(String(sessionId)); + return Promise.resolve(false); + }, + findPrincipal: () => Promise.resolve(undefined), + }, + }, + async (app) => { + const web = await app.inject({ + method: 'POST', + url: '/v1/auth/sign-out', + headers: { + cookie: `databreeze_refresh=current-refresh-token; databreeze_csrf=${csrfToken}`, + 'x-csrf-token': csrfToken, + origin: 'http://localhost:3000', + }, + payload: { + clientPlatform: 'web', + sessionId: '00000000-0000-4000-8000-000000000010', + }, + }); + assert.equal(web.statusCode, 204); + assert.equal(web.body, ''); + const webCookies = web.headers['set-cookie']; + assert.ok(Array.isArray(webCookies)); + assert.match( + webCookies[0] ?? '', + /^databreeze_refresh=; Max-Age=0; .*HttpOnly; Secure; SameSite=Lax$/, + ); + assert.match(webCookies[1] ?? '', /^databreeze_csrf=; Max-Age=0; .*Secure; SameSite=Lax$/); + + const native = await app.inject({ + method: 'POST', + url: '/v1/auth/sign-out', + payload: { + clientPlatform: 'android', + sessionId: '00000000-0000-4000-8000-000000000011', + }, + }); + assert.equal(native.statusCode, 204); + assert.equal(native.headers['set-cookie'], undefined); + assert.deepEqual(revoked, [ + '00000000-0000-4000-8000-000000000010', + '00000000-0000-4000-8000-000000000011', + ]); + }, + ); +}); + +void test('protected artifact reads derive tenant scope from an authenticated access token', async () => { + const principal = { + userId: '00000000-0000-4000-8000-000000000001', + organizationId: '00000000-0000-4000-8000-000000000002', + workspaceId: '00000000-0000-4000-8000-000000000003', + securityEpoch: 3, + mfaRequired: false, + }; + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(principal), + findPrincipalByAccessToken: (token) => + Promise.resolve(token === 'access-token-for-context-1' ? principal : undefined), + }, + }, + async (app) => { + const unauthenticated = await app.inject({ method: 'GET', url: '/v1/artifacts/inbox' }); + assertProblem(unauthenticated, 401, 'AUTHENTICATION_FAILED'); + + const authenticated = await app.inject({ + method: 'GET', + url: '/v1/artifacts/inbox', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(authenticated.statusCode, 200); + assert.deepEqual(authenticated.json(), []); + + const currentSession = await app.inject({ + method: 'GET', + url: '/v1/auth/me', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(currentSession.statusCode, 200); + assert.deepEqual(currentSession.json(), { + userId: principal.userId, + organizationId: principal.organizationId, + workspaceId: principal.workspaceId, + authorizationEpoch: principal.securityEpoch, + mfaRequired: principal.mfaRequired, + }); + + const auditEvents = await app.inject({ + method: 'GET', + url: '/v1/audit/events', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(auditEvents.statusCode, 200); + assert.deepEqual(auditEvents.json(), []); + + const auditSeals = await app.inject({ + method: 'GET', + url: '/v1/audit/seals', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(auditSeals.statusCode, 200); + assert.deepEqual(auditSeals.json(), []); + + const usage = await app.inject({ + method: 'GET', + url: '/v1/entitlements/usage', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(usage.statusCode, 200); + assert.deepEqual(usage.json(), { entries: [], reservations: [] }); + + const missingSnapshot = await app.inject({ + method: 'GET', + url: '/v1/entitlements/snapshots/80000000-0000-4000-8000-000000000099', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(missingSnapshot.statusCode, 200); + assert.deepEqual(missingSnapshot.json(), { + accepted: false, + code: 'ENTITLEMENT_NOT_FOUND', + }); + + const invalidSnapshot = await app.inject({ + method: 'GET', + url: '/v1/entitlements/snapshots/not-an-id', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(invalidSnapshot.statusCode, 200); + assert.deepEqual(invalidSnapshot.json(), { + accepted: false, + code: 'INVALID_IDENTIFIER', + }); + }, + ); +}); + +void test('MFA HTTP lifecycle derives the user from the authenticated tenant context and returns redacted state', async () => { + const actorId = '00000000-0000-4000-8000-000000000001'; + const mfaService = new MfaService(new InMemoryMfaRepositoryAdapter(), { + matches: (presented, stored) => presented === stored, + }); + const contextResult = createIamTenantContextV1({ + tenantScope: { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000002', + workspaceId: '00000000-0000-4000-8000-000000000003', + }, + actorId, + correlationId: '00000000-0000-4000-8000-000000000004', + idempotencyKey: 'mfa-http-test', + authorizationEpoch: 1, + }); + assert.equal(contextResult.accepted, true); + if (!contextResult.accepted) return; + const requestTenantContext = { resolve: () => Promise.resolve(contextResult.value) }; + await withApp({ mfaService, requestTenantContext }, async (app) => { + const enrolled = await app.inject({ + method: 'POST', + url: '/v1/auth/mfa/factors', + payload: { + id: '00000000-0000-4000-8000-000000000010', + method: 'TOTP', + secretReference: 'vault://iam/mfa/test-factor', + enrolledAt: '2026-01-01T00:00:00.000Z', + }, + }); + assert.equal(enrolled.statusCode, 200); + const enrolledBody = parsedBody<{ + readonly factors: readonly [{ readonly status: string; readonly secretReference?: unknown }]; + }>(enrolled); + assert.equal(enrolledBody.factors[0].status, 'PENDING'); + assert.equal(enrolledBody.factors[0].secretReference, undefined); + + const verified = await app.inject({ + method: 'POST', + url: '/v1/auth/mfa/factors/00000000-0000-4000-8000-000000000010/verify', + payload: { at: '2026-01-01T00:01:00.000Z' }, + }); + assert.equal(verified.statusCode, 200); + const verifiedBody = parsedBody<{ readonly factors: readonly [{ readonly status: string }] }>( + verified, + ); + assert.equal(verifiedBody.factors[0].status, 'ACTIVE'); + + const invalid = await app.inject({ + method: 'POST', + url: '/v1/auth/mfa/factors/00000000-0000-4000-8000-000000000099/verify', + payload: { at: '2026-01-01T00:02:00.000Z' }, + }); + assertProblem(invalid, 400, 'MFA_REQUEST_REJECTED'); + }); +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 43beaa2c..7dedbec0 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -66,7 +66,15 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifacts/evidence-grants/{grantId}', '/v1/artifacts/inbox', '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', + '/v1/audit/events', + '/v1/audit/seals', + '/v1/auth/me', + '/v1/auth/mfa/factors', + '/v1/auth/mfa/factors/{factorId}/verify', + '/v1/auth/mfa/recovery/redeem', + '/v1/auth/refresh', '/v1/auth/sign-in', + '/v1/auth/sign-out', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', '/v1/datasets', @@ -90,6 +98,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/devices/{deviceId}/grants', '/v1/devices/{deviceId}/key', '/v1/devices/{deviceId}/revoke', + '/v1/entitlements/snapshots/{snapshotId}', + '/v1/entitlements/usage', '/v1/organizations/{organizationId}/devices', '/v1/reference-entities', '/v1/reference-entities/merge', diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts new file mode 100644 index 00000000..ebf3cd85 --- /dev/null +++ b/services/api/test/platform/http/csrf-protection.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { evaluateCsrfRequestV1 } from '../../../src/platform/http/csrf-protection.js'; + +const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; + +const allowedOrigins = ['https://app.databreeze.example']; + +void test('allows safe methods and non-cookie clients without a CSRF token', () => { + assert.deepEqual(evaluateCsrfRequestV1({ method: 'GET', headers: {} }, { allowedOrigins }), { + accepted: true, + }); + assert.deepEqual( + evaluateCsrfRequestV1( + { method: 'POST', headers: { authorization: 'Bearer access-token' } }, + { allowedOrigins }, + ), + { accepted: true }, + ); +}); + +void test('requires a valid double-submit token for cookie-authenticated mutations', () => { + const base = { + method: 'POST', + headers: { + cookie: 'databreeze_refresh=session-value', + origin: 'https://app.databreeze.example', + }, + } as const; + + assert.deepEqual(evaluateCsrfRequestV1(base, { allowedOrigins }), { + accepted: false, + code: 'CSRF_REQUIRED', + }); + assert.deepEqual( + evaluateCsrfRequestV1( + { + ...base, + headers: { + ...base.headers, + cookie: `databreeze_refresh=session-value; databreeze_csrf=${token}`, + 'x-csrf-token': `${token}x`, + }, + }, + { allowedOrigins }, + ), + { accepted: false, code: 'CSRF_INVALID' }, + ); + assert.deepEqual( + evaluateCsrfRequestV1( + { + ...base, + headers: { + ...base.headers, + cookie: `databreeze_refresh=session-value; databreeze_csrf=${token}`, + 'x-csrf-token': token, + }, + }, + { allowedOrigins }, + ), + { accepted: true }, + ); +}); + +void test('rejects hostile, ambiguous, or missing browser origin signals', () => { + const headers = { + cookie: `databreeze_refresh=session-value; databreeze_csrf=${token}`, + 'x-csrf-token': token, + }; + + assert.deepEqual( + evaluateCsrfRequestV1( + { method: 'POST', headers: { ...headers, origin: 'https://evil.example' } }, + { allowedOrigins }, + ), + { accepted: false, code: 'ORIGIN_INVALID' }, + ); + assert.deepEqual( + evaluateCsrfRequestV1( + { + method: 'POST', + headers: { ...headers, origin: ['https://app.databreeze.example', 'https://evil.example'] }, + }, + { allowedOrigins }, + ), + { accepted: false, code: 'ORIGIN_INVALID' }, + ); + assert.deepEqual(evaluateCsrfRequestV1({ method: 'POST', headers }, { allowedOrigins }), { + accepted: false, + code: 'ORIGIN_INVALID', + }); + assert.deepEqual( + evaluateCsrfRequestV1( + { + method: 'POST', + headers: { ...headers, 'sec-fetch-site': 'same-origin' }, + }, + { allowedOrigins }, + ), + { accepted: true }, + ); +}); + +void test('fails closed for duplicate cookies and duplicate token headers', () => { + assert.deepEqual( + evaluateCsrfRequestV1( + { + method: 'PATCH', + headers: { + cookie: `databreeze_refresh=session-value; databreeze_csrf=${token}; databreeze_csrf=${token}`, + origin: 'https://app.databreeze.example', + 'x-csrf-token': [token, token], + }, + }, + { allowedOrigins }, + ), + { accepted: false, code: 'CSRF_INVALID' }, + ); +}); diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts new file mode 100644 index 00000000..34b0a886 --- /dev/null +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { SessionRequestTenantContextAdapter } from '../../../src/platform/http/session-tenant-context.adapter.js'; + +const principal = { + userId: '00000000-0000-4000-8000-000000000001', + organizationId: '00000000-0000-4000-8000-000000000002', + workspaceId: '00000000-0000-4000-8000-000000000003', + securityEpoch: 7, + mfaRequired: false, +}; +const correlationId = '00000000-0000-4000-8000-000000000010'; + +void test('derives a workspace tenant context from a bearer session and never accepts client scope fields', async () => { + const seen: string[] = []; + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: (token) => { + seen.push(String(token)); + return Promise.resolve(principal); + }, + }); + + const context = await adapter.resolve({ + id: 'request-001', + headers: { + authorization: 'Bearer opaque-access-token-1', + 'idempotency-key': 'mutation-001', + 'x-correlation-id': correlationId, + }, + body: { + organizationId: '00000000-0000-4000-8000-000000000099', + workspaceId: '00000000-0000-4000-8000-000000000099', + }, + }); + + assert.deepEqual(seen, ['opaque-access-token-1']); + assert.deepEqual(context, { + tenantScope: { + scopeType: 'workspace', + organizationId: principal.organizationId, + workspaceId: principal.workspaceId, + }, + actorId: principal.userId, + correlationId, + idempotencyKey: 'mutation-001', + authorizationEpoch: principal.securityEpoch, + mfaRequired: principal.mfaRequired, + }); +}); + +void test('rejects missing, ambiguous, malformed, and unknown bearer credentials', async () => { + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: () => Promise.resolve(undefined), + }); + for (const request of [ + { headers: {} }, + { headers: { authorization: ['Bearer one', 'Bearer two'] } }, + { headers: { authorization: 'Basic credential' } }, + { headers: { authorization: 'Bearer' } }, + ]) { + await assert.rejects(adapter.resolve(request), (error: unknown) => { + assert.equal((error as { code?: unknown }).code, 'AUTHENTICATION_FAILED'); + return true; + }); + } +}); + +void test('uses the request id for read-only calls and rejects unsafe principal state', async () => { + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: () => Promise.resolve({ ...principal, securityEpoch: 0 }), + }); + await assert.rejects( + adapter.resolve({ id: 'request-read-001', headers: { authorization: 'Bearer token' } }), + (error: unknown) => { + assert.equal((error as { code?: unknown }).code, 'AUTHENTICATION_FAILED'); + return true; + }, + ); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index cccfcf59..43e8de30 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -74,6 +74,8 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dso"\."device_sync_conflicts"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."strict_local_package_manifests"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."authorization_snapshots"/); + assert.match(diff.stdout, /CREATE TABLE "iam"\."mfa_recovery_codes"/); + assert.match(diff.stdout, /CREATE TABLE "iam"\."access_tokens"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."device_enrollment_challenges"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."device_grants"/); @@ -102,6 +104,8 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802180000_iam_device_enrollment', '20260802190000_dso_capabilities_grants', '20260802200000_dso_data_mode_policies', + '20260802210000_iam_mfa_recovery', + '20260802220000_iam_access_tokens', 'migration_lock.toml', ]); const migration = await readFile( @@ -381,4 +385,17 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const accessTokenMigration = await readFile( + path.join(migrationsDirectory, inventory[23], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE TABLE "iam"."access_tokens"', + 'CREATE UNIQUE INDEX "access_tokens_digest_key"', + ]) { + assert.match( + accessTokenMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); diff --git a/tools/repo-cli/src/check-execution-orchestration.mjs b/tools/repo-cli/src/check-execution-orchestration.mjs index 5229f4e7..6ee2c595 100644 --- a/tools/repo-cli/src/check-execution-orchestration.mjs +++ b/tools/repo-cli/src/check-execution-orchestration.mjs @@ -44,6 +44,16 @@ const requiredRunbookHeadings = [ '## End-of-session handoff record', '## Luna bootstrap prompt', ]; +const requiredExecutionPlanHeadings = [ + '## 1. Verified starting checkpoint', + '## 3. Delivery-batch map', + '## 4. Parallel execution and integration ownership', + '## 5. Atomic task recipe', + '## 6. PR and promotion algorithm', + '## 7. First Luna Max session', + '## 8. Completion and stop rules', +]; +const batchChangedFileMaximum = 260; const traceStatuses = new Set(['planned', 'partial', 'implemented', 'verified', 'released']); function parseOptions(argumentsList) { @@ -121,6 +131,145 @@ function validateDag(plans, diagnostics) { for (const planId of byId.keys()) visit(planId); } +function validateDeliveryBatches({ ledger, plans, taskIds, taskToPlan, diagnostics }) { + const batches = Array.isArray(ledger.deliveryBatches) ? ledger.deliveryBatches : []; + const byId = new Map(); + const batchByTask = new Map(); + + for (const batch of batches) { + if (typeof batch.batchId !== 'string' || batch.batchId.trim() === '') { + diagnostics.push('delivery batch has no batchId'); + continue; + } + if (byId.has(batch.batchId)) diagnostics.push(`duplicate delivery batch ${batch.batchId}`); + byId.set(batch.batchId, batch); + if (!/^feat\/[a-z0-9-]+$|^fix\/[a-z0-9-]+$/u.test(batch.branch ?? '')) { + diagnostics.push(`batch ${batch.batchId} has invalid branch ${batch.branch}`); + } + if (!ledger.statusVocabulary?.includes(batch.status)) { + diagnostics.push(`batch ${batch.batchId} has unsupported status ${batch.status}`); + } + const budget = batch.commitBudget ?? {}; + if (!Number.isInteger(budget.minimum) || budget.minimum < 30) { + diagnostics.push(`batch ${batch.batchId} commit minimum must be at least 30`); + } + if ( + !Number.isInteger(budget.target) || + budget.target < budget.minimum || + budget.target > budget.maximum + ) { + diagnostics.push(`batch ${batch.batchId} commit target is outside its budget`); + } + if (!Number.isInteger(budget.maximum) || budget.maximum >= 100) { + diagnostics.push(`batch ${batch.batchId} commit maximum must remain below 100`); + } + if ( + !Number.isInteger(batch.maximumChangedFiles) || + batch.maximumChangedFiles < 1 || + batch.maximumChangedFiles > batchChangedFileMaximum + ) { + diagnostics.push( + `batch ${batch.batchId} changed-file maximum must be between 1 and ${batchChangedFileMaximum}`, + ); + } + if (!Array.isArray(batch.taskIds) || batch.taskIds.length === 0) { + diagnostics.push(`batch ${batch.batchId} has no tasks`); + continue; + } + for (const taskId of batch.taskIds) { + if (!taskIds.has(taskId)) + diagnostics.push(`batch ${batch.batchId} has unknown task ${taskId}`); + if (batchByTask.has(taskId)) { + diagnostics.push( + `task ${taskId} is assigned to both ${batchByTask.get(taskId)} and ${batch.batchId}`, + ); + } + batchByTask.set(taskId, batch.batchId); + } + } + + for (const batch of batches) { + for (const dependency of batch.dependencies ?? []) { + if (!byId.has(dependency)) { + diagnostics.push(`batch ${batch.batchId} has unknown dependency ${dependency}`); + } + } + } + + const active = new Set(); + const complete = new Set(); + function visit(batchId) { + if (complete.has(batchId)) return; + if (active.has(batchId)) { + diagnostics.push(`delivery batch dependency cycle includes ${batchId}`); + return; + } + active.add(batchId); + for (const dependency of byId.get(batchId)?.dependencies ?? []) visit(dependency); + active.delete(batchId); + complete.add(batchId); + } + for (const batchId of byId.keys()) visit(batchId); + + const verifiedTasks = new Set( + Object.entries(ledger.taskState ?? {}) + .filter(([, state]) => ['verified', 'released'].includes(state?.status)) + .map(([taskId]) => taskId), + ); + for (const taskId of taskIds) { + if (verifiedTasks.has(taskId)) { + if (batchByTask.has(taskId)) diagnostics.push(`verified task ${taskId} remains batched`); + } else if (!batchByTask.has(taskId)) { + diagnostics.push(`unfinished task ${taskId} has no delivery batch`); + } + } + + if (!byId.has(ledger.activeBatchId)) { + diagnostics.push(`activeBatchId ${ledger.activeBatchId} is not a delivery batch`); + } else if (!byId.get(ledger.activeBatchId).taskIds.includes(ledger.nextTaskId)) { + diagnostics.push( + `active batch ${ledger.activeBatchId} does not contain nextTaskId ${ledger.nextTaskId}`, + ); + } + + function dependsOn(batchId, expectedDependency, seen = new Set()) { + if (batchId === expectedDependency) return true; + if (seen.has(batchId)) return false; + seen.add(batchId); + return (byId.get(batchId)?.dependencies ?? []).some((dependency) => + dependsOn(dependency, expectedDependency, seen), + ); + } + + const planById = new Map(plans.map((plan) => [plan.planId, plan])); + for (const [taskId, planId] of taskToPlan) { + const consumerBatchId = batchByTask.get(taskId); + if (consumerBatchId === undefined) continue; + const consumerBatch = byId.get(consumerBatchId); + for (const dependencyPlanId of planById.get(planId)?.dependencies ?? []) { + const dependencyTaskIds = planById.get(dependencyPlanId)?.taskIds ?? []; + for (const dependencyTaskId of dependencyTaskIds) { + if (verifiedTasks.has(dependencyTaskId)) continue; + const producerBatchId = batchByTask.get(dependencyTaskId); + if (producerBatchId === undefined) continue; + if (producerBatchId === consumerBatchId) { + if ( + consumerBatch.taskIds.indexOf(dependencyTaskId) > consumerBatch.taskIds.indexOf(taskId) + ) { + diagnostics.push( + `batch ${consumerBatchId} orders dependent task ${taskId} before ${dependencyTaskId}`, + ); + } + } else if (!dependsOn(consumerBatchId, producerBatchId)) { + diagnostics.push( + `batch ${consumerBatchId} containing ${taskId} does not depend on ${producerBatchId} containing ${dependencyTaskId}`, + ); + } + } + } + } +} + function run(argumentsList) { const { root } = parseOptions(argumentsList); const plansDirectory = path.join(root, 'docs', 'plans'); @@ -128,7 +277,14 @@ function run(argumentsList) { const traceabilityPath = path.join(plansDirectory, 'requirement-traceability.json'); const orchestrationPath = path.join(plansDirectory, '002-complete-execution-orchestration.md'); const runbookPath = path.join(plansDirectory, '003-luna-handoff-runbook.md'); - const requiredFiles = [ledgerPath, traceabilityPath, orchestrationPath, runbookPath]; + const executionPlanPath = path.join(plansDirectory, '004-luna-max-execution-plan.md'); + const requiredFiles = [ + ledgerPath, + traceabilityPath, + orchestrationPath, + runbookPath, + executionPlanPath, + ]; const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath)); if (missingFiles.length > 0) { throw new Error(`Missing orchestration files:\n${missingFiles.join('\n')}`); @@ -138,9 +294,13 @@ function run(argumentsList) { const traceability = readJson(traceabilityPath); const orchestration = readFileSync(orchestrationPath, 'utf8'); const runbook = readFileSync(runbookPath, 'utf8'); + const executionPlan = readFileSync(executionPlanPath, 'utf8'); const diagnostics = []; - if (ledger.version !== 1) diagnostics.push(`unsupported ledger version ${ledger.version}`); + if (ledger.version !== 2) diagnostics.push(`unsupported ledger version ${ledger.version}`); + if (ledger.authority?.deliveryBatches !== 'docs/plans/004-luna-max-execution-plan.md') { + diagnostics.push('delivery-batch authority must point to the Luna Max execution plan'); + } if (!sameJson(ledger.reviewPolicy, expectedReviewPolicy)) { diagnostics.push('reviewPolicy does not preserve the approved dev/main/CodeRabbit flow'); } @@ -210,6 +370,7 @@ function run(argumentsList) { const planIds = new Set(); const planFiles = new Set(); const taskIds = new Set(); + const taskToPlan = new Map(); for (const plan of plans) { if (planIds.has(plan.planId)) diagnostics.push(`duplicate plan ${plan.planId}`); planIds.add(plan.planId); @@ -246,6 +407,7 @@ function run(argumentsList) { for (const taskId of plan.taskIds) { if (taskIds.has(taskId)) diagnostics.push(`duplicate orchestration task ${taskId}`); taskIds.add(taskId); + taskToPlan.set(taskId, plan.planId); const heading = new RegExp(`^#### ${escapeRegExp(taskId)} —`, 'mu'); if (!heading.test(orchestration)) { diagnostics.push(`orchestration heading is missing for task ${taskId}`); @@ -285,10 +447,24 @@ function run(argumentsList) { } } } + validateDeliveryBatches({ ledger, plans, taskIds, taskToPlan, diagnostics }); for (const heading of requiredRunbookHeadings) { if (!runbook.split(/\r?\n/u).includes(heading)) diagnostics.push(`runbook heading missing: ${heading}`); } + for (const heading of requiredExecutionPlanHeadings) { + if (!executionPlan.split(/\r?\n/u).includes(heading)) { + diagnostics.push(`Luna Max execution plan heading missing: ${heading}`); + } + } + for (const batch of ledger.deliveryBatches ?? []) { + const documentedRow = `| \`${batch.batchId}\` | \`${batch.branch}\` |`; + if (!executionPlan.includes(documentedRow)) { + diagnostics.push( + `Luna Max execution plan does not document ${batch.batchId} on ${batch.branch}`, + ); + } + } if (diagnostics.length > 0) { process.stderr.write(`${diagnostics.sort().join('\n')}\n`); @@ -298,6 +474,8 @@ function run(argumentsList) { process.stdout.write( `${JSON.stringify({ nextTaskId: ledger.nextTaskId, + activeBatchId: ledger.activeBatchId, + batchCount: ledger.deliveryBatches.length, planCount: plans.length, requirementCount: requirements.length, taskCount: taskIds.size, diff --git a/tools/repo-cli/test/execution-orchestration.test.mjs b/tools/repo-cli/test/execution-orchestration.test.mjs index 5497c2e3..ab338bd8 100644 --- a/tools/repo-cli/test/execution-orchestration.test.mjs +++ b/tools/repo-cli/test/execution-orchestration.test.mjs @@ -95,7 +95,7 @@ test('execution ledger covers the complete dependency-ordered implementation pro 'utf8', ); - assert.equal(ledger.version, 1); + assert.equal(ledger.version, 2); assert.deepEqual( ledger.plans.map((plan) => plan.planId), [...expectedPlans.keys()], @@ -147,6 +147,35 @@ test('handoff policy preserves the requested dev and main review flow', () => { }); }); +test('delivery batches cover every unfinished task once within review budgets', () => { + const ledger = readJson('docs/plans/execution-orchestration.json'); + const allTasks = new Set(ledger.plans.flatMap((plan) => plan.taskIds)); + const verifiedTasks = new Set( + Object.entries(ledger.taskState) + .filter(([, state]) => ['verified', 'released'].includes(state.status)) + .map(([taskId]) => taskId), + ); + const batchedTasks = ledger.deliveryBatches.flatMap((batch) => batch.taskIds); + + assert.equal(ledger.deliveryBatches.length, 15); + assert.equal(new Set(batchedTasks).size, batchedTasks.length); + assert.deepEqual( + new Set(batchedTasks), + new Set([...allTasks].filter((taskId) => !verifiedTasks.has(taskId))), + ); + for (const batch of ledger.deliveryBatches) { + assert.ok(batch.commitBudget.minimum >= 30); + assert.ok(batch.commitBudget.target >= batch.commitBudget.minimum); + assert.ok(batch.commitBudget.target <= batch.commitBudget.maximum); + assert.ok(batch.commitBudget.maximum < 100); + assert.ok(batch.maximumChangedFiles <= 260); + } + const activeBatch = ledger.deliveryBatches.find( + (batch) => batch.batchId === ledger.activeBatchId, + ); + assert.ok(activeBatch.taskIds.includes(ledger.nextTaskId)); +}); + test('the handoff runbook contains deterministic resume and failure protocols', () => { const runbook = readFileSync( path.join(repositoryRoot, 'docs', 'plans', '003-luna-handoff-runbook.md'), @@ -177,8 +206,12 @@ test('repository checker validates the committed orchestration package', () => { test('ledger records verified task evidence before advancing the next task', () => { const ledger = readJson('docs/plans/execution-orchestration.json'); assert.equal(ledger.nextTaskId, 'FND-003'); - assert.equal(ledger.checkpoint.lastFeaturePullRequest, 18); - assert.equal(ledger.checkpoint.lastPromotionPullRequest, 14); + assert.equal(ledger.activeBatchId, 'B01'); + assert.equal(ledger.checkpoint.remoteDev, '783a4710c0aa2a2808d78ad7f0643e6731150bd7'); + assert.equal(ledger.checkpoint.remoteMain, '3ed3d77d0281ef239d0509c81ded447d8fffd213'); + assert.equal(ledger.checkpoint.lastFeaturePullRequest, 19); + assert.equal(ledger.checkpoint.lastPromotionPullRequest, 20); + assert.equal(ledger.checkpoint.lastPromotionFixPullRequest, 23); assert.deepEqual(ledger.taskState?.['FND-001']?.status, 'verified'); assert.match(ledger.taskState?.['FND-001']?.commit ?? '', /^[0-9a-f]{40}$/u); assert.ok( @@ -250,3 +283,39 @@ test('repository checker rejects task evidence paths that escape the repository }, ); }); + +test('repository checker rejects duplicate delivery-task ownership', () => { + withTemporaryPlans( + ({ ledger }) => { + ledger.deliveryBatches[1].taskIds.push(ledger.deliveryBatches[0].taskIds[0]); + }, + (result) => { + assert.notEqual(result.status, 0); + assert.match(result.stderr, /is assigned to both/u); + }, + ); +}); + +test('repository checker rejects undersized normal delivery batches', () => { + withTemporaryPlans( + ({ ledger }) => { + ledger.deliveryBatches[0].commitBudget.minimum = 29; + }, + (result) => { + assert.notEqual(result.status, 0); + assert.match(result.stderr, /commit minimum must be at least 30/u); + }, + ); +}); + +test('repository checker rejects delivery-batch drift from the Luna plan', () => { + withTemporaryPlans( + ({ ledger }) => { + ledger.deliveryBatches[0].branch = 'feat/undocumented-batch'; + }, + (result) => { + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Luna Max execution plan does not document B01/u); + }, + ); +});