From 62e10cddeda3cd6d49a3c78795a420b7c58331fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:15:42 +0700 Subject: [PATCH 001/177] docs(plans): orchestrate Luna Max delivery batches --- docs/plans/000-platform-program.md | 2 + .../002-complete-execution-orchestration.md | 15 +- docs/plans/003-luna-handoff-runbook.md | 16 +- docs/plans/004-luna-max-execution-plan.md | 159 +++++++++++ docs/plans/README.md | 9 +- docs/plans/execution-orchestration.json | 254 +++++++++++++++++- .../src/check-execution-orchestration.mjs | 182 ++++++++++++- .../test/execution-orchestration.test.mjs | 75 +++++- 8 files changed, 681 insertions(+), 31 deletions(-) create mode 100644 docs/plans/004-luna-max-execution-plan.md 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/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); + }, + ); +}); From eeb08539152d063b6fe3abfed6a82ac5fae69895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:17:17 +0700 Subject: [PATCH 002/177] docs(operations): record Luna Max handoff --- .../operations/luna-max-handoff-2026-08-02.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/operations/luna-max-handoff-2026-08-02.md 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. From 1ebdc1e6405b207f16d653165ca1d177a2ffca23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:30:00 +0700 Subject: [PATCH 003/177] feat(api): compose audit and entitlement foundations --- services/api/src/app.module.ts | 8 ++++- .../adapter/sha256-audit-digest.adapter.ts | 10 ++++++ services/api/src/features/aud/aud.module.ts | 31 +++++++++++++++++++ services/api/src/features/bua/bua.module.ts | 30 ++++++++++++++++++ .../foundation-module-composition.test.ts | 21 +++++++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/aud/adapter/sha256-audit-digest.adapter.ts create mode 100644 services/api/src/features/aud/aud.module.ts create mode 100644 services/api/src/features/bua/bua.module.ts create mode 100644 services/api/test/features/foundation-module-composition.test.ts diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index 994350d6..5781d5ad 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -5,12 +5,16 @@ 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'; export type AppModuleOptions = SystemModuleOptions & IamModuleOptions & IaeModuleOptions & DsmModuleOptions & - DsoModuleOptions; + DsoModuleOptions & + AudModuleOptions & + BuaModuleOptions; @Module({}) export class AppModule { @@ -23,6 +27,8 @@ export class AppModule { IaeModule.register(options), DsmModule.register(options), DsoModule.register(options), + AudModule.register(options), + BuaModule.register(options), ], }; } 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/aud.module.ts b/services/api/src/features/aud/aud.module.ts new file mode 100644 index 00000000..8b1da86a --- /dev/null +++ b/services/api/src/features/aud/aud.module.ts @@ -0,0 +1,31 @@ +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 { Sha256AuditDigestAdapter } from './adapter/sha256-audit-digest.adapter.js'; + +export const AUDIT_LEDGER_SERVICE = Symbol('AUDIT_LEDGER_SERVICE'); + +export interface AudModuleOptions { + readonly auditRepository?: AuditRepositoryPortV1; +} + +@Module({}) +export class AudModule { + public static register(options: AudModuleOptions = {}): DynamicModule { + const repository = options.auditRepository ?? new InMemoryAuditRepositoryAdapter(); + const service = new AuditLedgerService(repository, new Sha256AuditDigestAdapter()); + return { + module: AudModule, + providers: [ + { provide: AUDIT_REPOSITORY_PORT, useValue: repository }, + { provide: AUDIT_LEDGER_SERVICE, useValue: service }, + ], + exports: [AUDIT_REPOSITORY_PORT, AUDIT_LEDGER_SERVICE], + }; + } +} 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..1f1d896e --- /dev/null +++ b/services/api/src/features/bua/bua.module.ts @@ -0,0 +1,30 @@ +import { type DynamicModule, Module } from '@nestjs/common'; + +import { InMemoryEntitlementRepositoryAdapter } from './adapter/in-memory-entitlement-repository.adapter.js'; +import { EntitlementAdmissionService } from './application/entitlement-admission.service.js'; +import { + ENTITLEMENT_REPOSITORY_PORT, + type EntitlementRepositoryPortV1, +} from './application/entitlement-repository.port.js'; + +export const ENTITLEMENT_ADMISSION_SERVICE = Symbol('ENTITLEMENT_ADMISSION_SERVICE'); + +export interface BuaModuleOptions { + readonly entitlementRepository?: EntitlementRepositoryPortV1; +} + +@Module({}) +export class BuaModule { + public static register(options: BuaModuleOptions = {}): DynamicModule { + const repository = options.entitlementRepository ?? new InMemoryEntitlementRepositoryAdapter(); + const service = new EntitlementAdmissionService(repository); + return { + module: BuaModule, + providers: [ + { provide: ENTITLEMENT_REPOSITORY_PORT, useValue: repository }, + { provide: ENTITLEMENT_ADMISSION_SERVICE, useValue: service }, + ], + exports: [ENTITLEMENT_REPOSITORY_PORT, ENTITLEMENT_ADMISSION_SERVICE], + }; + } +} 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..a0c07bb4 --- /dev/null +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { AppModule } from '../../src/app.module.js'; +import { AudModule } from '../../src/features/aud/aud.module.js'; +import { BuaModule } from '../../src/features/bua/bua.module.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)); +}); From db436ce75936643835148a24004819873b95fff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:36:00 +0700 Subject: [PATCH 004/177] feat(aud): add tenant-scoped Prisma audit persistence --- .../prisma-audit-repository.adapter.ts | 382 ++++++++++++++++++ services/api/src/features/aud/aud.module.ts | 15 +- .../aud/prisma-audit-repository.test.ts | 168 ++++++++ .../foundation-module-composition.test.ts | 17 + 4 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts create mode 100644 services/api/test/features/aud/prisma-audit-repository.test.ts 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..37b23f33 --- /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 AuditSummaryV1, + 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/aud.module.ts b/services/api/src/features/aud/aud.module.ts index 8b1da86a..cb4ee6d6 100644 --- a/services/api/src/features/aud/aud.module.ts +++ b/services/api/src/features/aud/aud.module.ts @@ -6,19 +6,30 @@ import { 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'; 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; } @Module({}) export class AudModule { public static register(options: AudModuleOptions = {}): DynamicModule { - const repository = options.auditRepository ?? new InMemoryAuditRepositoryAdapter(); - const service = new AuditLedgerService(repository, new Sha256AuditDigestAdapter()); + 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, providers: [ 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/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index a0c07bb4..ca00ad80 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -3,6 +3,8 @@ import test from 'node:test'; import { AppModule } from '../../src/app.module.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'; function moduleTypes(): readonly unknown[] { @@ -19,3 +21,18 @@ void test('[IAM-001, AUD-001, BUA-001] application composition includes identity 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); +}); From ff6972490dc442396e81d15ff38f7c9439e61fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:41:33 +0700 Subject: [PATCH 005/177] feat(bua): add durable entitlement and usage persistence --- .../prisma-entitlement-repository.adapter.ts | 587 ++++++++++++++++++ services/api/src/features/bua/bua.module.ts | 12 +- .../bua/prisma-entitlement-repository.test.ts | 207 ++++++ .../foundation-module-composition.test.ts | 17 + 4 files changed, 822 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts create mode 100644 services/api/test/features/bua/prisma-entitlement-repository.test.ts 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..2af9dfcd --- /dev/null +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -0,0 +1,587 @@ +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/bua.module.ts b/services/api/src/features/bua/bua.module.ts index 1f1d896e..643667cd 100644 --- a/services/api/src/features/bua/bua.module.ts +++ b/services/api/src/features/bua/bua.module.ts @@ -1,6 +1,10 @@ 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, @@ -11,12 +15,18 @@ export const ENTITLEMENT_ADMISSION_SERVICE = Symbol('ENTITLEMENT_ADMISSION_SERVI export interface BuaModuleOptions { readonly entitlementRepository?: EntitlementRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly entitlementDatabase?: EntitlementDatabaseClientV1; } @Module({}) export class BuaModule { public static register(options: BuaModuleOptions = {}): DynamicModule { - const repository = options.entitlementRepository ?? new InMemoryEntitlementRepositoryAdapter(); + const repository = + options.entitlementRepository ?? + (options.entitlementDatabase === undefined + ? new InMemoryEntitlementRepositoryAdapter() + : new PrismaEntitlementRepositoryAdapter(options.entitlementDatabase)); const service = new EntitlementAdmissionService(repository); return { module: BuaModule, 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..122cb3ea --- /dev/null +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -0,0 +1,207 @@ +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 index ca00ad80..34c8e59e 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -6,6 +6,8 @@ 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'; function moduleTypes(): readonly unknown[] { const registered = AppModule.register(); @@ -36,3 +38,18 @@ void test('[AUD-001] configured audit persistence uses the Prisma adapter instea if (!provider || !('useValue' in provider)) return; assert.ok(provider.useValue instanceof PrismaAuditRepositoryAdapter); }); + +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); +}); From 0f46d1662f19a557a181a69eaff158e584424909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:45:45 +0700 Subject: [PATCH 006/177] feat(iam): add versioned PKCE boundary --- packages/domain/package.json | 4 ++ packages/domain/src/pkce/v1.ts | 73 +++++++++++++++++++++ packages/domain/src/v1.ts | 1 + packages/domain/test/pkce-v1.test.mjs | 40 +++++++++++ packages/domain/test/public-api-v1.test.mjs | 2 + 5 files changed, 120 insertions(+) create mode 100644 packages/domain/src/pkce/v1.ts create mode 100644 packages/domain/test/pkce-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index ea09ca1c..65fcca7d 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -52,6 +52,10 @@ "types": "./src/data-mode/v1.ts", "import": "./dist/data-mode/v1.js" }, + "./pkce/v1": { + "types": "./src/pkce/v1.ts", + "import": "./dist/pkce/v1.js" + }, "./artifact/v1": { "types": "./src/artifact/v1.ts", "import": "./dist/artifact/v1.js" diff --git a/packages/domain/src/pkce/v1.ts b/packages/domain/src/pkce/v1.ts new file mode 100644 index 00000000..a274a316 --- /dev/null +++ b/packages/domain/src/pkce/v1.ts @@ -0,0 +1,73 @@ +/** 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..f6164c55 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -23,5 +23,6 @@ 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 './permissions/v1.js'; export * from './tenant-scope/v1.js'; 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..c1eb95e6 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -21,6 +21,7 @@ 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', './artifact/v1', './artifact-intake/v1', './artifact-governance/v1', @@ -56,6 +57,7 @@ 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.DEVICE_AUTHORIZATION_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_SCHEMA_VERSION_V1, 1); From 012413913e2e1398e441d4d2f344e18a8de5bcee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:46:53 +0700 Subject: [PATCH 007/177] feat(iam): add versioned CSRF boundary --- packages/domain/package.json | 4 ++ packages/domain/src/csrf/v1.ts | 43 +++++++++++++++++++++ packages/domain/src/v1.ts | 1 + packages/domain/test/csrf-v1.test.mjs | 34 ++++++++++++++++ packages/domain/test/public-api-v1.test.mjs | 2 + 5 files changed, 84 insertions(+) create mode 100644 packages/domain/src/csrf/v1.ts create mode 100644 packages/domain/test/csrf-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index 65fcca7d..b2fe790f 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -56,6 +56,10 @@ "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/v1.ts b/packages/domain/src/v1.ts index f6164c55..4f5b9a48 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -24,5 +24,6 @@ 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/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index c1eb95e6..f46407dc 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -22,6 +22,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './device-capability/v1', './data-mode/v1', './pkce/v1', + './csrf/v1', './artifact/v1', './artifact-intake/v1', './artifact-governance/v1', @@ -58,6 +59,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o 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); From 46c2ee529b1b7f77c723b78e63ae35fe74155436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:52:11 +0700 Subject: [PATCH 008/177] feat(iam): add durable credential lookup --- .../prisma-credential-lookup.adapter.ts | 168 ++++++++++++++++++ .../iam/application/authentication.port.ts | 1 + services/api/src/features/iam/iam.module.ts | 26 ++- .../foundation-module-composition.test.ts | 29 +++ .../iam/prisma-credential-lookup.test.ts | 115 ++++++++++++ 5 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts create mode 100644 services/api/test/features/iam/prisma-credential-lookup.test.ts 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..582411fb --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts @@ -0,0 +1,168 @@ +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 ListDelegateV1 { + findMany(input: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }): Promise; +} + +export interface CredentialLookupDatabaseClientV1 { + readonly userIdentity: UniqueDelegateV1; + readonly passwordCredential: UniqueDelegateV1; + readonly membershipIdentity: ListDelegateV1; + readonly workspaceIdentity: UniqueDelegateV1; + 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' || row.scopeType !== 'WORKSPACE') + return undefined; + if (row.projectId !== null) return undefined; + const organizationId = stableId(row.organizationId); + const workspaceId = stableId(row.workspaceId); + if (!organizationId || !workspaceId) return undefined; + return { organizationId, workspaceId }; +} + +/** + * 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, workspace, factors] = await Promise.all([ + this.client.organizationIdentity.findUnique({ where: { id: selected.organizationId } }), + this.client.workspaceIdentity.findUnique({ where: { id: selected.workspaceId } }), + this.client.mfaFactor.findMany({ where: { userId, status: 'ACTIVE' } }), + ]); + if ( + !organization || + organization.id !== selected.organizationId || + organization.status !== 'ACTIVE' || + !workspace || + workspace.id !== selected.workspaceId || + workspace.organizationId !== selected.organizationId || + workspace.status !== 'ACTIVE' + ) + return undefined; + + return Object.freeze({ + principal: Object.freeze({ + userId, + organizationId: selected.organizationId, + workspaceId: selected.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/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/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 3d389873..aff6d2f4 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -4,12 +4,17 @@ import { AuthenticationController } from './api/authentication.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 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 { DeviceIdentityController } from './api/device-identity.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { @@ -35,6 +40,7 @@ import { export interface IamModuleOptions { readonly authentication?: AuthenticationUseCaseV1; readonly credentials?: CredentialLookupPortV1; + readonly credentialDatabase?: CredentialLookupDatabaseClientV1; readonly passwordCredentials?: PasswordCredentialService; readonly sessions?: SessionIssuerPortV1; readonly deviceIdentityService?: DeviceIdentityService; @@ -59,6 +65,16 @@ 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 authentication = + options.authentication ?? + (credentials + ? composeAuthenticationUseCase({ ...options, credentials }) + : composeAuthenticationUseCase(options)); const deviceIdentityRepository = options.deviceIdentityRepository ?? (options.deviceIdentityDatabase === undefined @@ -76,8 +92,16 @@ export class IamModule { providers: [ { provide: AUTHENTICATION_USE_CASE, - useValue: composeAuthenticationUseCase(options), + useValue: authentication, }, + ...(credentials + ? [ + { + provide: CREDENTIAL_LOOKUP_PORT, + useValue: credentials, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 34c8e59e..39cf901a 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -2,6 +2,12 @@ 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 { 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'; @@ -39,6 +45,29 @@ void test('[AUD-001] configured audit persistence uses the Prisma adapter instea 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('[BUA-001] configured entitlement persistence uses the Prisma adapter instead of the local fallback', () => { const database = {} as never; const registered = BuaModule.register({ entitlementDatabase: database }); 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..42221b1d --- /dev/null +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -0,0 +1,115 @@ +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' }), + }, + 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); +}); From 881b5d5f18e83cb1656413f19ea023b63a42221b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:53:53 +0700 Subject: [PATCH 009/177] refactor(iam): make session principal lookup async --- .../iam/adapter/in-memory-session-lifecycle.adapter.ts | 3 ++- .../src/features/iam/application/session-lifecycle.port.ts | 2 +- services/api/test/features/iam/session-lifecycle.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) 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..8364aac0 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 @@ -180,7 +180,8 @@ 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; 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..ac6ac2cb 100644 --- a/services/api/src/features/iam/application/session-lifecycle.port.ts +++ b/services/api/src/features/iam/application/session-lifecycle.port.ts @@ -28,5 +28,5 @@ export interface SessionLifecyclePortV1 extends SessionIssuerPortV1 { clientPlatform: 'android' | 'desktop' | 'web', ): Promise; revoke(sessionId: unknown): Promise; - findPrincipal(sessionId: unknown): AuthenticatedPrincipalV1 | undefined; + findPrincipal(sessionId: unknown): Promise; } 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', From a10e443906faf37deb412baaea2bc233e3cde6ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:02:30 +0700 Subject: [PATCH 010/177] feat(iam): add durable session lifecycle --- .../prisma-session-lifecycle.adapter.ts | 450 ++++++++++++++++++ services/api/src/features/iam/iam.module.ts | 28 +- .../foundation-module-composition.test.ts | 17 + .../iam/prisma-session-lifecycle.test.ts | 174 +++++++ 4 files changed, 666 insertions(+), 3 deletions(-) create mode 100644 services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts create mode 100644 services/api/test/features/iam/prisma-session-lifecycle.test.ts 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..1adc7607 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -0,0 +1,450 @@ +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 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 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 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 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, + }, + }); + }); + return { + sessionId: record.sessionId, + accessToken: tokenFor(stableIdentifier(randomUUID())), + 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 }, + }); + } 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); + 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, + }, + }); + return successfulSession({ + sessionId: session.sessionId, + accessToken: tokenFor(stableIdentifier(randomUUID())), + 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' }, + }); + return true; + }); + } + + 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/iam.module.ts b/services/api/src/features/iam/iam.module.ts index aff6d2f4..be331051 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -9,12 +9,17 @@ import { type AuthenticationUseCaseV1, type SessionIssuerPortV1, } from './application/authentication.port.js'; +import { SESSION_LIFECYCLE_PORT } from './application/session-lifecycle.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 { DeviceIdentityController } from './api/device-identity.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { @@ -43,6 +48,7 @@ export interface IamModuleOptions { readonly credentialDatabase?: CredentialLookupDatabaseClientV1; readonly passwordCredentials?: PasswordCredentialService; readonly sessions?: SessionIssuerPortV1; + readonly sessionDatabase?: SessionLifecycleDatabaseClientV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -70,10 +76,15 @@ export class IamModule { (options.credentialDatabase === undefined ? undefined : new PrismaCredentialLookupAdapter(options.credentialDatabase)); + const sessions = + options.sessions ?? + (options.sessionDatabase === undefined + ? undefined + : new PrismaSessionLifecycleAdapter(options.sessionDatabase)); const authentication = options.authentication ?? - (credentials - ? composeAuthenticationUseCase({ ...options, credentials }) + (credentials && sessions + ? composeAuthenticationUseCase({ ...options, credentials, sessions }) : composeAuthenticationUseCase(options)); const deviceIdentityRepository = options.deviceIdentityRepository ?? @@ -86,6 +97,9 @@ 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); return { module: IamModule, controllers: [AuthenticationController, DeviceIdentityController], @@ -102,6 +116,14 @@ export class IamModule { }, ] : []), + ...(sessions + ? [ + { + provide: SESSION_LIFECYCLE_PORT, + useValue: sessions, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, @@ -115,7 +137,7 @@ export class IamModule { useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), }, ], - exports: [DEVICE_IDENTITY_REPOSITORY_PORT, DEVICE_IDENTITY_SERVICE], + exports, }; } } diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 39cf901a..f10f19e8 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -8,6 +8,8 @@ import { 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 { 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'; @@ -68,6 +70,21 @@ void test('[IAM-001] configured credential persistence uses the Prisma adapter b 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('[BUA-001] configured entitlement persistence uses the Prisma adapter instead of the local fallback', () => { const database = {} as never; const registered = BuaModule.register({ entitlementDatabase: database }); 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..0e5294e4 --- /dev/null +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + PrismaSessionLifecycleAdapter, + type SessionLifecycleDatabaseClientV1, + type SessionRecordDatabaseRowV1, + type RefreshTokenDatabaseRowV1, +} 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; +} { + const sessions = new Map(); + const refreshTokens = 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 }; + }, + }, + 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 }; +} + +void test('[IAM-005, IAM-006] Prisma sessions persist opaque bounded access and refresh credentials', async () => { + const { client, sessions, refreshTokens } = 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((await adapter.findPrincipal(session.sessionId))?.userId, userId); +}); + +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); +}); From 5360c15b4487359192db90b1b02478b364a4ff6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:09:03 +0700 Subject: [PATCH 011/177] feat(iam): persist personal identity bootstrap --- ...a-identity-bootstrap-repository.adapter.ts | 306 ++++++++++++++++++ services/api/src/features/iam/iam.module.ts | 24 ++ .../foundation-module-composition.test.ts | 17 + ...isma-identity-bootstrap-repository.test.ts | 163 ++++++++++ 4 files changed, 510 insertions(+) create mode 100644 services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts create mode 100644 services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts 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..eeaf1ded --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts @@ -0,0 +1,306 @@ +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/iam.module.ts b/services/api/src/features/iam/iam.module.ts index be331051..7ae5c70a 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -10,6 +10,10 @@ import { type SessionIssuerPortV1, } from './application/authentication.port.js'; import { SESSION_LIFECYCLE_PORT } from './application/session-lifecycle.port.js'; +import { + IDENTITY_BOOTSTRAP_REPOSITORY_PORT, + type IdentityBootstrapRepositoryPortV1, +} from './application/identity-bootstrap-repository.port.js'; import type { PasswordCredentialService } from './application/password-credential.service.js'; import { UnavailableAuthenticationAdapter } from './adapter/unavailable-authentication.adapter.js'; import { @@ -20,6 +24,10 @@ import { PrismaSessionLifecycleAdapter, type SessionLifecycleDatabaseClientV1, } from './adapter/prisma-session-lifecycle.adapter.js'; +import { + PrismaIdentityBootstrapRepositoryAdapter, + type IdentityBootstrapDatabaseClientV1, +} from './adapter/prisma-identity-bootstrap-repository.adapter.js'; import { DeviceIdentityController } from './api/device-identity.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { @@ -49,6 +57,8 @@ export interface IamModuleOptions { readonly passwordCredentials?: PasswordCredentialService; readonly sessions?: SessionIssuerPortV1; readonly sessionDatabase?: SessionLifecycleDatabaseClientV1; + readonly identityBootstrapRepository?: IdentityBootstrapRepositoryPortV1; + readonly identityBootstrapDatabase?: IdentityBootstrapDatabaseClientV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -81,6 +91,11 @@ export class IamModule { (options.sessionDatabase === undefined ? undefined : new PrismaSessionLifecycleAdapter(options.sessionDatabase)); + const identityBootstrapRepository = + options.identityBootstrapRepository ?? + (options.identityBootstrapDatabase === undefined + ? undefined + : new PrismaIdentityBootstrapRepositoryAdapter(options.identityBootstrapDatabase)); const authentication = options.authentication ?? (credentials && sessions @@ -100,6 +115,7 @@ export class IamModule { 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); return { module: IamModule, controllers: [AuthenticationController, DeviceIdentityController], @@ -124,6 +140,14 @@ export class IamModule { }, ] : []), + ...(identityBootstrapRepository + ? [ + { + provide: IDENTITY_BOOTSTRAP_REPOSITORY_PORT, + useValue: identityBootstrapRepository, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index f10f19e8..fa480829 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -10,6 +10,8 @@ import { 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 { 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'; @@ -85,6 +87,21 @@ void test('[IAM-005] configured session persistence uses the Prisma lifecycle ad assert.ok(provider.useValue instanceof PrismaSessionLifecycleAdapter); }); +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('[BUA-001] configured entitlement persistence uses the Prisma adapter instead of the local fallback', () => { const database = {} as never; const registered = BuaModule.register({ entitlementDatabase: database }); 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..a1a2cbd3 --- /dev/null +++ b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts @@ -0,0 +1,163 @@ +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); +}); From 54cc4943d1a138a191fc4928c7cc1865da0efa44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:10:59 +0700 Subject: [PATCH 012/177] fix(iam): resolve organization memberships for sign-in --- .../prisma-credential-lookup.adapter.ts | 46 ++++++++++++++----- .../iam/prisma-credential-lookup.test.ts | 25 ++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) 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 index 582411fb..1492db36 100644 --- a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts @@ -51,6 +51,12 @@ interface UniqueDelegateV1 { }): Promise; } +interface WorkspaceLookupDelegateV1 extends UniqueDelegateV1 { + readonly findMany?: (input: { + readonly where: Readonly>; + }) => Promise; +} + interface ListDelegateV1 { findMany(input: { readonly where: Readonly>; @@ -62,14 +68,14 @@ export interface CredentialLookupDatabaseClientV1 { readonly userIdentity: UniqueDelegateV1; readonly passwordCredential: UniqueDelegateV1; readonly membershipIdentity: ListDelegateV1; - readonly workspaceIdentity: UniqueDelegateV1; + readonly workspaceIdentity: WorkspaceLookupDelegateV1; readonly organizationIdentity: UniqueDelegateV1; readonly mfaFactor: ListDelegateV1; } interface ActiveMembershipV1 { readonly organizationId: string; - readonly workspaceId: string; + readonly workspaceId?: string; } function stableId(input: unknown): string | undefined { @@ -81,13 +87,18 @@ function activeMembership( row: MembershipIdentityDatabaseRowV1, userId: string, ): ActiveMembershipV1 | undefined { - if (row.principalId !== userId || row.status !== 'ACTIVE' || row.scopeType !== 'WORKSPACE') + if (row.principalId !== userId || row.status !== 'ACTIVE') return undefined; - if (row.projectId !== null) return undefined; const organizationId = stableId(row.organizationId); - const workspaceId = stableId(row.workspaceId); - if (!organizationId || !workspaceId) return undefined; - return { organizationId, workspaceId }; + 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; } /** @@ -135,17 +146,30 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { .find((membership): membership is ActiveMembershipV1 => membership !== undefined); if (!selected) return undefined; - const [organization, workspace, factors] = await Promise.all([ + const [organization, factors] = await Promise.all([ this.client.organizationIdentity.findUnique({ where: { id: selected.organizationId } }), - this.client.workspaceIdentity.findUnique({ where: { id: selected.workspaceId } }), 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 !== selected.workspaceId || + workspace.id !== workspaceId || workspace.organizationId !== selected.organizationId || workspace.status !== 'ACTIVE' ) @@ -155,7 +179,7 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { principal: Object.freeze({ userId, organizationId: selected.organizationId, - workspaceId: selected.workspaceId, + workspaceId, securityEpoch: user.securityEpoch, mfaRequired: factors.length > 0, }), diff --git a/services/api/test/features/iam/prisma-credential-lookup.test.ts b/services/api/test/features/iam/prisma-credential-lookup.test.ts index 42221b1d..3b134c15 100644 --- a/services/api/test/features/iam/prisma-credential-lookup.test.ts +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -46,6 +46,7 @@ function database( }, workspaceIdentity: { findUnique: async () => ({ id: workspaceId, organizationId, status: 'ACTIVE' }), + findMany: async () => [{ id: workspaceId, organizationId, status: 'ACTIVE' }], }, organizationIdentity: { findUnique: async () => ({ id: organizationId, status: 'ACTIVE' }), @@ -113,3 +114,27 @@ void test('[IAM-001, IAM-002] lookup does not authenticate users without an acti 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); +}); From 5f7c58f4718ac1e86f978ec03aaeedb7a6d641c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:17:45 +0700 Subject: [PATCH 013/177] feat(iam): persist MFA factors and recovery codes --- .../migration.sql | 20 ++ services/api/prisma/schema/iam.prisma | 15 + .../adapter/prisma-mfa-repository.adapter.ts | 266 ++++++++++++++++++ services/api/src/features/iam/iam.module.ts | 18 ++ .../foundation-module-composition.test.ts | 17 ++ .../iam/prisma-mfa-repository.test.ts | 161 +++++++++++ services/api/test/prisma-foundation.test.mjs | 2 + 7 files changed, 499 insertions(+) create mode 100644 services/api/prisma/migrations/20260802210000_iam_mfa_recovery/migration.sql create mode 100644 services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts create mode 100644 services/api/test/features/iam/prisma-mfa-repository.test.ts 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/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index d529d923..387c0b2c 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -171,12 +171,27 @@ 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 AuthorizationSnapshot { id String @id @db.Uuid deviceId String @map("device_id") @db.Uuid 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..3f570176 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts @@ -0,0 +1,266 @@ +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/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 7ae5c70a..4c08ce91 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -14,6 +14,7 @@ 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 type { PasswordCredentialService } from './application/password-credential.service.js'; import { UnavailableAuthenticationAdapter } from './adapter/unavailable-authentication.adapter.js'; import { @@ -28,6 +29,7 @@ import { PrismaIdentityBootstrapRepositoryAdapter, type IdentityBootstrapDatabaseClientV1, } from './adapter/prisma-identity-bootstrap-repository.adapter.js'; +import { PrismaMfaRepositoryAdapter, type MfaDatabaseClientV1 } from './adapter/prisma-mfa-repository.adapter.js'; import { DeviceIdentityController } from './api/device-identity.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { @@ -59,6 +61,8 @@ export interface IamModuleOptions { readonly sessionDatabase?: SessionLifecycleDatabaseClientV1; readonly identityBootstrapRepository?: IdentityBootstrapRepositoryPortV1; readonly identityBootstrapDatabase?: IdentityBootstrapDatabaseClientV1; + readonly mfaRepository?: MfaRepositoryPortV1; + readonly mfaDatabase?: MfaDatabaseClientV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -96,6 +100,11 @@ export class IamModule { (options.identityBootstrapDatabase === undefined ? undefined : new PrismaIdentityBootstrapRepositoryAdapter(options.identityBootstrapDatabase)); + const mfaRepository = + options.mfaRepository ?? + (options.mfaDatabase === undefined + ? undefined + : new PrismaMfaRepositoryAdapter(options.mfaDatabase)); const authentication = options.authentication ?? (credentials && sessions @@ -116,6 +125,7 @@ export class IamModule { 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); return { module: IamModule, controllers: [AuthenticationController, DeviceIdentityController], @@ -148,6 +158,14 @@ export class IamModule { }, ] : []), + ...(mfaRepository + ? [ + { + provide: MFA_REPOSITORY_PORT, + useValue: mfaRepository, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index fa480829..ff0847ff 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -12,6 +12,8 @@ import { PrismaSessionLifecycleAdapter } from '../../src/features/iam/adapter/pr 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 { 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'; @@ -102,6 +104,21 @@ void test('[IAM-001, IAM-011] configured identity bootstrap persistence uses the 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('[BUA-001] configured entitlement persistence uses the Prisma adapter instead of the local fallback', () => { const database = {} as never; const registered = BuaModule.register({ entitlementDatabase: database }); 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..29094f6c --- /dev/null +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -0,0 +1,161 @@ +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/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index cccfcf59..15026d46 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -74,6 +74,7 @@ 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"\."device_enrollment_challenges"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."device_grants"/); @@ -102,6 +103,7 @@ 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', 'migration_lock.toml', ]); const migration = await readFile( From 3c904751a815824e05bc276440fb0675119bee99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:23:21 +0700 Subject: [PATCH 014/177] feat(iam): persist tenant-scoped memberships --- .../adapter/prisma-iam-repository.adapter.ts | 199 ++++++++++++++++++ .../iam/application/iam-repository.port.ts | 2 + services/api/src/features/iam/iam.module.ts | 18 ++ .../foundation-module-composition.test.ts | 17 ++ .../iam/prisma-iam-repository.test.ts | 175 +++++++++++++++ 5 files changed, 411 insertions(+) create mode 100644 services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts create mode 100644 services/api/test/features/iam/prisma-iam-repository.test.ts 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..d44da532 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -0,0 +1,199 @@ +import { + tenantScopeContainsV1, + tenantScopesEqualV1, + parseStableIdentifierV1, + parseTenantScopeV1, + 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/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/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 4c08ce91..5ebb25af 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -15,6 +15,7 @@ import { type IdentityBootstrapRepositoryPortV1, } from './application/identity-bootstrap-repository.port.js'; import { MFA_REPOSITORY_PORT, type MfaRepositoryPortV1 } from './application/mfa-repository.port.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 { @@ -30,6 +31,7 @@ import { 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 { @@ -63,6 +65,8 @@ export interface IamModuleOptions { readonly identityBootstrapDatabase?: IdentityBootstrapDatabaseClientV1; readonly mfaRepository?: MfaRepositoryPortV1; readonly mfaDatabase?: MfaDatabaseClientV1; + readonly iamRepository?: IamRepositoryPortV1; + readonly iamDatabase?: IamDatabaseClientV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -105,6 +109,11 @@ export class IamModule { (options.mfaDatabase === undefined ? undefined : new PrismaMfaRepositoryAdapter(options.mfaDatabase)); + const iamRepository = + options.iamRepository ?? + (options.iamDatabase === undefined + ? undefined + : new PrismaIamRepositoryAdapter(options.iamDatabase)); const authentication = options.authentication ?? (credentials && sessions @@ -126,6 +135,7 @@ export class IamModule { if (sessions) exports.unshift(SESSION_LIFECYCLE_PORT); if (identityBootstrapRepository) exports.unshift(IDENTITY_BOOTSTRAP_REPOSITORY_PORT); if (mfaRepository) exports.unshift(MFA_REPOSITORY_PORT); + if (iamRepository) exports.unshift(IAM_REPOSITORY_PORT); return { module: IamModule, controllers: [AuthenticationController, DeviceIdentityController], @@ -166,6 +176,14 @@ export class IamModule { }, ] : []), + ...(iamRepository + ? [ + { + provide: IAM_REPOSITORY_PORT, + useValue: iamRepository, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index ff0847ff..7cf04d8c 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -14,6 +14,8 @@ import { IDENTITY_BOOTSTRAP_REPOSITORY_PORT } from '../../src/features/iam/appli 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 { 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'; @@ -119,6 +121,21 @@ void test('[IAM-012, IAM-014] configured MFA persistence uses the Prisma adapter assert.ok(provider.useValue instanceof PrismaMfaRepositoryAdapter); }); +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 }); 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..c8f9fbe0 --- /dev/null +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -0,0 +1,175 @@ +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); +}); From 7ff5bd4ad93f25acf0f4c6546b507f3abb9f7c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:27:50 +0700 Subject: [PATCH 015/177] feat(api): enforce browser CSRF boundary --- services/api/src/bootstrap.ts | 8 +- .../api/src/platform/http/csrf-protection.ts | 114 ++++++++++++++++ .../api/src/platform/http/request-context.ts | 38 +++++- services/api/test/http-contract.test.ts | 49 +++++++ .../platform/http/csrf-protection.test.ts | 124 ++++++++++++++++++ 5 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 services/api/src/platform/http/csrf-protection.ts create mode 100644 services/api/test/platform/http/csrf-protection.test.ts 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/platform/http/csrf-protection.ts b/services/api/src/platform/http/csrf-protection.ts new file mode 100644 index 00000000..d16bba3c --- /dev/null +++ b/services/api/src/platform/http/csrf-protection.ts @@ -0,0 +1,114 @@ +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/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/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 97b22f86..6d80c148 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -23,6 +23,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], @@ -178,6 +179,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({ 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..cd0c9735 --- /dev/null +++ b/services/api/test/platform/http/csrf-protection.test.ts @@ -0,0 +1,124 @@ +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']; + +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 }, + ); +}); + +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 }, + ); +}); + +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 }, + ); +}); + +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' }, + ); +}); From 9f140f15411da9a8180bccb8889fa84bd74876aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:30:43 +0700 Subject: [PATCH 016/177] feat(iam): add secure session cookie boundary --- .../src/features/iam/api/session-cookies.ts | 56 +++++++++++++++++++ .../test/features/iam/session-cookies.test.ts | 36 ++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 services/api/src/features/iam/api/session-cookies.ts create mode 100644 services/api/test/features/iam/session-cookies.test.ts 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..0a623380 --- /dev/null +++ b/services/api/src/features/iam/api/session-cookies.ts @@ -0,0 +1,56 @@ +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('; '); +} + +/** 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/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts new file mode 100644 index 00000000..f6483fbe --- /dev/null +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + CSRF_COOKIE_NAME_V1, + REFRESH_COOKIE_NAME_V1, + readCookieValueV1, + serializeCookieV1, +} from '../../../src/features/iam/api/session-cookies.js'; + +const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; + +test('serializes bounded session cookies with explicit browser security attributes', () => { + assert.equal( + serializeCookieV1(REFRESH_COOKIE_NAME_V1, token, { httpOnly: true, maxAgeSeconds: 2_592_000 }), + `${REFRESH_COOKIE_NAME_V1}=${token}; 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`, + ); +}); + +test('reads one exact cookie value and fails closed for ambiguity or malformed input', () => { + assert.equal(readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${token}`, REFRESH_COOKIE_NAME_V1), token); + assert.equal( + readCookieValueV1(`other=value; ${REFRESH_COOKIE_NAME_V1}=${token}`, REFRESH_COOKIE_NAME_V1), + token, + ); + assert.equal( + readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${token}; ${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); +}); From a909d9c1bf0ce30b72befdfc16d317a4df055468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:35:31 +0700 Subject: [PATCH 017/177] feat(iam): keep browser refresh credentials in cookies --- services/api/openapi/v1.json | 1 - .../src/features/iam/api/auth-session.dto.ts | 15 ++++++++-- .../iam/api/authentication.controller.ts | 30 +++++++++++++++++-- services/api/test/http-contract.test.ts | 6 +++- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 987e8834..5ab3fc38 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -3150,7 +3150,6 @@ "organizationId", "workspaceId", "accessToken", - "refreshToken", "accessExpiresAt", "securityEpoch", "mfaRequired" 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..6d5f9071 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, HttpCode, Inject, Post, Res } from '@nestjs/common'; import { ApiBody, ApiOkResponse, @@ -13,8 +15,14 @@ import { type AuthenticationUseCaseV1, } from '../application/authentication.port.js'; import { AuthenticationProblemError } from '../application/authentication-problem.error.js'; +import { + CSRF_COOKIE_NAME_V1, + REFRESH_COOKIE_NAME_V1, + serializeCookieV1, +} from './session-cookies.js'; import { AuthSessionDto } from './auth-session.dto.js'; import { SignInDto } from './sign-in.dto.js'; +import type { FastifyReply } from 'fastify'; @ApiTags('auth') @Controller('v1/auth') @@ -31,16 +39,32 @@ 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, diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 6d80c148..5802e12b 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -300,11 +300,15 @@ 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); }, ); From 305fdecb11d123668e0fa4e3794fe9784feba12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:35:37 +0700 Subject: [PATCH 018/177] fix(iam): accept rotating refresh token cookie values --- .../api/src/features/iam/api/session-cookies.ts | 2 +- .../api/test/features/iam/session-cookies.test.ts | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/services/api/src/features/iam/api/session-cookies.ts b/services/api/src/features/iam/api/session-cookies.ts index 0a623380..75673ac6 100644 --- a/services/api/src/features/iam/api/session-cookies.ts +++ b/services/api/src/features/iam/api/session-cookies.ts @@ -1,5 +1,5 @@ const COOKIE_NAME_PATTERN_V1 = /^[A-Za-z0-9_]+$/u; -const COOKIE_VALUE_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'; diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts index f6483fbe..8e8c3aa4 100644 --- a/services/api/test/features/iam/session-cookies.test.ts +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -9,11 +9,12 @@ import { } from '../../../src/features/iam/api/session-cookies.js'; const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; +const refreshToken = `00000000-0000-4000-8000-000000000001.${token}`; test('serializes bounded session cookies with explicit browser security attributes', () => { assert.equal( - serializeCookieV1(REFRESH_COOKIE_NAME_V1, token, { httpOnly: true, maxAgeSeconds: 2_592_000 }), - `${REFRESH_COOKIE_NAME_V1}=${token}; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=Lax`, + 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 }), @@ -22,13 +23,13 @@ test('serializes bounded session cookies with explicit browser security attribut }); test('reads one exact cookie value and fails closed for ambiguity or malformed input', () => { - assert.equal(readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${token}`, REFRESH_COOKIE_NAME_V1), token); + assert.equal(readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${refreshToken}`, REFRESH_COOKIE_NAME_V1), refreshToken); assert.equal( - readCookieValueV1(`other=value; ${REFRESH_COOKIE_NAME_V1}=${token}`, REFRESH_COOKIE_NAME_V1), - token, + readCookieValueV1(`other=value; ${REFRESH_COOKIE_NAME_V1}=${refreshToken}`, REFRESH_COOKIE_NAME_V1), + refreshToken, ); assert.equal( - readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${token}; ${REFRESH_COOKIE_NAME_V1}=other`, REFRESH_COOKIE_NAME_V1), + 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); From 3af91014389988c14179926be4fd5a2a9a7a7692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:43:25 +0700 Subject: [PATCH 019/177] feat(iam): rotate refresh sessions through HTTP --- services/api/openapi/v1.json | 127 ++++++++++++++++++ .../iam/api/authentication.controller.ts | 55 +++++++- .../iam/api/session-refresh-response.dto.ts | 25 ++++ .../features/iam/api/session-refresh.dto.ts | 15 +++ .../iam/application/session-problem.error.ts | 8 ++ services/api/src/features/iam/iam.module.ts | 8 +- .../platform/http/problem-details.filter.ts | 11 ++ services/api/test/http-contract.test.ts | 76 +++++++++++ services/api/test/openapi.test.ts | 1 + 9 files changed, 321 insertions(+), 5 deletions(-) create mode 100644 services/api/src/features/iam/api/session-refresh-response.dto.ts create mode 100644 services/api/src/features/iam/api/session-refresh.dto.ts create mode 100644 services/api/src/features/iam/application/session-problem.error.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 5ab3fc38..b148260e 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -454,6 +454,110 @@ "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/devices/enrollment-challenges": { "post": { "operationId": "DeviceIdentityController.issueChallenge", @@ -3155,6 +3259,29 @@ "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"] + }, "IssueDeviceEnrollmentChallengeDto": { "type": "object", "properties": { diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 6d5f9071..e9b4c781 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; -import { Body, Controller, HttpCode, Inject, Post, Res } from '@nestjs/common'; +import { Body, Controller, HttpCode, Inject, Optional, Post, Req, Res } from '@nestjs/common'; import { ApiBody, ApiOkResponse, @@ -15,14 +15,19 @@ 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, + readCookieValueV1, serializeCookieV1, } from './session-cookies.js'; import { AuthSessionDto } from './auth-session.dto.js'; import { SignInDto } from './sign-in.dto.js'; -import type { FastifyReply } from 'fastify'; +import { SessionRefreshDto } from './session-refresh.dto.js'; +import { SessionRefreshResponseDto } from './session-refresh-response.dto.js'; +import type { FastifyReply, FastifyRequest } from 'fastify'; @ApiTags('auth') @Controller('v1/auth') @@ -30,6 +35,9 @@ export class AuthenticationController { constructor( @Inject(AUTHENTICATION_USE_CASE) private readonly authentication: AuthenticationUseCaseV1, + @Optional() + @Inject(SESSION_LIFECYCLE_PORT) + private readonly sessions?: SessionLifecyclePortV1, ) {} @Post('sign-in') @@ -70,4 +78,47 @@ export class AuthenticationController { 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 }), + }; + } } 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/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/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 5ebb25af..f09f8551 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -7,9 +7,11 @@ import { CREDENTIAL_LOOKUP_PORT, type CredentialLookupPortV1, type AuthenticationUseCaseV1, - type SessionIssuerPortV1, } from './application/authentication.port.js'; -import { SESSION_LIFECYCLE_PORT } from './application/session-lifecycle.port.js'; +import { + SESSION_LIFECYCLE_PORT, + type SessionLifecyclePortV1, +} from './application/session-lifecycle.port.js'; import { IDENTITY_BOOTSTRAP_REPOSITORY_PORT, type IdentityBootstrapRepositoryPortV1, @@ -59,7 +61,7 @@ export interface IamModuleOptions { readonly credentials?: CredentialLookupPortV1; readonly credentialDatabase?: CredentialLookupDatabaseClientV1; readonly passwordCredentials?: PasswordCredentialService; - readonly sessions?: SessionIssuerPortV1; + readonly sessions?: SessionLifecyclePortV1; readonly sessionDatabase?: SessionLifecycleDatabaseClientV1; readonly identityBootstrapRepository?: IdentityBootstrapRepositoryPortV1; readonly identityBootstrapDatabase?: IdentityBootstrapDatabaseClientV1; diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 284cbb53..e400a402 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -8,6 +8,7 @@ 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 { 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 +33,16 @@ 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 InputValidationException) { return { code: 'VALIDATION_FAILED', diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 5802e12b..9a3e3472 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -348,3 +348,79 @@ 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); + assert.equal(native.json().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/); + }, + ); +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 43beaa2c..5ff8985a 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -66,6 +66,7 @@ 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/auth/refresh', '/v1/auth/sign-in', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', From 15d7018075bf92d04c71cc87c19621f3b5297402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:48:04 +0700 Subject: [PATCH 020/177] feat(iam): revoke sessions through HTTP --- services/api/openapi/v1.json | 94 +++++++++++++++++++ .../iam/api/authentication.controller.ts | 22 +++++ .../src/features/iam/api/session-cookies.ts | 14 +++ .../features/iam/api/session-sign-out.dto.ts | 12 +++ .../test/features/iam/session-cookies.test.ts | 12 +++ services/api/test/http-contract.test.ts | 53 +++++++++++ services/api/test/openapi.test.ts | 1 + 7 files changed, 208 insertions(+) create mode 100644 services/api/src/features/iam/api/session-sign-out.dto.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index b148260e..3fc8b9a2 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -558,6 +558,92 @@ "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", @@ -3282,6 +3368,14 @@ }, "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": { diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index e9b4c781..d222efd3 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -20,6 +20,7 @@ import { SessionProblemError } from '../application/session-problem.error.js'; import { CSRF_COOKIE_NAME_V1, REFRESH_COOKIE_NAME_V1, + clearCookieV1, readCookieValueV1, serializeCookieV1, } from './session-cookies.js'; @@ -27,6 +28,7 @@ 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 type { FastifyReply, FastifyRequest } from 'fastify'; @ApiTags('auth') @@ -121,4 +123,24 @@ export class AuthenticationController { ...(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/session-cookies.ts b/services/api/src/features/iam/api/session-cookies.ts index 75673ac6..5feab2a3 100644 --- a/services/api/src/features/iam/api/session-cookies.ts +++ b/services/api/src/features/iam/api/session-cookies.ts @@ -36,6 +36,20 @@ export function serializeCookieV1(name: string, value: string, options: CookieOp .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; 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/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts index 8e8c3aa4..2e6345a3 100644 --- a/services/api/test/features/iam/session-cookies.test.ts +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -4,6 +4,7 @@ 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'; @@ -35,3 +36,14 @@ test('reads one exact cookie value and fails closed for ambiguity or malformed i assert.equal(readCookieValueV1('broken-cookie', REFRESH_COOKIE_NAME_V1), undefined); assert.equal(readCookieValueV1(undefined, REFRESH_COOKIE_NAME_V1), undefined); }); + +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/http-contract.test.ts b/services/api/test/http-contract.test.ts index 9a3e3472..c3d00810 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -424,3 +424,56 @@ void test('refresh rotates Web cookies without returning the refresh token and p }, ); }); + +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', + ]); + }, + ); +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 5ff8985a..6cdd3359 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -68,6 +68,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', '/v1/auth/refresh', '/v1/auth/sign-in', + '/v1/auth/sign-out', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', '/v1/datasets', From 5dc17798a388009006114b16834526228f1ade23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:53:28 +0700 Subject: [PATCH 021/177] feat(iam): persist access-token lookup digests --- .../migration.sql | 16 ++++ services/api/prisma/schema/iam.prisma | 15 ++++ .../in-memory-session-lifecycle.adapter.ts | 18 ++++- .../prisma-session-lifecycle.adapter.ts | 77 ++++++++++++++++++- .../iam/application/session-lifecycle.port.ts | 7 ++ .../iam/prisma-session-lifecycle.test.ts | 35 ++++++++- services/api/test/prisma-foundation.test.mjs | 15 ++++ 7 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 services/api/prisma/migrations/20260802220000_iam_access_tokens/migration.sql 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 387c0b2c..0dd1e5b9 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -192,6 +192,21 @@ model MfaRecoveryCode { @@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/features/iam/adapter/in-memory-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts index 8364aac0..e25db51b 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), }); @@ -188,6 +193,15 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { 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-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts index 1adc7607..257fda7b 100644 --- a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -46,6 +46,16 @@ export interface RefreshTokenDatabaseRowV1 { 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; @@ -106,6 +116,19 @@ interface RefreshTokenDelegateV1 { }): 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>; @@ -121,6 +144,7 @@ interface ListDelegateV1 { export interface SessionLifecycleDatabaseClientV1 { readonly sessionRecord: SessionDelegateV1; readonly refreshTokenRecord: RefreshTokenDelegateV1; + readonly accessTokenRecord: AccessTokenDelegateV1; readonly userIdentity: UniqueDelegateV1; readonly membershipIdentity: ListDelegateV1; readonly workspaceIdentity: UniqueDelegateV1; @@ -234,6 +258,8 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { }); 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({ @@ -261,10 +287,21 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { 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: tokenFor(stableIdentifier(randomUUID())), + accessToken, refreshToken, accessExpiresAt: record.accessExpiresAt, }; @@ -312,6 +349,10 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { 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' }, @@ -341,6 +382,8 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { 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, @@ -353,9 +396,20 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { 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: tokenFor(stableIdentifier(randomUUID())), + accessToken: nextAccessToken, refreshToken: nextRefreshToken, accessExpiresAt, }); @@ -380,10 +434,29 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { 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); 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 ac6ac2cb..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' @@ -29,4 +30,10 @@ export interface SessionLifecyclePortV1 extends SessionIssuerPortV1 { ): Promise; revoke(sessionId: unknown): Promise; 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/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index 0e5294e4..3a3cd095 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -6,6 +6,7 @@ import { 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'; @@ -23,9 +24,11 @@ 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 }) => { @@ -80,6 +83,30 @@ function createDatabase(): { 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 }), }, @@ -108,11 +135,11 @@ function createDatabase(): { $transaction: async (work: (transaction: SessionLifecycleDatabaseClientV1) => Promise) => work(client), } as unknown as SessionLifecycleDatabaseClientV1; - return { client, sessions, refreshTokens }; + 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 } = createDatabase(); + const { client, sessions, refreshTokens, accessTokens } = createDatabase(); const adapter = new PrismaSessionLifecycleAdapter(client, { clock: () => new Date('2026-01-01T00:00:00.000Z'), }); @@ -123,7 +150,10 @@ void test('[IAM-005, IAM-006] Prisma sessions persist opaque bounded access and 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 () => { @@ -171,4 +201,5 @@ void test('[IAM-005] revocation is idempotent and hides session principals after 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/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 15026d46..43e8de30 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -75,6 +75,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo 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"/); @@ -104,6 +105,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802190000_dso_capabilities_grants', '20260802200000_dso_data_mode_policies', '20260802210000_iam_mfa_recovery', + '20260802220000_iam_access_tokens', 'migration_lock.toml', ]); const migration = await readFile( @@ -383,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, '\\$&')), + ); + } }); From 0b19a0b2048e96d2bf536dc8f253585919bec9d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:58:56 +0700 Subject: [PATCH 022/177] feat(iam): derive tenant context from live sessions --- services/api/src/app.module.ts | 25 +++-- .../platform/http/problem-details.filter.ts | 13 +++ .../http/session-tenant-context.adapter.ts | 102 ++++++++++++++++++ .../foundation-module-composition.test.ts | 25 +++++ .../http/session-tenant-context.test.ts | 79 ++++++++++++++ 5 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 services/api/src/platform/http/session-tenant-context.adapter.ts create mode 100644 services/api/test/platform/http/session-tenant-context.test.ts diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index 5781d5ad..7a0ee73b 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -7,6 +7,7 @@ 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 & @@ -19,16 +20,26 @@ export type AppModuleOptions = SystemModuleOptions & @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), - AudModule.register(options), - BuaModule.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/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index e400a402..9f4a4b31 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -9,6 +9,7 @@ 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 { 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'; @@ -43,6 +44,18 @@ function describe(error: unknown, correlationId: string): ProblemInput { status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.UNAUTHORIZED, }; } + 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/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts new file mode 100644 index 00000000..9e642e7c --- /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, + }); + if (!context.accepted) throw new RequestTenantContextProblemError('CONTEXT_INVALID'); + return context.value; + } +} diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 7cf04d8c..37a74dd9 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -22,6 +22,8 @@ import { PrismaAuditRepositoryAdapter } from '../../src/features/aud/adapter/pri 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(); @@ -91,6 +93,29 @@ void test('[IAM-005] configured session persistence uses the Prisma lifecycle ad 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: async () => 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 }); 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..327a5b53 --- /dev/null +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -0,0 +1,79 @@ +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'; + +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: async (token) => { + seen.push(String(token)); + return 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, + }); +}); + +test('rejects missing, ambiguous, malformed, and unknown bearer credentials', async () => { + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: async () => 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; + }); + } +}); + +test('uses the request id for read-only calls and rejects unsafe principal state', async () => { + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: async () => ({ ...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; + }, + ); +}); From 152eae37ef064977aaf6068327698d9f98a0ac4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:01:07 +0700 Subject: [PATCH 023/177] test(iam): verify protected tenant context integration --- services/api/test/http-contract.test.ts | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index c3d00810..405adbae 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -477,3 +477,37 @@ void test('sign-out revokes idempotently and clears browser credentials', async }, ); }); + +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: async (token) => + 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(), []); + }, + ); +}); From 785240255e7fa622743477b1360bd5f4a9855362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:02:31 +0700 Subject: [PATCH 024/177] feat(iam): compose durable MFA service --- .../features/iam/application/mfa.service.ts | 2 ++ services/api/src/features/iam/iam.module.ts | 29 +++++++++++++++++++ .../foundation-module-composition.test.ts | 16 ++++++++++ 3 files changed, 47 insertions(+) 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/iam.module.ts b/services/api/src/features/iam/iam.module.ts index f09f8551..53018dc7 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -17,6 +17,7 @@ import { 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'; @@ -67,6 +68,8 @@ export interface IamModuleOptions { 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; @@ -111,6 +114,23 @@ export class IamModule { (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 @@ -137,6 +157,7 @@ export class IamModule { 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, @@ -178,6 +199,14 @@ export class IamModule { }, ] : []), + ...(mfaService + ? [ + { + provide: MFA_SERVICE, + useValue: mfaService, + }, + ] + : []), ...(iamRepository ? [ { diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 37a74dd9..83fa1c74 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -14,6 +14,7 @@ import { IDENTITY_BOOTSTRAP_REPOSITORY_PORT } from '../../src/features/iam/appli 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'; @@ -146,6 +147,21 @@ void test('[IAM-012, IAM-014] configured MFA persistence uses the Prisma adapter 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 }); From 02c18226454db44f1b1e0d5c7522c5aa594ba435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:05:31 +0700 Subject: [PATCH 025/177] feat(iam): expose authenticated MFA lifecycle --- services/api/openapi/v1.json | 249 ++++++++++++++++++ .../src/features/iam/api/mfa.controller.ts | 70 +++++ services/api/src/features/iam/api/mfa.dto.ts | 44 ++++ .../iam/application/mfa-problem.error.ts | 8 + services/api/src/features/iam/iam.module.ts | 3 +- .../platform/http/problem-details.filter.ts | 11 + services/api/test/openapi.test.ts | 3 + 7 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iam/api/mfa.controller.ts create mode 100644 services/api/src/features/iam/api/mfa.dto.ts create mode 100644 services/api/src/features/iam/application/mfa-problem.error.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 3fc8b9a2..6e909196 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -1093,6 +1093,231 @@ "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/artifacts/inbox": { "post": { "operationId": "InboxController.create", @@ -3423,6 +3648,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/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts new file mode 100644 index 00000000..5e015517 --- /dev/null +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -0,0 +1,70 @@ +import { Body, Controller, Get, 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..d0b57489 --- /dev/null +++ b/services/api/src/features/iam/api/mfa.dto.ts @@ -0,0 +1,44 @@ +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/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/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 53018dc7..8a5fabfa 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -1,6 +1,7 @@ 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, @@ -161,7 +162,7 @@ export class IamModule { if (iamRepository) exports.unshift(IAM_REPOSITORY_PORT); return { module: IamModule, - controllers: [AuthenticationController, DeviceIdentityController], + controllers: [AuthenticationController, DeviceIdentityController, MfaController], providers: [ { provide: AUTHENTICATION_USE_CASE, diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 9f4a4b31..1fc4353a 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -9,6 +9,7 @@ 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'; @@ -44,6 +45,16 @@ function describe(error: unknown, correlationId: string): ProblemInput { 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 { diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 6cdd3359..6192d768 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -66,6 +66,9 @@ 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/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', From 676c632290593f74b492663347d430c649e3a423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:07:49 +0700 Subject: [PATCH 026/177] test(iam): verify MFA HTTP lifecycle --- services/api/test/http-contract.test.ts | 54 +++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 405adbae..0b80ccf5 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; @@ -511,3 +514,54 @@ void test('protected artifact reads derive tenant scope from an authenticated ac }, ); }); + +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: async () => 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); + assert.equal(enrolled.json().factors[0].status, 'PENDING'); + assert.equal(enrolled.json().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); + assert.equal(verified.json().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'); + }); +}); From 293ed25b69c3d006ec75f1b4dd6a3d34a97e0956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:12:10 +0700 Subject: [PATCH 027/177] feat(aud): expose authenticated immutable audit reads --- services/api/openapi/v1.json | 136 ++++++++++++++++++ .../src/features/aud/api/audit.controller.ts | 35 +++++ services/api/src/features/aud/aud.module.ts | 12 ++ services/api/test/http-contract.test.ts | 16 +++ services/api/test/openapi.test.ts | 2 + 5 files changed, 201 insertions(+) create mode 100644 services/api/src/features/aud/api/audit.controller.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 6e909196..2d9d482b 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -3507,6 +3507,142 @@ "summary": "List immutable versions of one workspace data-mode policy", "tags": ["devices"] } + }, + "/v1/audit/events": { + "get": { + "operationId": "AuditController.events", + "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": "", + "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 immutable audit events visible to the caller", + "tags": ["audit"] + } + }, + "/v1/audit/seals": { + "get": { + "operationId": "AuditController.seals", + "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": "", + "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 verified audit seals visible to the caller", + "tags": ["audit"] + } } }, "info": { 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 index cb4ee6d6..906e60f1 100644 --- a/services/api/src/features/aud/aud.module.ts +++ b/services/api/src/features/aud/aud.module.ts @@ -11,6 +11,12 @@ import { 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'); @@ -18,6 +24,7 @@ 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({}) @@ -32,9 +39,14 @@ export class AudModule { 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/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 0b80ccf5..82d68fd4 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -511,6 +511,22 @@ void test('protected artifact reads derive tenant scope from an authenticated ac }); assert.equal(authenticated.statusCode, 200); assert.deepEqual(authenticated.json(), []); + + 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(), []); }, ); }); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 6192d768..bc618ea1 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -66,6 +66,8 @@ 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/mfa/factors', '/v1/auth/mfa/factors/{factorId}/verify', '/v1/auth/mfa/recovery/redeem', From dc045fd8100090aa114d55cadae5cfc405eaeb53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:17:36 +0700 Subject: [PATCH 028/177] feat(bua): expose scoped entitlement reads --- services/api/openapi/v1.json | 137 ++++++++++++++++++ .../bua/api/entitlement.controller.ts | 54 +++++++ services/api/src/features/bua/bua.module.ts | 12 ++ services/api/test/http-contract.test.ts | 30 ++++ services/api/test/openapi.test.ts | 2 + 5 files changed, 235 insertions(+) create mode 100644 services/api/src/features/bua/api/entitlement.controller.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 2d9d482b..50010fa7 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -3643,6 +3643,143 @@ "summary": "List verified audit seals visible to the caller", "tags": ["audit"] } + }, + "/v1/entitlements/snapshots/{snapshotId}": { + "get": { + "operationId": "EntitlementController.snapshot", + "parameters": [ + { "name": "snapshotId", "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": "Read one immutable entitlement snapshot in the caller scope", + "tags": ["entitlements"] + } + }, + "/v1/entitlements/usage": { + "get": { + "operationId": "EntitlementController.usage", + "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": "", + "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": "Read the append-only usage ledger state in the caller scope", + "tags": ["entitlements"] + } } }, "info": { 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..82b3e5bc --- /dev/null +++ b/services/api/src/features/bua/api/entitlement.controller.ts @@ -0,0 +1,54 @@ +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 { + 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 index 643667cd..5a956f6b 100644 --- a/services/api/src/features/bua/bua.module.ts +++ b/services/api/src/features/bua/bua.module.ts @@ -10,6 +10,12 @@ 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'); @@ -17,6 +23,7 @@ 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({}) @@ -30,9 +37,14 @@ export class BuaModule { 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/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 82d68fd4..e225c763 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -527,6 +527,36 @@ void test('protected artifact reads derive tenant scope from an authenticated ac }); 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', + }); }, ); }); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index bc618ea1..ce3ab996 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -97,6 +97,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', From 39dc6e0ae0acdda159bff8091342daf5e61c284d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:21:28 +0700 Subject: [PATCH 029/177] feat(iam): expose current session identity --- services/api/openapi/v1.json | 80 +++++++++++++++++++ .../iam/api/authentication.controller.ts | 25 +++++- .../features/iam/api/current-session.dto.ts | 22 +++++ services/api/test/http-contract.test.ts | 13 +++ services/api/test/openapi.test.ts | 1 + 5 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iam/api/current-session.dto.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 50010fa7..907560e2 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", @@ -3810,6 +3880,16 @@ "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 } + }, + "required": ["userId", "organizationId", "authorizationEpoch"] + }, "SignInDto": { "type": "object", "properties": { diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index d222efd3..0cc189af 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; -import { Body, Controller, HttpCode, Inject, Optional, Post, Req, Res } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, Inject, Optional, Post, Req, Res } from '@nestjs/common'; import { ApiBody, ApiOkResponse, @@ -29,6 +29,11 @@ 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') @@ -40,8 +45,26 @@ export class AuthenticationController { @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, + }; + } + @Post('sign-in') @HttpCode(200) @ApiOperation({ summary: 'Sign in and issue a short-lived session' }) 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..ba82a6ad --- /dev/null +++ b/services/api/src/features/iam/api/current-session.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { 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; + +} diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index e225c763..37adf389 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -512,6 +512,19 @@ void test('protected artifact reads derive tenant scope from an authenticated ac 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, + }); + const auditEvents = await app.inject({ method: 'GET', url: '/v1/audit/events', diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index ce3ab996..7dedbec0 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -68,6 +68,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/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', From 559124f9bdcf5f6223cc5e2cf6a0dcbcd7e1ee76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:25:09 +0700 Subject: [PATCH 030/177] fix(iam): preserve MFA state in session context --- services/api/openapi/v1.json | 5 +++-- .../api/src/features/iam/api/authentication.controller.ts | 1 + services/api/src/features/iam/api/current-session.dto.ts | 5 ++++- services/api/src/features/iam/application/tenant-context.ts | 5 +++++ .../api/src/platform/http/session-tenant-context.adapter.ts | 1 + services/api/test/http-contract.test.ts | 1 + .../api/test/platform/http/session-tenant-context.test.ts | 1 + 7 files changed, 16 insertions(+), 3 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 907560e2..69c9e6eb 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -3886,9 +3886,10 @@ "userId": { "type": "string", "format": "uuid" }, "organizationId": { "type": "string", "format": "uuid" }, "workspaceId": { "type": "string", "format": "uuid" }, - "authorizationEpoch": { "type": "number", "minimum": 1 } + "authorizationEpoch": { "type": "number", "minimum": 1 }, + "mfaRequired": { "type": "boolean" } }, - "required": ["userId", "organizationId", "authorizationEpoch"] + "required": ["userId", "organizationId", "authorizationEpoch", "mfaRequired"] }, "SignInDto": { "type": "object", diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 0cc189af..598d6040 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -62,6 +62,7 @@ export class AuthenticationController { ? {} : { workspaceId: context.tenantScope.workspaceId }), authorizationEpoch: context.authorizationEpoch, + mfaRequired: context.mfaRequired ?? 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 index ba82a6ad..bf333dd3 100644 --- a/services/api/src/features/iam/api/current-session.dto.ts +++ b/services/api/src/features/iam/api/current-session.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsUUID } from 'class-validator'; +import { IsBoolean, IsOptional, IsUUID } from 'class-validator'; /** IAM-009: the redacted identity projection used to hydrate an authenticated client. */ export class CurrentSessionDto { @@ -19,4 +19,7 @@ export class CurrentSessionDto { @ApiProperty({ minimum: 1 }) authorizationEpoch!: number; + @ApiProperty() + @IsBoolean() + mfaRequired!: boolean; } 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/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts index 9e642e7c..18133fbb 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -95,6 +95,7 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP 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/http-contract.test.ts b/services/api/test/http-contract.test.ts index 37adf389..d15d2a2e 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -523,6 +523,7 @@ void test('protected artifact reads derive tenant scope from an authenticated ac organizationId: principal.organizationId, workspaceId: principal.workspaceId, authorizationEpoch: principal.securityEpoch, + mfaRequired: principal.mfaRequired, }); const auditEvents = await app.inject({ diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts index 327a5b53..5c1da222 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -45,6 +45,7 @@ test('derives a workspace tenant context from a bearer session and never accepts correlationId, idempotencyKey: 'mutation-001', authorizationEpoch: principal.securityEpoch, + mfaRequired: principal.mfaRequired, }); }); From fa3795f4b6d4fbb90e54838b5bac9934ee00eb52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:35:33 +0700 Subject: [PATCH 031/177] fix(ci): satisfy strict TypeScript lint gates --- .../prisma-audit-repository.adapter.ts | 1 - .../adapter/prisma-iam-repository.adapter.ts | 1 - .../src/features/iam/api/mfa.controller.ts | 2 +- .../foundation-module-composition.test.ts | 2 +- .../iam/prisma-credential-lookup.test.ts | 1 + .../iam/prisma-iam-repository.test.ts | 1 + ...isma-identity-bootstrap-repository.test.ts | 1 + .../iam/prisma-mfa-repository.test.ts | 1 + .../iam/prisma-session-lifecycle.test.ts | 1 + .../test/features/iam/session-cookies.test.ts | 6 ++--- services/api/test/http-contract.test.ts | 24 +++++++++++++------ .../platform/http/csrf-protection.test.ts | 8 +++---- .../http/session-tenant-context.test.ts | 14 +++++------ 13 files changed, 38 insertions(+), 25 deletions(-) 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 index 37b23f33..8edd422b 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -5,7 +5,6 @@ import { type AuditActorTypeV1, type AuditEventV1, type AuditSealV1, - type AuditSummaryV1, type AuditDigestPortV1, } from '@databreeze/domain/audit/v1'; import { 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 index d44da532..c1797371 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -2,7 +2,6 @@ import { tenantScopeContainsV1, tenantScopesEqualV1, parseStableIdentifierV1, - parseTenantScopeV1, type StableIdentifierV1, type TenantScopeV1, } from '@databreeze/domain/tenant-scope/v1'; diff --git a/services/api/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts index 5e015517..a5240275 100644 --- a/services/api/src/features/iam/api/mfa.controller.ts +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, HttpCode, Inject, Optional, Param, Post, Req } from '@nestjs/common'; +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'; diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 83fa1c74..58ae1aa7 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -96,7 +96,7 @@ void test('[IAM-005] configured session persistence uses the Prisma lifecycle ad void test('[IAM-009] a session access-token lookup composes one live tenant-context adapter across feature modules', () => { const sessions = { - findPrincipalByAccessToken: async () => undefined, + findPrincipalByAccessToken: () => Promise.resolve(undefined), } as never; const registered = AppModule.register({ sessions }); const iam = registered.imports?.find( diff --git a/services/api/test/features/iam/prisma-credential-lookup.test.ts b/services/api/test/features/iam/prisma-credential-lookup.test.ts index 3b134c15..d54b8c14 100644 --- a/services/api/test/features/iam/prisma-credential-lookup.test.ts +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -1,3 +1,4 @@ +/* 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'; diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index c8f9fbe0..03349d8b 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -1,3 +1,4 @@ +/* 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'; 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 index a1a2cbd3..e1ad5f92 100644 --- a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts +++ b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts @@ -1,3 +1,4 @@ +/* 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'; diff --git a/services/api/test/features/iam/prisma-mfa-repository.test.ts b/services/api/test/features/iam/prisma-mfa-repository.test.ts index 29094f6c..8ef6758b 100644 --- a/services/api/test/features/iam/prisma-mfa-repository.test.ts +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -1,3 +1,4 @@ +/* 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'; diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index 3a3cd095..da1f710a 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -1,3 +1,4 @@ +/* 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'; diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts index 2e6345a3..2563ea30 100644 --- a/services/api/test/features/iam/session-cookies.test.ts +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -12,7 +12,7 @@ import { const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; const refreshToken = `00000000-0000-4000-8000-000000000001.${token}`; -test('serializes bounded session cookies with explicit browser security attributes', () => { +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`, @@ -23,7 +23,7 @@ test('serializes bounded session cookies with explicit browser security attribut ); }); -test('reads one exact cookie value and fails closed for ambiguity or malformed input', () => { +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), @@ -37,7 +37,7 @@ test('reads one exact cookie value and fails closed for ambiguity or malformed i assert.equal(readCookieValueV1(undefined, REFRESH_COOKIE_NAME_V1), undefined); }); -test('creates deletion cookies without weakening the original security attributes', () => { +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`, diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index d15d2a2e..833e0b5b 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -58,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' }); @@ -401,7 +406,8 @@ void test('refresh rotates Web cookies without returning the refresh token and p payload: { clientPlatform: 'desktop', refreshToken: 'desktop-refresh-token' }, }); assert.equal(native.statusCode, 200); - assert.equal(native.json().refreshToken, refreshed.refreshToken); + 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']); }, @@ -496,8 +502,8 @@ void test('protected artifact reads derive tenant scope from an authenticated ac refresh: () => Promise.reject(new Error('not used')), revoke: () => Promise.resolve(true), findPrincipal: () => Promise.resolve(principal), - findPrincipalByAccessToken: async (token) => - token === 'access-token-for-context-1' ? principal : undefined, + findPrincipalByAccessToken: (token) => + Promise.resolve(token === 'access-token-for-context-1' ? principal : undefined), }, }, async (app) => { @@ -593,7 +599,7 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con }); assert.equal(contextResult.accepted, true); if (!contextResult.accepted) return; - const requestTenantContext = { resolve: async () => contextResult.value }; + const requestTenantContext = { resolve: () => Promise.resolve(contextResult.value) }; await withApp({ mfaService, requestTenantContext }, async (app) => { const enrolled = await app.inject({ method: 'POST', @@ -606,8 +612,11 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con }, }); assert.equal(enrolled.statusCode, 200); - assert.equal(enrolled.json().factors[0].status, 'PENDING'); - assert.equal(enrolled.json().factors[0].secretReference, undefined); + 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', @@ -615,7 +624,8 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con payload: { at: '2026-01-01T00:01:00.000Z' }, }); assert.equal(verified.statusCode, 200); - assert.equal(verified.json().factors[0].status, 'ACTIVE'); + const verifiedBody = parsedBody<{ readonly factors: readonly [{ readonly status: string }] }>(verified); + assert.equal(verifiedBody.factors[0].status, 'ACTIVE'); const invalid = await app.inject({ method: 'POST', diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts index cd0c9735..a8c914f1 100644 --- a/services/api/test/platform/http/csrf-protection.test.ts +++ b/services/api/test/platform/http/csrf-protection.test.ts @@ -7,7 +7,7 @@ const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; const allowedOrigins = ['https://app.databreeze.example']; -test('allows safe methods and non-cookie clients without a CSRF token', () => { +void test('allows safe methods and non-cookie clients without a CSRF token', () => { assert.deepEqual( evaluateCsrfRequestV1( { method: 'GET', headers: {} }, @@ -24,7 +24,7 @@ test('allows safe methods and non-cookie clients without a CSRF token', () => { ); }); -test('requires a valid double-submit token for cookie-authenticated mutations', () => { +void test('requires a valid double-submit token for cookie-authenticated mutations', () => { const base = { method: 'POST', headers: { @@ -67,7 +67,7 @@ test('requires a valid double-submit token for cookie-authenticated mutations', ); }); -test('rejects hostile, ambiguous, or missing browser origin signals', () => { +void test('rejects hostile, ambiguous, or missing browser origin signals', () => { const headers = { cookie: `databreeze_refresh=session-value; databreeze_csrf=${token}`, 'x-csrf-token': token, @@ -106,7 +106,7 @@ test('rejects hostile, ambiguous, or missing browser origin signals', () => { ); }); -test('fails closed for duplicate cookies and duplicate token headers', () => { +void test('fails closed for duplicate cookies and duplicate token headers', () => { assert.deepEqual( evaluateCsrfRequestV1( { diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts index 5c1da222..34b0a886 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -12,12 +12,12 @@ const principal = { }; const correlationId = '00000000-0000-4000-8000-000000000010'; -test('derives a workspace tenant context from a bearer session and never accepts client scope fields', async () => { +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: async (token) => { + findPrincipalByAccessToken: (token) => { seen.push(String(token)); - return principal; + return Promise.resolve(principal); }, }); @@ -49,9 +49,9 @@ test('derives a workspace tenant context from a bearer session and never accepts }); }); -test('rejects missing, ambiguous, malformed, and unknown bearer credentials', async () => { +void test('rejects missing, ambiguous, malformed, and unknown bearer credentials', async () => { const adapter = new SessionRequestTenantContextAdapter({ - findPrincipalByAccessToken: async () => undefined, + findPrincipalByAccessToken: () => Promise.resolve(undefined), }); for (const request of [ { headers: {} }, @@ -66,9 +66,9 @@ test('rejects missing, ambiguous, malformed, and unknown bearer credentials', as } }); -test('uses the request id for read-only calls and rejects unsafe principal state', async () => { +void test('uses the request id for read-only calls and rejects unsafe principal state', async () => { const adapter = new SessionRequestTenantContextAdapter({ - findPrincipalByAccessToken: async () => ({ ...principal, securityEpoch: 0 }), + findPrincipalByAccessToken: () => Promise.resolve({ ...principal, securityEpoch: 0 }), }); await assert.rejects( adapter.resolve({ id: 'request-read-001', headers: { authorization: 'Bearer token' } }), From 238ce5277166b47fdd9a12c6c983af983f1f06eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:44:03 +0700 Subject: [PATCH 032/177] fix(ci): normalize repository formatting --- packages/domain/src/pkce/v1.ts | 17 +++-- .../prisma-audit-repository.adapter.ts | 3 +- .../prisma-entitlement-repository.adapter.ts | 65 +++++++++++++------ .../bua/api/entitlement.controller.ts | 11 ++-- .../in-memory-session-lifecycle.adapter.ts | 4 +- .../prisma-credential-lookup.adapter.ts | 11 ++-- .../adapter/prisma-iam-repository.adapter.ts | 16 +++-- ...a-identity-bootstrap-repository.adapter.ts | 32 ++++++--- .../adapter/prisma-mfa-repository.adapter.ts | 42 +++++++++--- .../prisma-session-lifecycle.adapter.ts | 43 ++++++------ .../iam/api/authentication.controller.ts | 14 +++- .../src/features/iam/api/mfa.controller.ts | 6 +- services/api/src/features/iam/api/mfa.dto.ts | 10 ++- services/api/src/features/iam/iam.module.ts | 24 +++++-- .../api/src/platform/http/csrf-protection.ts | 33 ++++++++-- .../platform/http/problem-details.filter.ts | 4 +- .../http/session-tenant-context.adapter.ts | 7 +- .../bua/prisma-entitlement-repository.test.ts | 33 ++++++++-- .../foundation-module-composition.test.ts | 5 +- .../iam/prisma-credential-lookup.test.ts | 4 +- .../iam/prisma-iam-repository.test.ts | 23 +++++-- ...isma-identity-bootstrap-repository.test.ts | 49 ++++++++++---- .../iam/prisma-mfa-repository.test.ts | 25 +++++-- .../iam/prisma-session-lifecycle.test.ts | 27 +++++--- .../test/features/iam/session-cookies.test.ts | 20 ++++-- services/api/test/http-contract.test.ts | 22 +++++-- .../platform/http/csrf-protection.test.ts | 26 ++++---- 27 files changed, 402 insertions(+), 174 deletions(-) diff --git a/packages/domain/src/pkce/v1.ts b/packages/domain/src/pkce/v1.ts index a274a316..cb6e8385 100644 --- a/packages/domain/src/pkce/v1.ts +++ b/packages/domain/src/pkce/v1.ts @@ -38,14 +38,19 @@ export function createPkceChallengeV1( verifierInput: unknown, hashPort: PkceHashPortV1, ): PkceResultV1 { - if (!validVerifier(verifierInput)) return Object.freeze({ accepted: false, code: 'INVALID_VERIFIER' }); + 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 }), + value: Object.freeze({ + schemaVersion: PKCE_SCHEMA_VERSION_V1, + method: 'S256' as const, + challenge, + }), }); } catch { return Object.freeze({ accepted: false, code: 'CRYPTO_UNAVAILABLE' }); @@ -57,14 +62,18 @@ export function verifyPkceChallengeV1( challengeInput: unknown, hashPort: PkceHashPortV1, ): boolean { - if (typeof challengeInput !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(challengeInput)) return false; + 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') + 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; 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 index 8edd422b..202c5b25 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -146,7 +146,8 @@ function positiveInteger(input: unknown): number | 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 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); 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 index 2af9dfcd..13d5019f 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -240,10 +240,12 @@ function persistedPlan(row: EntitlementPlanDatabaseRowV1): EntitlementPlanV1 { 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 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 expiresAt = + row.expiresAt === null ? undefined : parseStrictUtcTimestampV1(row.expiresAt.toISOString()); const features = parseFeatures(row.features); const quotas = parseQuotas(row.quotas); if ( @@ -282,8 +284,10 @@ function persistedSnapshot(row: EntitlementSnapshotDatabaseRowV1): EntitlementSn 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 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 ( @@ -353,14 +357,22 @@ function snapshotCreateData(snapshot: EntitlementSnapshotV1): EntitlementSnapsho return { ...databaseScope( snapshot.workspaceId - ? { scopeType: 'workspace', organizationId: snapshot.organizationId, workspaceId: 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: 'workspace', + organizationId: snapshot.organizationId, + workspaceId: snapshot.workspaceId, + } : { scopeType: 'organization', organizationId: snapshot.organizationId }, ), planCode: snapshot.planCode, @@ -392,9 +404,7 @@ function entryCreateData(entry: UsageLedgerEntryV1): UsageLedgerEntryCreateDataV }; } -function reservationCreateData( - reservation: UsageReservationV1, -): UsageReservationCreateDataV1 { +function reservationCreateData(reservation: UsageReservationV1): UsageReservationCreateDataV1 { return { ...databaseScope(reservation.tenantScope), id: reservation.reservationId, @@ -437,7 +447,9 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV await this.client.entitlementPlanRecord.create({ data: planCreateData(plan) }); } - public async findPlan(planCode: EntitlementPlanV1['planCode']): Promise { + public async findPlan( + planCode: EntitlementPlanV1['planCode'], + ): Promise { const row = await this.client.entitlementPlanRecord.findUnique({ where: { planCode } }); return row === null ? undefined : persistedPlan(row); } @@ -447,7 +459,11 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV snapshot: EntitlementSnapshotV1, ): Promise { const scope = snapshot.workspaceId - ? { scopeType: 'workspace' as const, organizationId: snapshot.organizationId, workspaceId: 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'); @@ -466,11 +482,17 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV context: IamTenantContextV1, snapshotId: EntitlementSnapshotV1['snapshotId'], ): Promise { - const row = await this.client.entitlementSnapshotRecord.findUnique({ where: { id: snapshotId } }); + 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: 'workspace' as const, + organizationId: snapshot.organizationId, + workspaceId: snapshot.workspaceId, + } : { scopeType: 'organization' as const, organizationId: snapshot.organizationId }; return visible(context.tenantScope, scope) ? snapshot : undefined; } @@ -489,12 +511,16 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV return Object.freeze({ entries: Object.freeze( entryRows - .filter((row) => visible(context.tenantScope, persistedScope({ ...row, projectId: null }))) + .filter((row) => + visible(context.tenantScope, persistedScope({ ...row, projectId: null })), + ) .map(persistedEntry), ), reservations: Object.freeze( reservationRows - .filter((row) => visible(context.tenantScope, persistedScope({ ...row, projectId: null }))) + .filter((row) => + visible(context.tenantScope, persistedScope({ ...row, projectId: null })), + ) .map(persistedReservation), ), }); @@ -524,7 +550,9 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV where: { id: reservation.reservationId }, }); if (existing === null) { - await this.client.usageReservationRecord.create({ data: reservationCreateData(reservation) }); + await this.client.usageReservationRecord.create({ + data: reservationCreateData(reservation), + }); continue; } const current = persistedReservation(existing); @@ -578,10 +606,7 @@ export class PrismaEntitlementRepositoryAdapter implements EntitlementRepository return new PrismaEntitlementTransactionAdapter(this.client).listUsageState(context); } - public persistUsageState( - context: IamTenantContextV1, - state: UsageLedgerStateV1, - ): Promise { + 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 index 82b3e5bc..0813c5ea 100644 --- a/services/api/src/features/bua/api/entitlement.controller.ts +++ b/services/api/src/features/bua/api/entitlement.controller.ts @@ -1,10 +1,7 @@ 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 type { EntitlementSnapshotV1, UsageLedgerStateV1 } from '@databreeze/domain/entitlements/v1'; import { ENTITLEMENT_REPOSITORY_PORT, @@ -33,7 +30,11 @@ export class EntitlementController { async snapshot( @Req() request: unknown, @Param('snapshotId') snapshotIdInput: string, - ): Promise { + ): 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' }; 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 e25db51b..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 @@ -185,7 +185,9 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { return true; } - public async findPrincipal(sessionIdInput: unknown): Promise { + public async findPrincipal( + sessionIdInput: unknown, + ): Promise { await Promise.resolve(); if (typeof sessionIdInput !== 'string') return undefined; const session = this.sessions.get(sessionIdInput); 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 index 1492db36..4830cfc5 100644 --- a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts @@ -46,9 +46,7 @@ export interface MfaFactorDatabaseRowV1 { } interface UniqueDelegateV1 { - findUnique(input: { - readonly where: Readonly>; - }): Promise; + findUnique(input: { readonly where: Readonly> }): Promise; } interface WorkspaceLookupDelegateV1 extends UniqueDelegateV1 { @@ -87,8 +85,7 @@ function activeMembership( row: MembershipIdentityDatabaseRowV1, userId: string, ): ActiveMembershipV1 | undefined { - if (row.principalId !== userId || row.status !== 'ACTIVE') - return undefined; + if (row.principalId !== userId || row.status !== 'ACTIVE') return undefined; const organizationId = stableId(row.organizationId); if (!organizationId) return undefined; if (row.scopeType === 'WORKSPACE') { @@ -163,7 +160,9 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { workspaceId = workspace ? stableId(workspace.id) : undefined; } if (!workspaceId) return undefined; - const workspace = await this.client.workspaceIdentity.findUnique({ where: { id: workspaceId } }); + const workspace = await this.client.workspaceIdentity.findUnique({ + where: { id: workspaceId }, + }); if ( !organization || organization.id !== selected.organizationId || 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 index c1797371..ccf18823 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -30,7 +30,9 @@ export interface IamMembershipDatabaseRowV1 { } interface IamMembershipDelegateV1 { - findUnique(input: { readonly where: { readonly id: string } }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; findMany(input: { readonly where: Readonly>; }): Promise; @@ -99,7 +101,8 @@ function membershipRow(membership: MembershipIdentityV1): IamMembershipDatabaseR principalId: membership.principalId, scopeType: membership.scope.scopeType.toUpperCase(), organizationId: membership.scope.organizationId, - workspaceId: membership.scope.scopeType === 'organization' ? null : membership.scope.workspaceId, + workspaceId: + membership.scope.scopeType === 'organization' ? null : membership.scope.workspaceId, projectId: membership.scope.scopeType === 'project' ? membership.scope.projectId : null, roleId: membership.roleId, status: membership.status, @@ -131,7 +134,9 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { ); } - public async listMemberships(context: IamTenantContextV1): Promise { + public async listMemberships( + context: IamTenantContextV1, + ): Promise { const rows = await this.client.membershipIdentity.findMany({ where: {} }); return rows .map(membershipFromRow) @@ -157,7 +162,10 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { 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)) + 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 }, 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 index eeaf1ded..f30ecced 100644 --- 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 @@ -5,7 +5,10 @@ import { type PersonalOrganizationBootstrapV1, type UserIdentityV1, } from '@databreeze/domain/identity/v1'; -import { parseStableIdentifierV1, parseStrictUtcTimestampV1 } from '@databreeze/domain/tenant-scope/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; import type { IdentityBootstrapRepositoryPortV1, @@ -70,13 +73,13 @@ interface IdentityDelegateV1 { } interface ListDelegateV1 { - findMany(input: { - readonly where: Readonly>; - }): Promise; + findMany(input: { readonly where: Readonly> }): Promise; } interface UserDelegateV1 { - findUnique(input: { readonly where: { readonly id: string } }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; } interface MembershipDelegateV1 extends IdentityDelegateV1 { @@ -122,7 +125,10 @@ function userFromRow(row: UserIdentityDatabaseRowV1): UserIdentityV1 { return created.value; } -function membershipMatches(row: MembershipIdentityDatabaseRowV1, expected: MembershipIdentityV1): boolean { +function membershipMatches( + row: MembershipIdentityDatabaseRowV1, + expected: MembershipIdentityV1, +): boolean { return ( row.id === expected.id && row.principalType === expected.principalType && @@ -172,7 +178,9 @@ function bootstrapRowsMatch( class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTransactionPortV1 { public constructor(private readonly client: IdentityBootstrapDatabaseClientV1) {} - public async findByUserId(userId: PersonalOrganizationBootstrapV1['user']['id']): Promise { + 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); @@ -190,8 +198,11 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran 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 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' }, }); @@ -278,7 +289,8 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran ): 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'); + if (JSON.stringify(existing) !== JSON.stringify(expected)) + throw new Error('IAM_BOOTSTRAP_CONFLICT'); return; } await delegate.create({ data: expected }); 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 index 3f570176..199897cc 100644 --- a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts @@ -39,8 +39,12 @@ export interface MfaRecoveryCodeDatabaseRowV1 { } interface MfaFactorDelegateV1 { - findMany(input: { readonly where: Readonly> }): Promise; - findUnique(input: { readonly where: { readonly id: string } }): Promise; + 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 }; @@ -49,9 +53,15 @@ interface MfaFactorDelegateV1 { } interface MfaRecoveryCodeDelegateV1 { - findMany(input: { readonly where: Readonly> }): Promise; - findUnique(input: { readonly where: { readonly id: string } }): Promise; - create(input: { readonly data: MfaRecoveryCodeDatabaseRowV1 }): Promise; + 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; @@ -165,20 +175,32 @@ function recoveryRow(code: RecoveryCodeV1): MfaRecoveryCodeDatabaseRowV1 { 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))) + 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))) + 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 && + (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 !== prior.revision && code.revision !== prior.revision + 1) + return false; if (!prior && code.revision !== 1) return false; } return true; 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 index 257fda7b..e5de3a6c 100644 --- a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -88,9 +88,7 @@ export interface SessionMfaFactorDatabaseRowV1 { } interface SessionDelegateV1 { - create(input: { - readonly data: SessionRecordDatabaseRowV1; - }): Promise; + create(input: { readonly data: SessionRecordDatabaseRowV1 }): Promise; findUnique(input: { readonly where: { readonly id: string }; }): Promise; @@ -101,9 +99,7 @@ interface SessionDelegateV1 { } interface RefreshTokenDelegateV1 { - create(input: { - readonly data: RefreshTokenDatabaseRowV1; - }): Promise; + create(input: { readonly data: RefreshTokenDatabaseRowV1 }): Promise; findUnique(input: { readonly where: { readonly tokenDigest: string }; }): Promise; @@ -117,9 +113,7 @@ interface RefreshTokenDelegateV1 { } interface AccessTokenDelegateV1 { - create(input: { - readonly data: AccessTokenDatabaseRowV1; - }): Promise; + create(input: { readonly data: AccessTokenDatabaseRowV1 }): Promise; findUnique(input: { readonly where: { readonly tokenDigest: string }; }): Promise; @@ -130,15 +124,11 @@ interface AccessTokenDelegateV1 { } interface UniqueDelegateV1 { - findUnique(input: { - readonly where: Readonly>; - }): Promise; + findUnique(input: { readonly where: Readonly> }): Promise; } interface ListDelegateV1 { - findMany(input: { - readonly where: Readonly>; - }): Promise; + findMany(input: { readonly where: Readonly> }): Promise; } export interface SessionLifecycleDatabaseClientV1 { @@ -218,7 +208,12 @@ function tokenFromRow(row: RefreshTokenDatabaseRowV1): { 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') + 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 }; } @@ -379,7 +374,10 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { 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) }, + data: { + accessExpiresAt: new Date(accessExpiresAt), + inactivityExpiresAt: new Date(inactivityExpiresAt), + }, }); const nextRefreshToken = tokenFor(rotated.nextTokenId); const nextAccessTokenId = stableIdentifier(randomUUID()); @@ -450,19 +448,24 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { const row = await this.client.accessTokenRecord.findUnique({ where: { tokenDigest: digestToken(accessTokenInput) }, }); - if (!row || row.status !== 'ACTIVE' || row.expiresAt.getTime() <= this.clock().getTime()) return undefined; + 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 { + 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 } }); + 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()); diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 598d6040..594f5d44 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -15,7 +15,10 @@ 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 { + SESSION_LIFECYCLE_PORT, + type SessionLifecyclePortV1, +} from '../application/session-lifecycle.port.js'; import { SessionProblemError } from '../application/session-problem.error.js'; import { CSRF_COOKIE_NAME_V1, @@ -98,7 +101,9 @@ export class AuthenticationController { organizationId: result.value.principal.organizationId, workspaceId: result.value.principal.workspaceId, accessToken: result.value.session.accessToken, - ...(input.clientPlatform === 'web' ? {} : { 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, @@ -122,7 +127,10 @@ export class AuthenticationController { input.clientPlatform === 'web' ? readCookieValueV1(request.headers.cookie, REFRESH_COOKIE_NAME_V1) : input.refreshToken; - if (refreshToken === undefined || (input.clientPlatform === 'web' && input.refreshToken !== undefined)) { + if ( + refreshToken === undefined || + (input.clientPlatform === 'web' && input.refreshToken !== undefined) + ) { throw new SessionProblemError('SESSION_INVALID'); } const result = await this.sessions.refresh(refreshToken, input.clientPlatform); diff --git a/services/api/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts index a5240275..df7e017d 100644 --- a/services/api/src/features/iam/api/mfa.controller.ts +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -7,11 +7,7 @@ import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; -import { - EnrollMfaFactorDto, - RedeemMfaRecoveryCodeDto, - VerifyMfaFactorDto, -} from './mfa.dto.js'; +import { EnrollMfaFactorDto, RedeemMfaRecoveryCodeDto, VerifyMfaFactorDto } from './mfa.dto.js'; @ApiTags('auth') @ApiBearerAuth() diff --git a/services/api/src/features/iam/api/mfa.dto.ts b/services/api/src/features/iam/api/mfa.dto.ts index d0b57489..db58a085 100644 --- a/services/api/src/features/iam/api/mfa.dto.ts +++ b/services/api/src/features/iam/api/mfa.dto.ts @@ -1,5 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsIn, IsISO8601, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; +import { + IsIn, + IsISO8601, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; export class EnrollMfaFactorDto { @ApiProperty({ format: 'uuid' }) diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 8a5fabfa..6bf6257e 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -17,9 +17,15 @@ 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_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 { + 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 { @@ -34,8 +40,14 @@ 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 { + 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 { @@ -70,7 +82,9 @@ export interface IamModuleOptions { readonly mfaRepository?: MfaRepositoryPortV1; readonly mfaDatabase?: MfaDatabaseClientV1; readonly mfaService?: MfaService; - readonly recoveryCodeMatcher?: { matches(presentedDigest: string, storedDigest: string): boolean }; + readonly recoveryCodeMatcher?: { + matches(presentedDigest: string, storedDigest: string): boolean; + }; readonly iamRepository?: IamRepositoryPortV1; readonly iamDatabase?: IamDatabaseClientV1; readonly deviceIdentityService?: DeviceIdentityService; diff --git a/services/api/src/platform/http/csrf-protection.ts b/services/api/src/platform/http/csrf-protection.ts index d16bba3c..af209493 100644 --- a/services/api/src/platform/http/csrf-protection.ts +++ b/services/api/src/platform/http/csrf-protection.ts @@ -16,27 +16,42 @@ export interface CsrfProtectionOptionsV1 { export type CsrfRequestResultV1 = | { readonly accepted: true } - | { readonly accepted: false; readonly code: 'CSRF_REQUIRED' | 'CSRF_INVALID' | 'ORIGIN_INVALID' }; + | { + 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 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 } { +): + | { 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 }; + 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 } { +function parseCookies(raw: string): { + readonly values: ReadonlyMap; + readonly duplicateNames: ReadonlySet; + readonly malformed: boolean; +} { const values = new Map(); const duplicateNames = new Set(); let malformed = false; @@ -67,7 +82,10 @@ function hasCookieAuth(cookies: ReturnType): boolean { return false; } -function originAccepted(headers: CsrfRequestV1['headers'], options: CsrfProtectionOptionsV1): boolean { +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); @@ -91,7 +109,8 @@ export function evaluateCsrfRequestV1( 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 }); + 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 }); diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 1fc4353a..75a9262b 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -60,9 +60,7 @@ function describe(error: unknown, correlationId: string): ProblemInput { return { code: invalidContext ? 'CONTEXT_INVALID' : 'AUTHENTICATION_FAILED', correlationId, - messageKey: invalidContext - ? 'api.error.context_invalid' - : 'api.error.authentication_failed', + messageKey: invalidContext ? 'api.error.context_invalid' : 'api.error.authentication_failed', retryable: false, status: invalidContext ? HttpStatus.BAD_REQUEST : HttpStatus.UNAUTHORIZED, }; diff --git a/services/api/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts index 18133fbb..c7d2f4ef 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -23,9 +23,7 @@ interface RequestLikeV1 { } export interface SessionPrincipalLookupV1 { - findPrincipalByAccessToken( - accessToken: unknown, - ): Promise; + findPrincipalByAccessToken(accessToken: unknown): Promise; } function requestLike(input: unknown): RequestLikeV1 | undefined { @@ -84,7 +82,8 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP } catch { throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); } - if (principal === undefined) throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + if (principal === undefined) + throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); const context = createIamTenantContextV1({ tenantScope: { scopeType: 'workspace', diff --git a/services/api/test/features/bua/prisma-entitlement-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-repository.test.ts index 122cb3ea..de4527ec 100644 --- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -79,9 +79,15 @@ function delegate>(rows: TRow[]) { rows.push(persisted); return Promise.resolve(persisted); }, - findUnique({ where }: { readonly where: { readonly id?: string; readonly planCode?: string } }) { + 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); + return Promise.resolve( + rows.find((row) => row['id'] === key || row['planCode'] === key) ?? null, + ); }, findMany({ where, @@ -97,7 +103,8 @@ function delegate>(rows: TRow[]) { return Promise.resolve( [...filtered].sort((left, right) => { if (!field) return 0; - const comparison = left[field]! < right[field]! ? -1 : left[field]! > right[field]! ? 1 : 0; + const comparison = + left[field]! < right[field]! ? -1 : left[field]! > right[field]! ? 1 : 0; return direction === 'desc' ? -comparison : comparison; }), ); @@ -167,12 +174,21 @@ void test('[BUA-001, BUA-002, BUA-008, IAM-009] Prisma entitlement adapter persi 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')); + 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), + (await repository.listUsageState(context(siblingWorkspaceId, 'sibling'))).entries.length, + 0, + ); + assert.equal( + await repository.findSnapshot( + context(siblingWorkspaceId, 'snapshot-sibling'), + snapshot().snapshotId, + ), undefined, ); }); @@ -181,7 +197,10 @@ void test('[BUA-012] Prisma entitlement adapter applies reservation status revis 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')); + 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'), { diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 58ae1aa7..d644d1bb 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -101,7 +101,10 @@ void test('[IAM-009] a session access-token lookup composes one live tenant-cont const registered = AppModule.register({ sessions }); const iam = registered.imports?.find( (candidate) => - typeof candidate === 'object' && candidate !== null && 'module' in candidate && candidate.module === IamModule, + 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; diff --git a/services/api/test/features/iam/prisma-credential-lookup.test.ts b/services/api/test/features/iam/prisma-credential-lookup.test.ts index d54b8c14..2636c30a 100644 --- a/services/api/test/features/iam/prisma-credential-lookup.test.ts +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -81,7 +81,9 @@ void test('[IAM-001, IAM-002, IAM-009] credential lookup returns only an active, 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' }) }, + organizationIdentity: { + findUnique: async () => ({ id: organizationId, status: 'SUSPENDED' }), + }, }), ); assert.equal(await inactive.findCredential('user@example.com'), undefined); diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index 03349d8b..935ed118 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { parseStableIdentifierV1, type StableIdentifierV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; import { PrismaIamRepositoryAdapter, @@ -37,7 +41,12 @@ function context(scope: TenantScopeV1, expectedRevision?: number) { return result.value; } -function row(idValue: string, scope: 'WORKSPACE' | 'ORGANIZATION', workspace: string | null, roleId: string): IamMembershipDatabaseRowV1 { +function row( + idValue: string, + scope: 'WORKSPACE' | 'ORGANIZATION', + workspace: string | null, + roleId: string, +): IamMembershipDatabaseRowV1 { return { id: idValue, principalType: 'USER', @@ -61,10 +70,13 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { 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, + 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), + Object.entries(where).every( + ([key, value]) => candidate[key as keyof IamMembershipDatabaseRowV1] === value, + ), ), create: async ({ data }: { readonly data: IamMembershipDatabaseRowV1 }) => { memberships.set(data.id, data); @@ -108,7 +120,8 @@ void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and 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, + (await repository.listMemberships(context({ scopeType: 'organization', organizationId }))) + .length, 3, ); assert.equal( 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 index e1ad5f92..4a187435 100644 --- a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts +++ b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts @@ -42,7 +42,18 @@ function createDatabase(): { 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 }], + [ + 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(); @@ -50,24 +61,29 @@ function createDatabase(): { const memberships = new Map(); const client = { userIdentity: { - findUnique: async ({ where }: { readonly where: { readonly id: string } }) => users.get(where.id) ?? null, + 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, + 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, + 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), + Object.entries(where).every( + ([key, value]) => row[key as keyof WorkspaceIdentityDatabaseRowV1] === value, + ), ), }, projectIdentity: { @@ -75,10 +91,13 @@ function createDatabase(): { projects.set(data.id, data); return data; }, - findUnique: async ({ where }: { readonly where: { readonly id: string } }) => projects.get(where.id) ?? null, + 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), + Object.entries(where).every( + ([key, value]) => row[key as keyof ProjectIdentityDatabaseRowV1] === value, + ), ), }, membershipIdentity: { @@ -86,13 +105,18 @@ function createDatabase(): { memberships.set(data.id, data); return data; }, - findUnique: async ({ where }: { readonly where: { readonly id: string } }) => memberships.get(where.id) ?? null, + 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), + Object.entries(where).every( + ([key, value]) => row[key as keyof MembershipIdentityDatabaseRowV1] === value, + ), ), }, - $transaction: async (work: (transaction: IdentityBootstrapDatabaseClientV1) => Promise) => { + $transaction: async ( + work: (transaction: IdentityBootstrapDatabaseClientV1) => Promise, + ) => { const before = { organizations: new Map(organizations), workspaces: new Map(workspaces), @@ -142,7 +166,10 @@ void test('[IAM-011] repeated bootstrap is immutable and conflicting hierarchy i 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' } }), + adapter.save({ + ...validated.value, + organization: { ...validated.value.organization, name: 'Changed' }, + }), /IAM_BOOTSTRAP_CONFLICT/, ); }); diff --git a/services/api/test/features/iam/prisma-mfa-repository.test.ts b/services/api/test/features/iam/prisma-mfa-repository.test.ts index 8ef6758b..05c5a0c6 100644 --- a/services/api/test/features/iam/prisma-mfa-repository.test.ts +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createMfaFactorV1, createRecoveryCodeV1, transitionMfaFactorV1 } from '@databreeze/domain/mfa/v1'; +import { + createMfaFactorV1, + createRecoveryCodeV1, + transitionMfaFactorV1, +} from '@databreeze/domain/mfa/v1'; import type { MfaStateV1 } from '@databreeze/domain/mfa/v1'; import { @@ -28,9 +32,12 @@ function createDatabase(): { mfaFactor: { findMany: async ({ where }: { readonly where: Readonly> }) => [...factors.values()].filter((row) => - Object.entries(where).every(([key, value]) => row[key as keyof MfaFactorDatabaseRowV1] === value), + 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, + 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; @@ -52,9 +59,12 @@ function createDatabase(): { mfaRecoveryCode: { findMany: async ({ where }: { readonly where: Readonly> }) => [...recoveryCodes.values()].filter((row) => - Object.entries(where).every(([key, value]) => row[key as keyof MfaRecoveryCodeDatabaseRowV1] === value), + 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, + 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; @@ -150,7 +160,10 @@ void test('[IAM-009, IAM-012] MFA state cannot cross users and failed transactio 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.saveState('00000000-0000-4000-8000-000000000099', input), + /MFA_SCOPE_MISMATCH/, + ); await assert.rejects( adapter.withTransaction(async (transaction) => { await transaction.saveState(factor.userId, input); diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index da1f710a..f4eee384 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -59,13 +59,11 @@ function createDatabase(): { }, findUnique: async ({ where }: { readonly where: { readonly tokenDigest: string } }) => [...refreshTokens.values()].find((row) => row.tokenDigest === where.tokenDigest) ?? null, - findMany: async ({ - where, - }: { - readonly where: Readonly>; - }) => + findMany: async ({ where }: { readonly where: Readonly> }) => [...refreshTokens.values()].filter((row) => - Object.entries(where).every(([key, value]) => row[key as keyof RefreshTokenDatabaseRowV1] === value), + Object.entries(where).every( + ([key, value]) => row[key as keyof RefreshTokenDatabaseRowV1] === value, + ), ), updateMany: async ({ where, @@ -76,7 +74,11 @@ function createDatabase(): { }) => { let count = 0; for (const [id, row] of refreshTokens) { - if (!Object.entries(where).every(([key, value]) => row[key as keyof RefreshTokenDatabaseRowV1] === value)) + if ( + !Object.entries(where).every( + ([key, value]) => row[key as keyof RefreshTokenDatabaseRowV1] === value, + ) + ) continue; refreshTokens.set(id, { ...row, ...data }); count += 1; @@ -100,7 +102,11 @@ function createDatabase(): { }) => { let count = 0; for (const [id, row] of accessTokens) { - if (!Object.entries(where).every(([key, value]) => row[key as keyof AccessTokenDatabaseRowV1] === value)) + if ( + !Object.entries(where).every( + ([key, value]) => row[key as keyof AccessTokenDatabaseRowV1] === value, + ) + ) continue; accessTokens.set(id, { ...row, ...data }); count += 1; @@ -133,8 +139,9 @@ function createDatabase(): { mfaFactor: { findMany: async () => [{ id: '00000000-0000-4000-8000-000000000005' }], }, - $transaction: async (work: (transaction: SessionLifecycleDatabaseClientV1) => Promise) => - work(client), + $transaction: async ( + work: (transaction: SessionLifecycleDatabaseClientV1) => Promise, + ) => work(client), } as unknown as SessionLifecycleDatabaseClientV1; return { client, sessions, refreshTokens, accessTokens }; } diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts index 2563ea30..8b686e8f 100644 --- a/services/api/test/features/iam/session-cookies.test.ts +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -14,7 +14,10 @@ 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 }), + 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( @@ -24,13 +27,22 @@ void test('serializes bounded session cookies with explicit browser security att }); 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), + readCookieValueV1(`${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), + 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); diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 833e0b5b..c2e39d03 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -315,7 +315,10 @@ void test('sign-in returns a session DTO and maps authentication failures withou 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[0] ?? '', + /^databreeze_refresh=refresh-token; .*HttpOnly; Secure; SameSite=Lax$/, + ); assert.match(setCookies[1] ?? '', /^databreeze_csrf=[A-Za-z0-9_-]+; .*Secure; SameSite=Lax$/); assertResponseIdentifiers(response); }, @@ -397,7 +400,10 @@ void test('refresh rotates Web cookies without returning the refresh token and p 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[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({ @@ -417,7 +423,8 @@ void test('refresh rotates Web cookies without returning the refresh token and p { sessions: { issue: () => Promise.reject(new Error('not used')), - refresh: () => Promise.resolve({ accepted: false as const, code: 'REUSE_DETECTED' as const }), + refresh: () => + Promise.resolve({ accepted: false as const, code: 'REUSE_DETECTED' as const }), revoke: () => Promise.resolve(true), findPrincipal: () => Promise.resolve(undefined), }, @@ -466,7 +473,10 @@ void test('sign-out revokes idempotently and clears browser credentials', async 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[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({ @@ -624,7 +634,9 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con payload: { at: '2026-01-01T00:01:00.000Z' }, }); assert.equal(verified.statusCode, 200); - const verifiedBody = parsedBody<{ readonly factors: readonly [{ readonly status: string }] }>(verified); + const verifiedBody = parsedBody<{ readonly factors: readonly [{ readonly status: string }] }>( + verified, + ); assert.equal(verifiedBody.factors[0].status, 'ACTIVE'); const invalid = await app.inject({ diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts index a8c914f1..ebf3cd85 100644 --- a/services/api/test/platform/http/csrf-protection.test.ts +++ b/services/api/test/platform/http/csrf-protection.test.ts @@ -8,13 +8,9 @@ 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: 'GET', headers: {} }, { allowedOrigins }), { + accepted: true, + }); assert.deepEqual( evaluateCsrfRequestV1( { method: 'POST', headers: { authorization: 'Bearer access-token' } }, @@ -82,18 +78,18 @@ void test('rejects hostile, ambiguous, or missing browser origin signals', () => ); 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 }, + { + 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( { From 8022db16851cb63f055331bdc9cb6a0dfbbdb81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:04:35 +0700 Subject: [PATCH 033/177] feat(iae): expose exact artifact version reads --- .../iae/api/artifact-read.controller.ts | 53 +++++++ services/api/src/features/iae/iae.module.ts | 3 +- .../iae/artifact-read.controller.test.ts | 137 ++++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/api/artifact-read.controller.ts create mode 100644 services/api/test/features/iae/artifact-read.controller.test.ts diff --git a/services/api/src/features/iae/api/artifact-read.controller.ts b/services/api/src/features/iae/api/artifact-read.controller.ts new file mode 100644 index 00000000..d2df83b2 --- /dev/null +++ b/services/api/src/features/iae/api/artifact-read.controller.ts @@ -0,0 +1,53 @@ +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 { + ARTIFACT_REPOSITORY_PORT, + type ArtifactRepositoryPortV1, +} from '../application/artifact-repository.port.js'; +import { ArtifactService } from '../application/artifact.service.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +/** IAE-006, IAE-008, IAE-019, IAE-020: content-free exact-version reads. */ +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifact-versions') +export class ArtifactReadController { + private readonly artifacts: ArtifactService; + + public constructor( + @Inject(ARTIFACT_REPOSITORY_PORT) repository: ArtifactRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.artifacts = new ArtifactService(repository); + } + + @Get(':versionId') + @ApiOperation({ summary: 'Read immutable artifact-version metadata and placements' }) + async get(@Req() request: unknown, @Param('versionId') versionIdInput: string): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!versionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const result = await this.artifacts.find(context, versionId.value); + if (!result.version) return { accepted: false, code: 'NOT_FOUND' as const }; + return Object.freeze({ accepted: true, value: result }); + } + + @Get(':versionId/evidence') + @ApiOperation({ summary: 'List typed evidence references for one immutable version' }) + async evidence( + @Req() request: unknown, + @Param('versionId') versionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!versionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const result = await this.artifacts.find(context, versionId.value); + if (!result.version) return { accepted: false, code: 'NOT_FOUND' as const }; + return Object.freeze({ accepted: true, value: result.evidence }); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index bff8b5a2..fc7a25d6 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -2,6 +2,7 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { InboxController } from './api/inbox.controller.js'; import { EvidenceGrantController } from './api/evidence-grant.controller.js'; +import { ArtifactReadController } from './api/artifact-read.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -47,7 +48,7 @@ export class IaeModule { public static register(options: IaeModuleOptions = {}): DynamicModule { return { module: IaeModule, - controllers: [InboxController, EvidenceGrantController], + controllers: [InboxController, EvidenceGrantController, ArtifactReadController], providers: [ { provide: ARTIFACT_INTAKE_REPOSITORY_PORT, diff --git a/services/api/test/features/iae/artifact-read.controller.test.ts b/services/api/test/features/iae/artifact-read.controller.test.ts new file mode 100644 index 00000000..5b0f6904 --- /dev/null +++ b/services/api/test/features/iae/artifact-read.controller.test.ts @@ -0,0 +1,137 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { ArtifactService } from '../../../src/features/iae/application/artifact.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000621'; +const workspaceId = '00000000-0000-4000-8000-000000000622'; +const artifactId = '00000000-0000-4000-8000-000000000623'; +const versionId = '00000000-0000-4000-8000-000000000624'; +const placementId = '00000000-0000-4000-8000-000000000625'; +const evidenceId = '00000000-0000-4000-8000-000000000626'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000627', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000628', + idempotencyKey: 'artifact-read', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[IAE-006, IAE-008, IAE-019, IAE-020] artifact reads return exact content-free metadata', async () => { + const repository = new InMemoryArtifactRepositoryAdapter(); + const tenantContext = context(); + const service = new ArtifactService(repository); + const created = await service.register(tenantContext, { + version: { + artifactId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceKind: 'FILE', + dataMode: 'Local', + contentSha256: 'a'.repeat(64), + byteSize: 10, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: '2026-01-01T00:00:00.000Z', + }, + placement: { + placementId, + tenantScope: tenantContext.tenantScope, + kind: 'LOCAL', + opaqueReference: 'local-placement-000001', + contentSha256: 'a'.repeat(64), + }, + evidence: { + evidenceId, + tenantScope: tenantContext.tenantScope, + coordinate: { kind: 'ROW', row: 1, field: 'amount' }, + }, + }); + assert.equal(created.accepted, true); + + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + artifactRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ method: 'GET', url: `/v1/artifact-versions/${versionId}` }); + assert.equal(response.statusCode, 200); + const body = response.json(); + assert.equal(body.accepted, true); + assert.equal(body.value.version.versionId, versionId); + assert.equal(body.value.placements[0].opaqueReference, 'local-placement-000001'); + assert.doesNotMatch(response.body, /C:\\|\\\\|sourcePath|localPath/u); + + const evidenceResponse = await app.inject({ + method: 'GET', + url: `/v1/artifact-versions/${versionId}/evidence`, + }); + assert.equal(evidenceResponse.statusCode, 200); + assert.deepEqual(evidenceResponse.json().value[0].coordinate, { + kind: 'ROW', + row: 1, + field: 'amount', + }); + } finally { + await app.close(); + } +}); + +void test('[IAE-008, IAM-009] artifact reads do not enumerate a sibling workspace', async () => { + const repository = new InMemoryArtifactRepositoryAdapter(); + const tenantContext = context(); + const service = new ArtifactService(repository); + await service.register(tenantContext, { + version: { + artifactId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceKind: 'FILE', + dataMode: 'Cloud', + contentSha256: 'b'.repeat(64), + byteSize: 10, + mediaType: 'text/csv', + displayName: 'private.csv', + createdAt: '2026-01-01T00:00:00.000Z', + }, + placement: { + placementId, + tenantScope: tenantContext.tenantScope, + kind: 'CLOUD', + opaqueReference: 'cloud-placement-000001', + contentSha256: 'b'.repeat(64), + }, + }); + const sibling = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000629', + tenantScope: { + scopeType: 'workspace', + organizationId, + workspaceId: '00000000-0000-4000-8000-000000000630', + }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000631', + idempotencyKey: 'artifact-read-sibling', + }); + assert.equal(sibling.accepted, true); + if (!sibling.accepted) return; + const parsedVersionId = parseStableIdentifierV1(versionId); + assert.equal(parsedVersionId.accepted, true); + if (!parsedVersionId.accepted) return; + const result = await service.find(sibling.value, parsedVersionId.value); + assert.equal(result.version, undefined); +}); From b06482a80c555b120d6411bcebd69b4b33f65112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:05:37 +0700 Subject: [PATCH 034/177] feat(iae): expose artifact lineage reads --- .../iae/api/artifact-lineage.controller.ts | 58 ++++++++++++++++ services/api/src/features/iae/iae.module.ts | 20 +++++- .../iae/artifact-lineage.controller.test.ts | 67 +++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/api/artifact-lineage.controller.ts create mode 100644 services/api/test/features/iae/artifact-lineage.controller.test.ts diff --git a/services/api/src/features/iae/api/artifact-lineage.controller.ts b/services/api/src/features/iae/api/artifact-lineage.controller.ts new file mode 100644 index 00000000..0f191fc5 --- /dev/null +++ b/services/api/src/features/iae/api/artifact-lineage.controller.ts @@ -0,0 +1,58 @@ +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 { + ARTIFACT_LINEAGE_REPOSITORY_PORT, + type ArtifactLineageRepositoryPortV1, +} from '../application/artifact-lineage-repository.port.js'; +import { ArtifactGovernanceService } from '../application/artifact-governance.service.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +/** IAE-007: lineage is addressable by exact derived and source versions. */ +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifact-versions') +export class ArtifactLineageController { + private readonly governance: ArtifactGovernanceService; + + public constructor( + @Inject(ARTIFACT_LINEAGE_REPOSITORY_PORT) repository: ArtifactLineageRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.governance = new ArtifactGovernanceService(repository); + } + + @Get(':versionId/lineage') + @ApiOperation({ summary: 'Read lineage for an exact derived artifact version' }) + async forDerived( + @Req() request: unknown, + @Param('versionId') versionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!versionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const lineage = await this.governance.findForDerived(context, versionId.value); + return lineage + ? Object.freeze({ accepted: true, value: lineage }) + : Object.freeze({ accepted: false, code: 'NOT_FOUND' as const }); + } + + @Get(':versionId/derived-lineage') + @ApiOperation({ summary: 'List derived versions that use an exact source version' }) + async forSource( + @Req() request: unknown, + @Param('versionId') versionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!versionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return Object.freeze({ + accepted: true, + value: await this.governance.listForSource(context, versionId.value), + }); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index fc7a25d6..3c428536 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -3,12 +3,14 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { InboxController } from './api/inbox.controller.js'; import { EvidenceGrantController } from './api/evidence-grant.controller.js'; import { ArtifactReadController } from './api/artifact-read.controller.js'; +import { ArtifactLineageController } from './api/artifact-lineage.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, type ArtifactIntakeDatabaseClientV1, } from './adapter/prisma-artifact-intake-repository.adapter.js'; import { InMemoryArtifactRepositoryAdapter } from './adapter/in-memory-artifact-repository.adapter.js'; +import { InMemoryArtifactLineageRepositoryAdapter } from './adapter/in-memory-artifact-lineage-repository.adapter.js'; import { PrismaArtifactRepositoryAdapter, type ArtifactDatabaseClientV1, @@ -22,6 +24,10 @@ import { ARTIFACT_REPOSITORY_PORT, type ArtifactRepositoryPortV1, } from './application/artifact-repository.port.js'; +import { + ARTIFACT_LINEAGE_REPOSITORY_PORT, + type ArtifactLineageRepositoryPortV1, +} from './application/artifact-lineage-repository.port.js'; import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1, @@ -39,6 +45,7 @@ export interface IaeModuleOptions { readonly artifactRepository?: ArtifactRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactDatabase?: ArtifactDatabaseClientV1; + readonly artifactLineageRepository?: ArtifactLineageRepositoryPortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -48,7 +55,12 @@ export class IaeModule { public static register(options: IaeModuleOptions = {}): DynamicModule { return { module: IaeModule, - controllers: [InboxController, EvidenceGrantController, ArtifactReadController], + controllers: [ + InboxController, + EvidenceGrantController, + ArtifactReadController, + ArtifactLineageController, + ], providers: [ { provide: ARTIFACT_INTAKE_REPOSITORY_PORT, @@ -66,6 +78,11 @@ export class IaeModule { ? new InMemoryArtifactRepositoryAdapter() : new PrismaArtifactRepositoryAdapter(options.artifactDatabase)), }, + { + provide: ARTIFACT_LINEAGE_REPOSITORY_PORT, + useValue: + options.artifactLineageRepository ?? new InMemoryArtifactLineageRepositoryAdapter(), + }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, useValue: options.evidenceGrantRepository ?? new InMemoryEvidenceGrantRepositoryAdapter(), @@ -78,6 +95,7 @@ export class IaeModule { exports: [ ARTIFACT_INTAKE_REPOSITORY_PORT, ARTIFACT_REPOSITORY_PORT, + ARTIFACT_LINEAGE_REPOSITORY_PORT, EVIDENCE_GRANT_REPOSITORY_PORT, ], }; diff --git a/services/api/test/features/iae/artifact-lineage.controller.test.ts b/services/api/test/features/iae/artifact-lineage.controller.test.ts new file mode 100644 index 00000000..6219ae49 --- /dev/null +++ b/services/api/test/features/iae/artifact-lineage.controller.test.ts @@ -0,0 +1,67 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryArtifactLineageRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.js'; +import { ArtifactGovernanceService } from '../../../src/features/iae/application/artifact-governance.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000641'; +const workspaceId = '00000000-0000-4000-8000-000000000642'; +const sourceVersionId = '00000000-0000-4000-8000-000000000643'; +const derivedVersionId = '00000000-0000-4000-8000-000000000644'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000645', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000646', + idempotencyKey: 'lineage-http', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[IAE-007] lineage endpoints resolve exact derived and source versions', async () => { + const repository = new InMemoryArtifactLineageRepositoryAdapter(); + const tenantContext = context(); + const governance = new ArtifactGovernanceService(repository); + const created = await governance.registerLineage(tenantContext, { + lineageId: '00000000-0000-4000-8000-000000000647', + derivedArtifactVersionId: derivedVersionId, + tenantScope: tenantContext.tenantScope, + sourceArtifactVersionIds: [sourceVersionId], + sourceTenantScopes: [tenantContext.tenantScope], + processorVersion: 'spreadsheet-auditor@1', + coordinateLineage: [], + }); + assert.equal(created.accepted, true); + + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + artifactLineageRepository: repository, + requestTenantContext, + }); + try { + const derived = await app.inject({ + method: 'GET', + url: `/v1/artifact-versions/${derivedVersionId}/lineage`, + }); + assert.equal(derived.statusCode, 200); + assert.equal(derived.json().value.derivedArtifactVersionId, derivedVersionId); + + const source = await app.inject({ + method: 'GET', + url: `/v1/artifact-versions/${sourceVersionId}/derived-lineage`, + }); + assert.equal(source.statusCode, 200); + assert.equal(source.json().value.length, 1); + } finally { + await app.close(); + } +}); From 56ed43af47c7a94cc9b443db8778587894b32745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:08:10 +0700 Subject: [PATCH 035/177] feat(iae): version content placement availability --- packages/domain/src/artifact/v1.ts | 20 +++++ .../in-memory-artifact-repository.adapter.ts | 19 +++++ .../prisma-artifact-repository.adapter.ts | 43 +++++++++++ .../iae/api/content-placement.controller.ts | 44 +++++++++++ .../features/iae/api/content-placement.dto.ts | 13 ++++ .../application/artifact-repository.port.ts | 1 + .../application/content-placement.service.ts | 55 ++++++++++++++ services/api/src/features/iae/iae.module.ts | 2 + .../iae/content-placement.service.test.ts | 74 +++++++++++++++++++ .../iae/prisma-artifact-repository.test.ts | 12 +++ 10 files changed, 283 insertions(+) create mode 100644 services/api/src/features/iae/api/content-placement.controller.ts create mode 100644 services/api/src/features/iae/api/content-placement.dto.ts create mode 100644 services/api/src/features/iae/application/content-placement.service.ts create mode 100644 services/api/test/features/iae/content-placement.service.test.ts diff --git a/packages/domain/src/artifact/v1.ts b/packages/domain/src/artifact/v1.ts index d1732b48..df93850b 100644 --- a/packages/domain/src/artifact/v1.ts +++ b/packages/domain/src/artifact/v1.ts @@ -84,6 +84,7 @@ export type ArtifactErrorCodeV1 = | 'INVALID_NAME' | 'INVALID_STATUS' | 'INVALID_REVISION' + | 'REVISION_CONFLICT' | 'INVALID_REFERENCE' | 'INVALID_COORDINATE' | 'COORDINATE_OUT_OF_BOUNDS' @@ -271,6 +272,25 @@ export function createContentPlacementV1(input: { ); } +/** IAE-020: placement availability is a revisioned projection, not mutable content identity. */ +export function updateContentPlacementAvailabilityV1( + placement: ContentPlacementV1, + availableInput: unknown, + expectedRevisionInput: unknown, +): ArtifactResultV1 { + if (typeof availableInput !== 'boolean') return rejected('INVALID_STATUS'); + if (!positiveRevision(expectedRevisionInput)) return rejected('INVALID_REVISION'); + if (expectedRevisionInput !== placement.revision) return rejected('REVISION_CONFLICT'); + if (availableInput === placement.available) return accepted(placement); + return accepted( + Object.freeze({ + ...placement, + available: availableInput, + revision: placement.revision + 1, + }), + ); +} + function evidenceCoordinate(input: unknown): EvidenceCoordinateV1 | undefined { if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; const record = input as Record; diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts index 10748ee8..e3cc3d84 100644 --- a/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts @@ -92,6 +92,24 @@ export class InMemoryArtifactRepositoryAdapter implements ArtifactRepositoryPort .map(clonePlacement); } + async updatePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise { + await Promise.resolve(); + if (!scopeAllowsMutation(context, placement.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = this.placements.get(placement.placementId); + if (!existing) throw new Error('IAE_PLACEMENT_NOT_FOUND'); + if (JSON.stringify(existing) === JSON.stringify(placement)) return; + if (placement.revision !== existing.revision + 1) throw new Error('IAE_REVISION_CONFLICT'); + if ( + existing.artifactVersionId !== placement.artifactVersionId || + existing.kind !== placement.kind || + existing.opaqueReference !== placement.opaqueReference || + existing.contentSha256 !== placement.contentSha256 + ) + throw new Error('IAE_IMMUTABLE_PLACEMENT'); + this.placements.set(placement.placementId, clonePlacement(placement)); + } + async saveEvidence(context: IamTenantContextV1, evidence: EvidenceReferenceV1): Promise { await Promise.resolve(); if (!scopeAllowsMutation(context, evidence.tenantScope)) @@ -135,6 +153,7 @@ export class InMemoryArtifactRepositoryAdapter implements ArtifactRepositoryPort saveVersion: this.saveVersion.bind(this), findVersion: this.findVersion.bind(this), savePlacement: this.savePlacement.bind(this), + updatePlacement: this.updatePlacement.bind(this), listPlacements: this.listPlacements.bind(this), saveEvidence: this.saveEvidence.bind(this), listEvidence: this.listEvidence.bind(this), diff --git a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts index da6d83cb..f4dd3950 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts @@ -90,6 +90,13 @@ export interface ArtifactDatabaseClientV1 { findMany(input: { readonly where: Readonly>; }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { readonly available: boolean; readonly revision: number }; + }): Promise; }; readonly evidenceReference: { create(input: { readonly data: EvidenceCreateDataV1 }): Promise; @@ -269,6 +276,36 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { .map((row) => rowToPlacement(row, version)); } + public async updatePlacement( + context: IamTenantContextV1, + placement: ContentPlacementV1, + ): Promise { + const existing = await this.client.contentPlacement.findUnique({ + where: { id: placement.placementId }, + }); + if (existing === null) throw new Error('IAE_PLACEMENT_NOT_FOUND'); + if (!tenantScopeContainsV1(context.tenantScope, placement.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const versionRow = await this.client.artifactVersion.findUnique({ + where: { id: placement.artifactVersionId }, + }); + if (versionRow === null) throw new Error('IAE_VERSION_NOT_FOUND'); + const current = rowToPlacement(existing, rowToVersion(versionRow)); + if (JSON.stringify(current) === JSON.stringify(placement)) return; + if (placement.revision !== current.revision + 1) throw new Error('IAE_REVISION_CONFLICT'); + if ( + current.artifactVersionId !== placement.artifactVersionId || + current.kind !== placement.kind || + current.opaqueReference !== placement.opaqueReference || + current.contentSha256 !== placement.contentSha256 + ) + throw new Error('IAE_IMMUTABLE_PLACEMENT'); + await this.client.contentPlacement.update({ + where: { id: placement.placementId }, + data: { available: placement.available, revision: placement.revision }, + }); + } + public async saveEvidence( context: IamTenantContextV1, evidence: EvidenceReferenceV1, @@ -330,6 +367,12 @@ export class PrismaArtifactRepositoryAdapter implements ArtifactRepositoryPortV1 public savePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise { return new PrismaArtifactTransactionAdapter(this.client).savePlacement(context, placement); } + public updatePlacement( + context: IamTenantContextV1, + placement: ContentPlacementV1, + ): Promise { + return new PrismaArtifactTransactionAdapter(this.client).updatePlacement(context, placement); + } public listPlacements( context: IamTenantContextV1, versionId: ArtifactVersionV1['versionId'], diff --git a/services/api/src/features/iae/api/content-placement.controller.ts b/services/api/src/features/iae/api/content-placement.controller.ts new file mode 100644 index 00000000..f56b5328 --- /dev/null +++ b/services/api/src/features/iae/api/content-placement.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Inject, Param, Patch, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ARTIFACT_REPOSITORY_PORT, + type ArtifactRepositoryPortV1, +} from '../application/artifact-repository.port.js'; +import { ContentPlacementService } from '../application/content-placement.service.js'; +import { UpdateContentPlacementDto } from './content-placement.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifact-versions') +export class ContentPlacementController { + private readonly placements: ContentPlacementService; + + public constructor( + @Inject(ARTIFACT_REPOSITORY_PORT) repository: ArtifactRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.placements = new ContentPlacementService(repository); + } + + @Patch(':versionId/placements/:placementId') + @ApiOperation({ summary: 'Update verified placement availability with a revision precondition' }) + @ApiBody({ type: UpdateContentPlacementDto }) + async update( + @Req() request: unknown, + @Param('versionId') versionId: string, + @Param('placementId') placementId: string, + @Body() input: UpdateContentPlacementDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.placements.setAvailability(context, { + versionId, + placementId, + ...input, + }); + } +} diff --git a/services/api/src/features/iae/api/content-placement.dto.ts b/services/api/src/features/iae/api/content-placement.dto.ts new file mode 100644 index 00000000..fdfcafc3 --- /dev/null +++ b/services/api/src/features/iae/api/content-placement.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsInt, Min } from 'class-validator'; + +export class UpdateContentPlacementDto { + @ApiProperty() + @IsBoolean() + available!: boolean; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; +} diff --git a/services/api/src/features/iae/application/artifact-repository.port.ts b/services/api/src/features/iae/application/artifact-repository.port.ts index a2617cd4..0db52f18 100644 --- a/services/api/src/features/iae/application/artifact-repository.port.ts +++ b/services/api/src/features/iae/application/artifact-repository.port.ts @@ -15,6 +15,7 @@ export interface ArtifactTransactionPortV1 { versionId: ArtifactVersionV1['versionId'], ): Promise; savePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise; + updatePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise; listPlacements( context: IamTenantContextV1, versionId: ArtifactVersionV1['versionId'], diff --git a/services/api/src/features/iae/application/content-placement.service.ts b/services/api/src/features/iae/application/content-placement.service.ts new file mode 100644 index 00000000..4ad9c0f3 --- /dev/null +++ b/services/api/src/features/iae/application/content-placement.service.ts @@ -0,0 +1,55 @@ +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { + updateContentPlacementAvailabilityV1, + type ArtifactResultV1, + type ContentPlacementV1, +} from '@databreeze/domain/artifact/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { ArtifactRepositoryPortV1 } from './artifact-repository.port.js'; + +export type ContentPlacementServiceErrorV1 = 'VERSION_NOT_FOUND' | 'PLACEMENT_NOT_FOUND'; +export type ContentPlacementServiceResultV1 = + | ArtifactResultV1 + | { readonly accepted: false; readonly code: ContentPlacementServiceErrorV1 }; + +/** Updates only verified availability state while preserving opaque placement identity. */ +export class ContentPlacementService { + public constructor(private readonly repository: ArtifactRepositoryPortV1) {} + + public async setAvailability( + context: IamTenantContextV1, + input: { + readonly versionId: unknown; + readonly placementId: unknown; + readonly available: unknown; + readonly expectedRevision: unknown; + }, + ): Promise> { + const versionId = parseStableIdentifierV1(input.versionId); + const placementId = parseStableIdentifierV1(input.placementId); + if (!versionId.accepted || !placementId.accepted) + return Object.freeze({ accepted: false as const, code: 'INVALID_IDENTIFIER' as const }); + return this.repository.withTransaction(context, async (transaction) => { + const version = await transaction.findVersion(context, versionId.value); + if (!version) + return Object.freeze({ accepted: false as const, code: 'VERSION_NOT_FOUND' as const }); + const current = (await transaction.listPlacements(context, version.versionId)).find( + (candidate) => candidate.placementId === placementId.value, + ); + if (!current) + return Object.freeze({ accepted: false as const, code: 'PLACEMENT_NOT_FOUND' as const }); + const updated = updateContentPlacementAvailabilityV1( + current, + input.available, + input.expectedRevision, + ); + if (!updated.accepted) return updated; + await transaction.updatePlacement(context, updated.value); + return updated; + }); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 3c428536..d1d52040 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -4,6 +4,7 @@ import { InboxController } from './api/inbox.controller.js'; import { EvidenceGrantController } from './api/evidence-grant.controller.js'; import { ArtifactReadController } from './api/artifact-read.controller.js'; import { ArtifactLineageController } from './api/artifact-lineage.controller.js'; +import { ContentPlacementController } from './api/content-placement.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -60,6 +61,7 @@ export class IaeModule { EvidenceGrantController, ArtifactReadController, ArtifactLineageController, + ContentPlacementController, ], providers: [ { diff --git a/services/api/test/features/iae/content-placement.service.test.ts b/services/api/test/features/iae/content-placement.service.test.ts new file mode 100644 index 00000000..040d63c6 --- /dev/null +++ b/services/api/test/features/iae/content-placement.service.test.ts @@ -0,0 +1,74 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { ArtifactService } from '../../../src/features/iae/application/artifact.service.js'; +import { ContentPlacementService } from '../../../src/features/iae/application/content-placement.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000651'; +const workspaceId = '00000000-0000-4000-8000-000000000652'; +const artifactId = '00000000-0000-4000-8000-000000000653'; +const versionId = '00000000-0000-4000-8000-000000000654'; +const placementId = '00000000-0000-4000-8000-000000000655'; + +function context(key: string) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000656', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000657', + idempotencyKey: key, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[IAE-020, DSO-006] placement availability uses optimistic revisions and keeps identity immutable', async () => { + const repository = new InMemoryArtifactRepositoryAdapter(); + const tenantContext = context('placement-create'); + const artifacts = new ArtifactService(repository); + const created = await artifacts.register(tenantContext, { + version: { + artifactId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'c'.repeat(64), + byteSize: 1, + mediaType: 'text/plain', + displayName: 'note.txt', + createdAt: '2026-01-01T00:00:00.000Z', + }, + placement: { + placementId, + tenantScope: tenantContext.tenantScope, + kind: 'CLOUD', + opaqueReference: 'cloud-placement-000002', + contentSha256: 'c'.repeat(64), + }, + }); + assert.equal(created.accepted, true); + const service = new ContentPlacementService(repository); + const unavailable = await service.setAvailability(context('placement-offline'), { + versionId, + placementId, + available: false, + expectedRevision: 1, + }); + assert.equal(unavailable.accepted, true); + if (!unavailable.accepted) return; + assert.equal(unavailable.value.available, false); + assert.equal(unavailable.value.revision, 2); + assert.equal(unavailable.value.opaqueReference, 'cloud-placement-000002'); + + const stale = await service.setAvailability(context('placement-stale'), { + versionId, + placementId, + available: true, + expectedRevision: 1, + }); + assert.deepEqual(stale, { accepted: false, code: 'REVISION_CONFLICT' }); +}); diff --git a/services/api/test/features/iae/prisma-artifact-repository.test.ts b/services/api/test/features/iae/prisma-artifact-repository.test.ts index 816dd1b7..b50896b7 100644 --- a/services/api/test/features/iae/prisma-artifact-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-repository.test.ts @@ -77,6 +77,18 @@ function client( ), ); }, + findUnique(input) { + return Promise.resolve( + placements.find((candidate) => candidate.id === input.where.id) ?? null, + ); + }, + update(input) { + const current = placements.find((candidate) => candidate.id === input.where.id); + if (!current) throw new Error('fixture placement not found'); + const next = { ...current, ...input.data }; + placements[placements.indexOf(current)] = next; + return Promise.resolve(next); + }, }, evidenceReference: { create(input) { From 474843fdd4f5334c7d20792f4dab43602759688e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:09:47 +0700 Subject: [PATCH 036/177] feat(iae): model explicit artifact deletion authorization --- packages/domain/package.json | 4 + packages/domain/src/artifact-retention/v1.ts | 142 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../test/artifact-retention-v1.test.mjs | 52 +++++++ .../domain/test/built-public-api-smoke.mjs | 3 + packages/domain/test/public-api-v1.test.mjs | 1 + 6 files changed, 203 insertions(+) create mode 100644 packages/domain/src/artifact-retention/v1.ts create mode 100644 packages/domain/test/artifact-retention-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index b2fe790f..ffacafd7 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -72,6 +72,10 @@ "types": "./src/artifact-governance/v1.ts", "import": "./dist/artifact-governance/v1.js" }, + "./artifact-retention/v1": { + "types": "./src/artifact-retention/v1.ts", + "import": "./dist/artifact-retention/v1.js" + }, "./dataset/v1": { "types": "./src/dataset/v1.ts", "import": "./dist/dataset/v1.js" diff --git a/packages/domain/src/artifact-retention/v1.ts b/packages/domain/src/artifact-retention/v1.ts new file mode 100644 index 00000000..6df42e82 --- /dev/null +++ b/packages/domain/src/artifact-retention/v1.ts @@ -0,0 +1,142 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; +import type { ArtifactRetentionEvaluationV1 } from '../artifact-governance/v1.js'; + +/** IAE-016, IAE-021: explicit, auditable deletion requests separate from byte erasure. */ +export const ARTIFACT_RETENTION_SCHEMA_VERSION_V1 = 1 as const; + +export type ArtifactDeletionStateV1 = + | 'REQUESTED' + | 'BLOCKED' + | 'AUTHORIZED' + | 'COMPLETED' + | 'CANCELLED'; + +export interface ArtifactDeletionRequestV1 { + readonly schemaVersion: typeof ARTIFACT_RETENTION_SCHEMA_VERSION_V1; + readonly requestId: StableIdentifierV1; + readonly artifactVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly requestedBy: StableIdentifierV1; + readonly requestedAt: StrictUtcTimestampV1; + readonly state: ArtifactDeletionStateV1; + readonly blockers: readonly string[]; + readonly authorizedAt?: StrictUtcTimestampV1; + readonly revision: number; +} + +export type ArtifactRetentionErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_STATE' + | 'INVALID_REVISION' + | 'CROSS_SCOPE' + | 'RETENTION_BLOCKED' + | 'MFA_REQUIRED'; + +export type ArtifactRetentionResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ArtifactRetentionErrorCodeV1 }; + +function accepted(value: TValue): ArtifactRetentionResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: ArtifactRetentionErrorCodeV1): ArtifactRetentionResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const result = parseStrictUtcTimestampV1(input); + return result.accepted ? result.value : undefined; +} + +export function createArtifactDeletionRequestV1(input: { + readonly requestId: unknown; + readonly artifactVersionId: unknown; + readonly tenantScope: unknown; + readonly requestedBy: unknown; + readonly requestedAt: unknown; +}): ArtifactRetentionResultV1 { + const requestId = identifier(input.requestId); + const artifactVersionId = identifier(input.artifactVersionId); + const tenantScope = parseTenantScopeV1(input.tenantScope); + const requestedBy = identifier(input.requestedBy); + const requestedAt = timestamp(input.requestedAt); + if (!requestId || !artifactVersionId || !requestedBy) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (!requestedAt) return rejected('INVALID_TIMESTAMP'); + return accepted( + Object.freeze({ + schemaVersion: ARTIFACT_RETENTION_SCHEMA_VERSION_V1, + requestId, + artifactVersionId, + tenantScope: tenantScope.value, + requestedBy, + requestedAt, + state: 'REQUESTED' as const, + blockers: Object.freeze([]), + revision: 1, + }), + ); +} + +export function authorizeArtifactDeletionV1( + request: ArtifactDeletionRequestV1, + evaluation: ArtifactRetentionEvaluationV1, + input: { + readonly tenantScope: unknown; + readonly approvedAt: unknown; + readonly mfaSatisfied: unknown; + }, +): ArtifactRetentionResultV1 { + const tenantScope = parseTenantScopeV1(input.tenantScope); + const approvedAt = timestamp(input.approvedAt); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (!tenantScopesEqualV1(tenantScope.value, request.tenantScope)) return rejected('CROSS_SCOPE'); + if (!approvedAt || Date.parse(approvedAt) < Date.parse(request.requestedAt)) + return rejected('INVALID_TIMESTAMP'); + if (typeof input.mfaSatisfied !== 'boolean' || !input.mfaSatisfied) + return rejected('MFA_REQUIRED'); + if (!evaluation.eligible) return rejected('RETENTION_BLOCKED'); + if (request.state !== 'REQUESTED' && request.state !== 'BLOCKED') + return rejected('INVALID_STATE'); + return accepted( + Object.freeze({ + ...request, + state: 'AUTHORIZED' as const, + blockers: Object.freeze([]), + authorizedAt: approvedAt, + revision: request.revision + 1, + }), + ); +} + +export function blockArtifactDeletionV1( + request: ArtifactDeletionRequestV1, + evaluation: ArtifactRetentionEvaluationV1, +): ArtifactRetentionResultV1 { + if (request.state !== 'REQUESTED' && request.state !== 'BLOCKED') + return rejected('INVALID_STATE'); + return accepted( + Object.freeze({ + ...request, + state: 'BLOCKED' as const, + blockers: Object.freeze([...evaluation.blockers]), + revision: request.revision + (request.state === 'BLOCKED' ? 0 : 1), + }), + ); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 4f5b9a48..6d9e9706 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -3,6 +3,7 @@ export * from './audit/v1.js'; export * from './artifact/v1.js'; export * from './artifact-intake/v1.js'; export * from './artifact-governance/v1.js'; +export * from './artifact-retention/v1.js'; export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; export * from './jobs/v1.js'; diff --git a/packages/domain/test/artifact-retention-v1.test.mjs b/packages/domain/test/artifact-retention-v1.test.mjs new file mode 100644 index 00000000..9959562f --- /dev/null +++ b/packages/domain/test/artifact-retention-v1.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + authorizeArtifactDeletionV1, + blockArtifactDeletionV1, + createArtifactDeletionRequestV1, +} from '../dist/artifact-retention/v1.js'; + +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000701', + workspaceId: '00000000-0000-4000-8000-000000000702', +}; +const base = { + requestId: '00000000-0000-4000-8000-000000000703', + artifactVersionId: '00000000-0000-4000-8000-000000000704', + tenantScope: scope, + requestedBy: '00000000-0000-4000-8000-000000000705', + requestedAt: '2026-01-03T00:00:00.000Z', +}; + +void test('[IAE-016, IAE-021] deletion authorization requires eligible retention and recent MFA', () => { + const request = createArtifactDeletionRequestV1(base); + assert.equal(request.accepted, true); + if (!request.accepted) return; + const blocked = blockArtifactDeletionV1(request.value, { + eligible: false, + blockers: ['LEGAL_HOLD'], + evaluatedAt: '2026-01-03T00:00:00.000Z', + }); + assert.deepEqual(blocked, { + accepted: true, + value: { ...request.value, state: 'BLOCKED', blockers: ['LEGAL_HOLD'], revision: 2 }, + }); + if (!blocked.accepted) return; + assert.deepEqual( + authorizeArtifactDeletionV1( + blocked.value, + { eligible: true, blockers: [], evaluatedAt: '2026-01-04T00:00:00.000Z' }, + { tenantScope: scope, approvedAt: '2026-01-04T00:00:00.000Z', mfaSatisfied: false }, + ), + { accepted: false, code: 'MFA_REQUIRED' }, + ); + const authorized = authorizeArtifactDeletionV1( + blocked.value, + { eligible: true, blockers: [], evaluatedAt: '2026-01-04T00:00:00.000Z' }, + { tenantScope: scope, approvedAt: '2026-01-04T00:00:00.000Z', mfaSatisfied: true }, + ); + assert.equal(authorized.accepted, true); + if (authorized.accepted) assert.equal(authorized.value.state, 'AUTHORIZED'); +}); diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 7bcfdb56..89d215d9 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -8,6 +8,7 @@ const [ artifact, artifactIntake, artifactGovernance, + artifactRetention, dataset, datasetGovernance, dataMode, @@ -30,6 +31,7 @@ const [ import('@databreeze/domain/artifact/v1'), import('@databreeze/domain/artifact-intake/v1'), import('@databreeze/domain/artifact-governance/v1'), + import('@databreeze/domain/artifact-retention/v1'), import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/data-mode/v1'), @@ -54,6 +56,7 @@ assert.equal(typeof authorization.createScopedAuthorizationEvaluatorV1, 'functio assert.equal(artifact.ARTIFACT_SCHEMA_VERSION_V1, 1); assert.equal(artifactIntake.ARTIFACT_INTAKE_SCHEMA_VERSION_V1, 1); assert.equal(artifactGovernance.ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1, 1); +assert.equal(artifactRetention.ARTIFACT_RETENTION_SCHEMA_VERSION_V1, 1); assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index f46407dc..bd12d868 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -26,6 +26,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './artifact/v1', './artifact-intake/v1', './artifact-governance/v1', + './artifact-retention/v1', './dataset/v1', './dataset-governance/v1', './jobs/v1', From bf197f43c91227de5c051126fa96c5a8c716ca85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:11:56 +0700 Subject: [PATCH 037/177] feat(iae): add governed retention request service --- ...y-artifact-retention-repository.adapter.ts | 81 ++++++++++++++ .../iae/api/artifact-retention.controller.ts | 72 +++++++++++++ .../iae/api/artifact-retention.dto.ts | 61 +++++++++++ .../artifact-retention-repository.port.ts | 20 ++++ .../application/artifact-retention.service.ts | 102 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 13 +++ .../iae/artifact-retention.service.test.ts | 97 +++++++++++++++++ 7 files changed, 446 insertions(+) create mode 100644 services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts create mode 100644 services/api/src/features/iae/api/artifact-retention.controller.ts create mode 100644 services/api/src/features/iae/api/artifact-retention.dto.ts create mode 100644 services/api/src/features/iae/application/artifact-retention-repository.port.ts create mode 100644 services/api/src/features/iae/application/artifact-retention.service.ts create mode 100644 services/api/test/features/iae/artifact-retention.service.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts new file mode 100644 index 00000000..73624c5e --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts @@ -0,0 +1,81 @@ +import { + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import type { ArtifactDeletionRequestV1 } from '@databreeze/domain/artifact-retention/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactRetentionRepositoryPortV1, + ArtifactRetentionTransactionPortV1, +} from '../application/artifact-retention-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(request: ArtifactDeletionRequestV1): ArtifactDeletionRequestV1 { + return Object.freeze({ + ...request, + tenantScope: Object.freeze({ ...request.tenantScope }), + blockers: Object.freeze([...request.blockers]), + }); +} + +export class InMemoryArtifactRetentionRepositoryAdapter + implements ArtifactRetentionRepositoryPortV1 +{ + private requests = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save( + context: IamTenantContextV1, + request: ArtifactDeletionRequestV1, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, request.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = this.requests.get(request.requestId); + if (existing && JSON.stringify(existing) === JSON.stringify(request)) return; + if (existing) { + if (request.revision !== existing.revision + 1) throw new Error('IAE_REVISION_CONFLICT'); + if ( + existing.artifactVersionId !== request.artifactVersionId || + existing.requestedBy !== request.requestedBy || + existing.requestedAt !== request.requestedAt + ) + throw new Error('IAE_IMMUTABLE_DELETION_REQUEST'); + } + this.requests.set(request.requestId, clone(request)); + } + + public async find( + context: IamTenantContextV1, + requestId: ArtifactDeletionRequestV1['requestId'], + ): Promise { + await Promise.resolve(); + const request = this.requests.get(requestId); + return request && visible(context.tenantScope, request.tenantScope) ? clone(request) : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactRetentionTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.requests); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + } catch (error) { + this.requests = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iae/api/artifact-retention.controller.ts b/services/api/src/features/iae/api/artifact-retention.controller.ts new file mode 100644 index 00000000..806e8e52 --- /dev/null +++ b/services/api/src/features/iae/api/artifact-retention.controller.ts @@ -0,0 +1,72 @@ +import { Body, Controller, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ARTIFACT_REPOSITORY_PORT, + type ArtifactRepositoryPortV1, +} from '../application/artifact-repository.port.js'; +import { + ARTIFACT_RETENTION_REPOSITORY_PORT, + type ArtifactRetentionRepositoryPortV1, +} from '../application/artifact-retention-repository.port.js'; +import { ArtifactRetentionService } from '../application/artifact-retention.service.js'; +import { + AuthorizeArtifactDeletionRequestDto, + CreateArtifactDeletionRequestDto, +} from './artifact-retention.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1') +export class ArtifactRetentionController { + private readonly retention: ArtifactRetentionService; + + public constructor( + @Inject(ARTIFACT_RETENTION_REPOSITORY_PORT) requests: ArtifactRetentionRepositoryPortV1, + @Inject(ARTIFACT_REPOSITORY_PORT) artifacts: ArtifactRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.retention = new ArtifactRetentionService(requests, artifacts); + } + + @Post('artifact-versions/:versionId/deletion-requests') + @ApiOperation({ summary: 'Request governed deletion of an exact artifact version' }) + @ApiBody({ type: CreateArtifactDeletionRequestDto }) + async request( + @Req() request: unknown, + @Param('versionId') versionId: string, + @Body() input: CreateArtifactDeletionRequestDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.retention.request(context, { + requestId: input.requestId, + artifactVersionId: versionId, + tenantScope: context.tenantScope, + requestedBy: input.requestedBy, + requestedAt: input.requestedAt, + retention: input, + }); + } + + @Post('artifact-deletion-requests/:requestId/authorize') + @ApiOperation({ summary: 'Authorize an eligible deletion request after MFA step-up' }) + @ApiBody({ type: AuthorizeArtifactDeletionRequestDto }) + async authorize( + @Req() request: unknown, + @Param('requestId') requestId: string, + @Body() input: AuthorizeArtifactDeletionRequestDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.retention.authorize(context, { + requestId, + retention: input, + approvedAt: input.approvedAt, + mfaSatisfied: input.mfaSatisfied, + expectedRevision: input.expectedRevision, + }); + } +} diff --git a/services/api/src/features/iae/api/artifact-retention.dto.ts b/services/api/src/features/iae/api/artifact-retention.dto.ts new file mode 100644 index 00000000..be4ed98c --- /dev/null +++ b/services/api/src/features/iae/api/artifact-retention.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsISO8601, IsInt, IsUUID, Min } from 'class-validator'; + +export class RetentionEvaluationDto { + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + evaluatedAt!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + workspaceRetentionUntil!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + resourceRetentionUntil!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + auditRetentionUntil!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + recoveryWindowUntil!: string; + + @ApiProperty() + @IsBoolean() + activeApproval!: boolean; + + @ApiProperty() + @IsBoolean() + legalHold!: boolean; +} + +export class CreateArtifactDeletionRequestDto extends RetentionEvaluationDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + requestId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + requestedBy!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + requestedAt!: string; +} + +export class AuthorizeArtifactDeletionRequestDto extends RetentionEvaluationDto { + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + approvedAt!: string; + + @ApiProperty() + @IsBoolean() + mfaSatisfied!: boolean; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; +} diff --git a/services/api/src/features/iae/application/artifact-retention-repository.port.ts b/services/api/src/features/iae/application/artifact-retention-repository.port.ts new file mode 100644 index 00000000..10bb6a64 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-retention-repository.port.ts @@ -0,0 +1,20 @@ +import type { ArtifactDeletionRequestV1 } from '@databreeze/domain/artifact-retention/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const ARTIFACT_RETENTION_REPOSITORY_PORT = Symbol('ARTIFACT_RETENTION_REPOSITORY_PORT'); + +export interface ArtifactRetentionTransactionPortV1 { + save(context: IamTenantContextV1, request: ArtifactDeletionRequestV1): Promise; + find( + context: IamTenantContextV1, + requestId: ArtifactDeletionRequestV1['requestId'], + ): Promise; +} + +export interface ArtifactRetentionRepositoryPortV1 extends ArtifactRetentionTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactRetentionTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/iae/application/artifact-retention.service.ts b/services/api/src/features/iae/application/artifact-retention.service.ts new file mode 100644 index 00000000..1958e7b8 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-retention.service.ts @@ -0,0 +1,102 @@ +import { + authorizeArtifactDeletionV1, + blockArtifactDeletionV1, + createArtifactDeletionRequestV1, + type ArtifactDeletionRequestV1, + type ArtifactRetentionResultV1, +} from '@databreeze/domain/artifact-retention/v1'; +import { evaluateArtifactRetentionV1 } from '@databreeze/domain/artifact-governance/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { ArtifactRepositoryPortV1 } from './artifact-repository.port.js'; +import type { ArtifactRetentionRepositoryPortV1 } from './artifact-retention-repository.port.js'; + +export type ArtifactRetentionServiceErrorV1 = 'ARTIFACT_NOT_FOUND' | 'REQUEST_NOT_FOUND'; +export type ArtifactRetentionServiceResultV1 = + | ArtifactRetentionResultV1 + | { readonly accepted: false; readonly code: ArtifactRetentionServiceErrorV1 }; + +/** Keeps retention policy and deletion-request state in IAE; object erasure remains asynchronous. */ +export class ArtifactRetentionService { + public constructor( + private readonly requests: ArtifactRetentionRepositoryPortV1, + private readonly artifacts: ArtifactRepositoryPortV1, + ) {} + + public async request( + context: IamTenantContextV1, + input: Parameters[0] & { + readonly retention: Parameters[0]; + }, + ): Promise> { + const created = createArtifactDeletionRequestV1(input); + if (!created.accepted) return created; + const artifactVersionId = parseStableIdentifierV1(input.artifactVersionId); + if (!artifactVersionId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const artifact = await this.artifacts.findVersion(context, artifactVersionId.value); + if (!artifact) return Object.freeze({ accepted: false, code: 'ARTIFACT_NOT_FOUND' as const }); + const evaluation = evaluateArtifactRetentionV1(input.retention); + if (!evaluation.accepted) + return Object.freeze({ + accepted: false as const, + code: + evaluation.code === 'INVALID_TIMESTAMP' + ? ('INVALID_TIMESTAMP' as const) + : ('INVALID_STATE' as const), + }); + const next = evaluation.value.eligible + ? created + : blockArtifactDeletionV1(created.value, evaluation.value); + if (!next.accepted) return next; + return this.requests.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, next.value.requestId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(next.value)) + return { accepted: true, value: existing }; + throw new Error('IAE_IMMUTABLE_DELETION_REQUEST'); + } + await transaction.save(context, next.value); + return next; + }); + } + + public async authorize( + context: IamTenantContextV1, + input: { + readonly requestId: unknown; + readonly retention: Parameters[0]; + readonly approvedAt: unknown; + readonly mfaSatisfied: unknown; + readonly expectedRevision?: unknown; + }, + ): Promise> { + const requestId = parseStableIdentifierV1(input.requestId); + if (!requestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const evaluation = evaluateArtifactRetentionV1(input.retention); + if (!evaluation.accepted) + return Object.freeze({ + accepted: false as const, + code: + evaluation.code === 'INVALID_TIMESTAMP' + ? ('INVALID_TIMESTAMP' as const) + : ('INVALID_STATE' as const), + }); + return this.requests.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, requestId.value); + if (!current) return Object.freeze({ accepted: false, code: 'REQUEST_NOT_FOUND' as const }); + if (input.expectedRevision !== undefined && input.expectedRevision !== current.revision) + return Object.freeze({ accepted: false, code: 'INVALID_REVISION' as const }); + const authorized = authorizeArtifactDeletionV1(current, evaluation.value, { + tenantScope: context.tenantScope, + approvedAt: input.approvedAt, + mfaSatisfied: input.mfaSatisfied, + }); + if (!authorized.accepted) return authorized; + await transaction.save(context, authorized.value); + return authorized; + }); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index d1d52040..205b88cb 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -5,6 +5,7 @@ import { EvidenceGrantController } from './api/evidence-grant.controller.js'; import { ArtifactReadController } from './api/artifact-read.controller.js'; import { ArtifactLineageController } from './api/artifact-lineage.controller.js'; import { ContentPlacementController } from './api/content-placement.controller.js'; +import { ArtifactRetentionController } from './api/artifact-retention.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -12,6 +13,7 @@ import { } from './adapter/prisma-artifact-intake-repository.adapter.js'; import { InMemoryArtifactRepositoryAdapter } from './adapter/in-memory-artifact-repository.adapter.js'; import { InMemoryArtifactLineageRepositoryAdapter } from './adapter/in-memory-artifact-lineage-repository.adapter.js'; +import { InMemoryArtifactRetentionRepositoryAdapter } from './adapter/in-memory-artifact-retention-repository.adapter.js'; import { PrismaArtifactRepositoryAdapter, type ArtifactDatabaseClientV1, @@ -29,6 +31,10 @@ import { ARTIFACT_LINEAGE_REPOSITORY_PORT, type ArtifactLineageRepositoryPortV1, } from './application/artifact-lineage-repository.port.js'; +import { + ARTIFACT_RETENTION_REPOSITORY_PORT, + type ArtifactRetentionRepositoryPortV1, +} from './application/artifact-retention-repository.port.js'; import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1, @@ -47,6 +53,7 @@ export interface IaeModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactDatabase?: ArtifactDatabaseClientV1; readonly artifactLineageRepository?: ArtifactLineageRepositoryPortV1; + readonly artifactRetentionRepository?: ArtifactRetentionRepositoryPortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -62,6 +69,7 @@ export class IaeModule { ArtifactReadController, ArtifactLineageController, ContentPlacementController, + ArtifactRetentionController, ], providers: [ { @@ -85,6 +93,10 @@ export class IaeModule { useValue: options.artifactLineageRepository ?? new InMemoryArtifactLineageRepositoryAdapter(), }, + { + provide: ARTIFACT_RETENTION_REPOSITORY_PORT, + useValue: options.artifactRetentionRepository ?? new InMemoryArtifactRetentionRepositoryAdapter(), + }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, useValue: options.evidenceGrantRepository ?? new InMemoryEvidenceGrantRepositoryAdapter(), @@ -98,6 +110,7 @@ export class IaeModule { ARTIFACT_INTAKE_REPOSITORY_PORT, ARTIFACT_REPOSITORY_PORT, ARTIFACT_LINEAGE_REPOSITORY_PORT, + ARTIFACT_RETENTION_REPOSITORY_PORT, EVIDENCE_GRANT_REPOSITORY_PORT, ], }; diff --git a/services/api/test/features/iae/artifact-retention.service.test.ts b/services/api/test/features/iae/artifact-retention.service.test.ts new file mode 100644 index 00000000..a5ad0348 --- /dev/null +++ b/services/api/test/features/iae/artifact-retention.service.test.ts @@ -0,0 +1,97 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemoryArtifactRetentionRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.js'; +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { ArtifactRetentionService } from '../../../src/features/iae/application/artifact-retention.service.js'; +import { ArtifactService } from '../../../src/features/iae/application/artifact.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000711'; +const workspaceId = '00000000-0000-4000-8000-000000000712'; +const artifactId = '00000000-0000-4000-8000-000000000713'; +const versionId = '00000000-0000-4000-8000-000000000714'; + +function context(key: string) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000715', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000716', + idempotencyKey: key, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function retention(legalHold: boolean) { + return { + evaluatedAt: '2026-01-03T00:00:00.000Z', + workspaceRetentionUntil: '2025-12-01T00:00:00.000Z', + resourceRetentionUntil: '2025-12-01T00:00:00.000Z', + auditRetentionUntil: '2025-12-01T00:00:00.000Z', + recoveryWindowUntil: '2025-12-01T00:00:00.000Z', + activeApproval: false, + legalHold, + }; +} + +void test('[IAE-016, IAE-021] retention service preserves blocked requests and authorizes only after re-evaluation', async () => { + const artifacts = new InMemoryArtifactRepositoryAdapter(); + const tenantContext = context('retention-artifact'); + const artifactService = new ArtifactService(artifacts); + await artifactService.register(tenantContext, { + version: { + artifactId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceKind: 'FILE', + dataMode: 'Local', + contentSha256: 'd'.repeat(64), + byteSize: 1, + mediaType: 'text/plain', + displayName: 'private.txt', + createdAt: '2026-01-01T00:00:00.000Z', + }, + placement: { + placementId: '00000000-0000-4000-8000-000000000717', + tenantScope: tenantContext.tenantScope, + kind: 'LOCAL', + opaqueReference: 'local-placement-000003', + contentSha256: 'd'.repeat(64), + }, + }); + const service = new ArtifactRetentionService( + new InMemoryArtifactRetentionRepositoryAdapter(), + artifacts, + ); + const request = await service.request(tenantContext, { + requestId: '00000000-0000-4000-8000-000000000718', + artifactVersionId: versionId, + tenantScope: tenantContext.tenantScope, + requestedBy: tenantContext.actorId, + requestedAt: '2026-01-03T00:00:00.000Z', + retention: retention(true), + }); + assert.equal(request.accepted, true); + if (!request.accepted) return; + assert.equal(request.value.state, 'BLOCKED'); + const stale = await service.authorize(tenantContext, { + requestId: request.value.requestId, + retention: retention(true), + approvedAt: '2026-01-04T00:00:00.000Z', + mfaSatisfied: true, + expectedRevision: request.value.revision, + }); + assert.deepEqual(stale, { accepted: false, code: 'RETENTION_BLOCKED' }); + const authorized = await service.authorize(tenantContext, { + requestId: request.value.requestId, + retention: retention(false), + approvedAt: '2026-01-04T00:00:00.000Z', + mfaSatisfied: true, + expectedRevision: request.value.revision, + }); + assert.equal(authorized.accepted, true); + if (authorized.accepted) assert.equal(authorized.value.state, 'AUTHORIZED'); +}); From c678bed96582956b30c40cbf45bbb293545d9f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:12:56 +0700 Subject: [PATCH 038/177] feat(iae): define verifiable artifact export manifests --- packages/domain/package.json | 4 + packages/domain/src/artifact-export/v1.ts | 159 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/artifact-export-v1.test.mjs | 58 +++++++ .../domain/test/built-public-api-smoke.mjs | 3 + packages/domain/test/public-api-v1.test.mjs | 1 + 6 files changed, 226 insertions(+) create mode 100644 packages/domain/src/artifact-export/v1.ts create mode 100644 packages/domain/test/artifact-export-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index ffacafd7..12b5fb7b 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -76,6 +76,10 @@ "types": "./src/artifact-retention/v1.ts", "import": "./dist/artifact-retention/v1.js" }, + "./artifact-export/v1": { + "types": "./src/artifact-export/v1.ts", + "import": "./dist/artifact-export/v1.js" + }, "./dataset/v1": { "types": "./src/dataset/v1.ts", "import": "./dist/dataset/v1.js" diff --git a/packages/domain/src/artifact-export/v1.ts b/packages/domain/src/artifact-export/v1.ts new file mode 100644 index 00000000..316e95f6 --- /dev/null +++ b/packages/domain/src/artifact-export/v1.ts @@ -0,0 +1,159 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** IAE-018: independent verification manifest for governed artifact exports. */ +export const ARTIFACT_EXPORT_SCHEMA_VERSION_V1 = 1 as const; + +export type ExportApprovalStateV1 = 'NOT_REQUIRED' | 'PENDING' | 'APPROVED' | 'REJECTED'; + +export interface ArtifactExportEntryV1 { + readonly versionId: StableIdentifierV1; + readonly contentSha256: string; + readonly byteSize: number; + readonly evidenceIds: readonly StableIdentifierV1[]; + readonly processorVersions: readonly string[]; +} + +export interface ArtifactExportManifestV1 { + readonly schemaVersion: typeof ARTIFACT_EXPORT_SCHEMA_VERSION_V1; + readonly manifestId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly entries: readonly ArtifactExportEntryV1[]; + readonly approvalState: ExportApprovalStateV1; + readonly createdAt: StrictUtcTimestampV1; + readonly canonicalHash: string; +} + +export type ArtifactExportErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'CROSS_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_HASH' + | 'INVALID_ENTRY' + | 'DUPLICATE_IDENTIFIER' + | 'INVALID_APPROVAL'; + +export type ArtifactExportResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ArtifactExportErrorCodeV1 }; + +function accepted(value: TValue): ArtifactExportResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: ArtifactExportErrorCodeV1): ArtifactExportResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const result = parseStrictUtcTimestampV1(input); + return result.accepted ? result.value : undefined; +} + +function text(input: unknown): string | undefined { + return typeof input === 'string' && + input.length > 0 && + input.length <= 128 && + !/\p{Cc}/u.test(input) + ? input.normalize('NFC').trim() + : undefined; +} + +export function createArtifactExportManifestV1(input: { + readonly manifestId: unknown; + readonly tenantScope: unknown; + readonly entries: unknown; + readonly approvalState: unknown; + readonly createdAt: unknown; + readonly canonicalHash: unknown; +}): ArtifactExportResultV1 { + const manifestId = identifier(input.manifestId); + const tenantScope = parseTenantScopeV1(input.tenantScope); + const createdAt = timestamp(input.createdAt); + const canonicalHash = + typeof input.canonicalHash === 'string' && /^[0-9a-f]{64}$/u.test(input.canonicalHash) + ? input.canonicalHash.toLowerCase() + : undefined; + if (!manifestId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + if (!canonicalHash) return rejected('INVALID_HASH'); + if (!['NOT_REQUIRED', 'PENDING', 'APPROVED', 'REJECTED'].includes(input.approvalState as string)) + return rejected('INVALID_APPROVAL'); + if (!Array.isArray(input.entries) || input.entries.length === 0 || input.entries.length > 1024) + return rejected('INVALID_ENTRY'); + const entries: ArtifactExportEntryV1[] = []; + for (const candidate of input.entries) { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) + return rejected('INVALID_ENTRY'); + const record = candidate as Record; + const versionId = identifier(record['versionId']); + const contentSha256 = + typeof record['contentSha256'] === 'string' && /^[0-9a-f]{64}$/u.test(record['contentSha256']) + ? record['contentSha256'].toLowerCase() + : undefined; + const byteSize = record['byteSize']; + const evidenceIds = Array.isArray(record['evidenceIds']) + ? record['evidenceIds'].map(identifier) + : undefined; + const processorVersions = Array.isArray(record['processorVersions']) + ? record['processorVersions'].map(text) + : undefined; + if ( + !versionId || + !contentSha256 || + typeof byteSize !== 'number' || + !Number.isSafeInteger(byteSize) || + byteSize < 0 || + !evidenceIds || + evidenceIds.some((value): value is undefined => value === undefined) || + !processorVersions || + processorVersions.some((value): value is undefined => value === undefined) + ) + return rejected('INVALID_ENTRY'); + entries.push( + Object.freeze({ + versionId, + contentSha256, + byteSize, + evidenceIds: Object.freeze(evidenceIds as StableIdentifierV1[]), + processorVersions: Object.freeze(processorVersions as string[]), + }), + ); + } + if (new Set(entries.map((entry) => entry.versionId)).size !== entries.length) + return rejected('DUPLICATE_IDENTIFIER'); + if (entries.some((entry) => entry.evidenceIds.some((evidenceId) => !evidenceId))) + return rejected('INVALID_ENTRY'); + return accepted( + Object.freeze({ + schemaVersion: ARTIFACT_EXPORT_SCHEMA_VERSION_V1, + manifestId, + tenantScope: tenantScope.value, + entries: Object.freeze(entries), + approvalState: input.approvalState as ExportApprovalStateV1, + createdAt, + canonicalHash, + }), + ); +} + +export function exportScopesEqualV1( + left: ArtifactExportManifestV1, + right: ArtifactExportManifestV1, +): boolean { + return tenantScopesEqualV1(left.tenantScope, right.tenantScope); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 6d9e9706..2034d52e 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -4,6 +4,7 @@ export * from './artifact/v1.js'; export * from './artifact-intake/v1.js'; export * from './artifact-governance/v1.js'; export * from './artifact-retention/v1.js'; +export * from './artifact-export/v1.js'; export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; export * from './jobs/v1.js'; diff --git a/packages/domain/test/artifact-export-v1.test.mjs b/packages/domain/test/artifact-export-v1.test.mjs new file mode 100644 index 00000000..8e6cfb3b --- /dev/null +++ b/packages/domain/test/artifact-export-v1.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createArtifactExportManifestV1 } from '../dist/artifact-export/v1.js'; + +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000721', + workspaceId: '00000000-0000-4000-8000-000000000722', +}; + +void test('[IAE-018] export manifests preserve hashes, evidence references, and approval state', () => { + const result = createArtifactExportManifestV1({ + manifestId: '00000000-0000-4000-8000-000000000723', + tenantScope: scope, + entries: [ + { + versionId: '00000000-0000-4000-8000-000000000724', + contentSha256: 'a'.repeat(64), + byteSize: 10, + evidenceIds: ['00000000-0000-4000-8000-000000000725'], + processorVersions: ['spreadsheet-auditor@1'], + }, + ], + approvalState: 'APPROVED', + createdAt: '2026-01-03T00:00:00.000Z', + canonicalHash: 'b'.repeat(64), + }); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.entries[0].contentSha256, 'a'.repeat(64)); + assert.deepEqual( + createArtifactExportManifestV1({ + manifestId: '00000000-0000-4000-8000-000000000723', + tenantScope: scope, + entries: [ + { + versionId: '00000000-0000-4000-8000-000000000724', + contentSha256: 'a'.repeat(64), + byteSize: 10, + evidenceIds: [], + processorVersions: [], + }, + { + versionId: '00000000-0000-4000-8000-000000000724', + contentSha256: 'c'.repeat(64), + byteSize: 11, + evidenceIds: [], + processorVersions: [], + }, + ], + approvalState: 'PENDING', + createdAt: '2026-01-03T00:00:00.000Z', + canonicalHash: 'b'.repeat(64), + }), + { accepted: false, code: 'DUPLICATE_IDENTIFIER' }, + ); +}); diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 89d215d9..a57af3f5 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -9,6 +9,7 @@ const [ artifactIntake, artifactGovernance, artifactRetention, + artifactExport, dataset, datasetGovernance, dataMode, @@ -32,6 +33,7 @@ const [ import('@databreeze/domain/artifact-intake/v1'), import('@databreeze/domain/artifact-governance/v1'), import('@databreeze/domain/artifact-retention/v1'), + import('@databreeze/domain/artifact-export/v1'), import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/data-mode/v1'), @@ -57,6 +59,7 @@ assert.equal(artifact.ARTIFACT_SCHEMA_VERSION_V1, 1); assert.equal(artifactIntake.ARTIFACT_INTAKE_SCHEMA_VERSION_V1, 1); assert.equal(artifactGovernance.ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(artifactRetention.ARTIFACT_RETENTION_SCHEMA_VERSION_V1, 1); +assert.equal(artifactExport.ARTIFACT_EXPORT_SCHEMA_VERSION_V1, 1); assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index bd12d868..78400cdf 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -27,6 +27,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './artifact-intake/v1', './artifact-governance/v1', './artifact-retention/v1', + './artifact-export/v1', './dataset/v1', './dataset-governance/v1', './jobs/v1', From 33e2fe65da9f5b6443e019528ad92dd85b4b19ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:16:03 +0700 Subject: [PATCH 039/177] feat(iae): persist artifact export manifests --- ...mory-artifact-export-repository.adapter.ts | 78 ++++++++++++++ .../iae/api/artifact-export.controller.ts | 53 +++++++++ .../features/iae/api/artifact-export.dto.ts | 23 ++++ .../artifact-export-repository.port.ts | 20 ++++ .../application/artifact-export.service.ts | 101 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 17 ++- .../iae/artifact-export.service.test.ts | 92 ++++++++++++++++ 7 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/adapter/in-memory-artifact-export-repository.adapter.ts create mode 100644 services/api/src/features/iae/api/artifact-export.controller.ts create mode 100644 services/api/src/features/iae/api/artifact-export.dto.ts create mode 100644 services/api/src/features/iae/application/artifact-export-repository.port.ts create mode 100644 services/api/src/features/iae/application/artifact-export.service.ts create mode 100644 services/api/test/features/iae/artifact-export.service.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-export-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-export-repository.adapter.ts new file mode 100644 index 00000000..323a6399 --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-artifact-export-repository.adapter.ts @@ -0,0 +1,78 @@ +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { ArtifactExportManifestV1 } from '@databreeze/domain/artifact-export/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactExportRepositoryPortV1, + ArtifactExportTransactionPortV1, +} from '../application/artifact-export-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(manifest: ArtifactExportManifestV1): ArtifactExportManifestV1 { + return Object.freeze({ + ...manifest, + tenantScope: Object.freeze({ ...manifest.tenantScope }), + entries: Object.freeze( + manifest.entries.map((entry) => + Object.freeze({ + ...entry, + evidenceIds: Object.freeze([...entry.evidenceIds]), + processorVersions: Object.freeze([...entry.processorVersions]), + }), + ), + ), + }); +} + +export class InMemoryArtifactExportRepositoryAdapter implements ArtifactExportRepositoryPortV1 { + private manifests = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save( + context: IamTenantContextV1, + manifest: ArtifactExportManifestV1, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, manifest.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = this.manifests.get(manifest.manifestId); + if (existing && JSON.stringify(existing) !== JSON.stringify(manifest)) + throw new Error('IAE_IMMUTABLE_EXPORT_MANIFEST'); + this.manifests.set(manifest.manifestId, clone(manifest)); + } + + public async find( + context: IamTenantContextV1, + manifestId: ArtifactExportManifestV1['manifestId'], + ): Promise { + await Promise.resolve(); + const manifest = this.manifests.get(manifestId); + return manifest && visible(context.tenantScope, manifest.tenantScope) + ? clone(manifest) + : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactExportTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.manifests); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + } catch (error) { + this.manifests = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iae/api/artifact-export.controller.ts b/services/api/src/features/iae/api/artifact-export.controller.ts new file mode 100644 index 00000000..c1a44bfb --- /dev/null +++ b/services/api/src/features/iae/api/artifact-export.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ARTIFACT_REPOSITORY_PORT, + type ArtifactRepositoryPortV1, +} from '../application/artifact-repository.port.js'; +import { ArtifactService } from '../application/artifact.service.js'; +import { + ARTIFACT_LINEAGE_REPOSITORY_PORT, + type ArtifactLineageRepositoryPortV1, +} from '../application/artifact-lineage-repository.port.js'; +import { + ARTIFACT_EXPORT_REPOSITORY_PORT, + type ArtifactExportRepositoryPortV1, +} from '../application/artifact-export-repository.port.js'; +import { ArtifactExportService } from '../application/artifact-export.service.js'; +import { CreateArtifactExportDto } from './artifact-export.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifacts/exports') +export class ArtifactExportController { + private readonly exports: ArtifactExportService; + + public constructor( + @Inject(ARTIFACT_EXPORT_REPOSITORY_PORT) manifests: ArtifactExportRepositoryPortV1, + @Inject(ARTIFACT_REPOSITORY_PORT) artifacts: ArtifactRepositoryPortV1, + @Inject(ARTIFACT_LINEAGE_REPOSITORY_PORT) lineage: ArtifactLineageRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.exports = new ArtifactExportService(manifests, new ArtifactService(artifacts), lineage); + } + + @Post() + @ApiOperation({ summary: 'Create an immutable artifact verification manifest' }) + @ApiBody({ type: CreateArtifactExportDto }) + async create(@Req() request: unknown, @Body() input: CreateArtifactExportDto): Promise { + const context = await this.requestContext.resolve(request); + return this.exports.create(context, input); + } + + @Get(':manifestId') + @ApiOperation({ summary: 'Read an immutable artifact verification manifest' }) + async get(@Req() request: unknown, @Param('manifestId') manifestId: string): Promise { + const context = await this.requestContext.resolve(request); + return this.exports.find(context, manifestId); + } +} diff --git a/services/api/src/features/iae/api/artifact-export.dto.ts b/services/api/src/features/iae/api/artifact-export.dto.ts new file mode 100644 index 00000000..4c0fa9a9 --- /dev/null +++ b/services/api/src/features/iae/api/artifact-export.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsIn, IsISO8601, IsUUID, ArrayMaxSize, ArrayMinSize } from 'class-validator'; + +export class CreateArtifactExportDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + manifestId!: string; + + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(1024) + @IsUUID('4', { each: true }) + versionIds!: string[]; + + @ApiProperty({ enum: ['NOT_REQUIRED', 'PENDING', 'APPROVED', 'REJECTED'] }) + @IsIn(['NOT_REQUIRED', 'PENDING', 'APPROVED', 'REJECTED']) + approvalState!: 'NOT_REQUIRED' | 'PENDING' | 'APPROVED' | 'REJECTED'; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; +} diff --git a/services/api/src/features/iae/application/artifact-export-repository.port.ts b/services/api/src/features/iae/application/artifact-export-repository.port.ts new file mode 100644 index 00000000..8445f8a1 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-export-repository.port.ts @@ -0,0 +1,20 @@ +import type { ArtifactExportManifestV1 } from '@databreeze/domain/artifact-export/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const ARTIFACT_EXPORT_REPOSITORY_PORT = Symbol('ARTIFACT_EXPORT_REPOSITORY_PORT'); + +export interface ArtifactExportTransactionPortV1 { + save(context: IamTenantContextV1, manifest: ArtifactExportManifestV1): Promise; + find( + context: IamTenantContextV1, + manifestId: ArtifactExportManifestV1['manifestId'], + ): Promise; +} + +export interface ArtifactExportRepositoryPortV1 extends ArtifactExportTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactExportTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/iae/application/artifact-export.service.ts b/services/api/src/features/iae/application/artifact-export.service.ts new file mode 100644 index 00000000..eb149ef7 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-export.service.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto'; + +import { + createArtifactExportManifestV1, + type ArtifactExportManifestV1, + type ArtifactExportResultV1, +} from '@databreeze/domain/artifact-export/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import { ArtifactService } from './artifact.service.js'; +import type { ArtifactLineageRepositoryPortV1 } from './artifact-lineage-repository.port.js'; +import type { ArtifactExportRepositoryPortV1 } from './artifact-export-repository.port.js'; + +export type ArtifactExportServiceErrorV1 = 'ARTIFACT_NOT_FOUND'; +export type ArtifactExportServiceResultV1 = + | ArtifactExportResultV1 + | { readonly accepted: false; readonly code: ArtifactExportServiceErrorV1 }; + +/** Builds and stores an export manifest without copying protected source bytes. */ +export class ArtifactExportService { + public constructor( + private readonly manifests: ArtifactExportRepositoryPortV1, + private readonly artifacts: ArtifactService, + private readonly lineage: ArtifactLineageRepositoryPortV1, + ) {} + + public async create( + context: IamTenantContextV1, + input: { + readonly manifestId: unknown; + readonly versionIds: readonly unknown[]; + readonly approvalState: unknown; + readonly createdAt: unknown; + }, + ): Promise> { + const entries: Array<{ + readonly versionId: string; + readonly contentSha256: string; + readonly byteSize: number; + readonly evidenceIds: readonly string[]; + readonly processorVersions: readonly string[]; + }> = []; + for (const candidate of input.versionIds) { + const versionId = parseStableIdentifierV1(candidate); + if (!versionId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const found = await this.artifacts.find(context, versionId.value); + if (!found.version) + return Object.freeze({ accepted: false, code: 'ARTIFACT_NOT_FOUND' as const }); + const derivedLineage = await this.lineage.withTransaction(context, (transaction) => + transaction.findByDerived(context, versionId.value), + ); + entries.push({ + versionId: found.version.versionId, + contentSha256: found.version.contentSha256, + byteSize: found.version.byteSize, + evidenceIds: found.evidence.map((evidence) => evidence.evidenceId), + processorVersions: derivedLineage ? [derivedLineage.processorVersion] : [], + }); + } + const canonicalInput = JSON.stringify({ + tenantScope: context.tenantScope, + entries, + approvalState: input.approvalState, + }); + const canonicalHash = createHash('sha256').update(canonicalInput).digest('hex'); + const created = createArtifactExportManifestV1({ + manifestId: input.manifestId, + tenantScope: context.tenantScope, + entries, + approvalState: input.approvalState, + createdAt: input.createdAt, + canonicalHash, + }); + if (!created.accepted) return created; + return this.manifests.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.manifestId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return { accepted: true, value: existing }; + throw new Error('IAE_IMMUTABLE_EXPORT_MANIFEST'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + manifestIdInput: unknown, + ): Promise> { + const manifestId = parseStableIdentifierV1(manifestIdInput); + if (!manifestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const found = await this.manifests.find(context, manifestId.value); + return found + ? Object.freeze({ accepted: true, value: found }) + : Object.freeze({ accepted: false, code: 'ARTIFACT_NOT_FOUND' as const }); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 205b88cb..6bf2a24e 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -6,6 +6,7 @@ import { ArtifactReadController } from './api/artifact-read.controller.js'; import { ArtifactLineageController } from './api/artifact-lineage.controller.js'; import { ContentPlacementController } from './api/content-placement.controller.js'; import { ArtifactRetentionController } from './api/artifact-retention.controller.js'; +import { ArtifactExportController } from './api/artifact-export.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -14,6 +15,7 @@ import { import { InMemoryArtifactRepositoryAdapter } from './adapter/in-memory-artifact-repository.adapter.js'; import { InMemoryArtifactLineageRepositoryAdapter } from './adapter/in-memory-artifact-lineage-repository.adapter.js'; import { InMemoryArtifactRetentionRepositoryAdapter } from './adapter/in-memory-artifact-retention-repository.adapter.js'; +import { InMemoryArtifactExportRepositoryAdapter } from './adapter/in-memory-artifact-export-repository.adapter.js'; import { PrismaArtifactRepositoryAdapter, type ArtifactDatabaseClientV1, @@ -35,6 +37,10 @@ import { ARTIFACT_RETENTION_REPOSITORY_PORT, type ArtifactRetentionRepositoryPortV1, } from './application/artifact-retention-repository.port.js'; +import { + ARTIFACT_EXPORT_REPOSITORY_PORT, + type ArtifactExportRepositoryPortV1, +} from './application/artifact-export-repository.port.js'; import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1, @@ -54,6 +60,7 @@ export interface IaeModuleOptions { readonly artifactDatabase?: ArtifactDatabaseClientV1; readonly artifactLineageRepository?: ArtifactLineageRepositoryPortV1; readonly artifactRetentionRepository?: ArtifactRetentionRepositoryPortV1; + readonly artifactExportRepository?: ArtifactExportRepositoryPortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -70,6 +77,7 @@ export class IaeModule { ArtifactLineageController, ContentPlacementController, ArtifactRetentionController, + ArtifactExportController, ], providers: [ { @@ -95,7 +103,13 @@ export class IaeModule { }, { provide: ARTIFACT_RETENTION_REPOSITORY_PORT, - useValue: options.artifactRetentionRepository ?? new InMemoryArtifactRetentionRepositoryAdapter(), + useValue: + options.artifactRetentionRepository ?? new InMemoryArtifactRetentionRepositoryAdapter(), + }, + { + provide: ARTIFACT_EXPORT_REPOSITORY_PORT, + useValue: + options.artifactExportRepository ?? new InMemoryArtifactExportRepositoryAdapter(), }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, @@ -111,6 +125,7 @@ export class IaeModule { ARTIFACT_REPOSITORY_PORT, ARTIFACT_LINEAGE_REPOSITORY_PORT, ARTIFACT_RETENTION_REPOSITORY_PORT, + ARTIFACT_EXPORT_REPOSITORY_PORT, EVIDENCE_GRANT_REPOSITORY_PORT, ], }; diff --git a/services/api/test/features/iae/artifact-export.service.test.ts b/services/api/test/features/iae/artifact-export.service.test.ts new file mode 100644 index 00000000..ea057ef4 --- /dev/null +++ b/services/api/test/features/iae/artifact-export.service.test.ts @@ -0,0 +1,92 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemoryArtifactExportRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-export-repository.adapter.js'; +import { InMemoryArtifactLineageRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-lineage-repository.adapter.js'; +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { ArtifactExportService } from '../../../src/features/iae/application/artifact-export.service.js'; +import { ArtifactGovernanceService } from '../../../src/features/iae/application/artifact-governance.service.js'; +import { ArtifactService } from '../../../src/features/iae/application/artifact.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000731'; +const workspaceId = '00000000-0000-4000-8000-000000000732'; +const artifactId = '00000000-0000-4000-8000-000000000733'; +const versionId = '00000000-0000-4000-8000-000000000734'; + +function context(key: string) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000735', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000736', + idempotencyKey: key, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[IAE-018] export service creates an idempotent manifest with exact evidence and lineage', async () => { + const artifacts = new InMemoryArtifactRepositoryAdapter(); + const lineage = new InMemoryArtifactLineageRepositoryAdapter(); + const tenantContext = context('export-artifact'); + await new ArtifactService(artifacts).register(tenantContext, { + version: { + artifactId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceKind: 'GENERATED', + dataMode: 'Hybrid', + contentSha256: 'e'.repeat(64), + byteSize: 10, + mediaType: 'text/csv', + displayName: 'derived.csv', + createdAt: '2026-01-01T00:00:00.000Z', + }, + placement: { + placementId: '00000000-0000-4000-8000-000000000737', + tenantScope: tenantContext.tenantScope, + kind: 'CLOUD', + opaqueReference: 'cloud-placement-000004', + contentSha256: 'e'.repeat(64), + }, + evidence: { + evidenceId: '00000000-0000-4000-8000-000000000738', + tenantScope: tenantContext.tenantScope, + coordinate: { kind: 'ROW', row: 1 }, + }, + }); + const governance = new ArtifactGovernanceService(lineage); + await governance.registerLineage(tenantContext, { + lineageId: '00000000-0000-4000-8000-000000000739', + derivedArtifactVersionId: versionId, + tenantScope: tenantContext.tenantScope, + sourceArtifactVersionIds: ['00000000-0000-4000-8000-000000000740'], + sourceTenantScopes: [tenantContext.tenantScope], + processorVersion: 'spreadsheet-auditor@1', + coordinateLineage: [], + }); + const service = new ArtifactExportService( + new InMemoryArtifactExportRepositoryAdapter(), + new ArtifactService(artifacts), + lineage, + ); + const created = await service.create(tenantContext, { + manifestId: '00000000-0000-4000-8000-000000000741', + versionIds: [versionId], + approvalState: 'APPROVED', + createdAt: '2026-01-03T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.deepEqual(created.value.entries[0]?.evidenceIds, ['00000000-0000-4000-8000-000000000738']); + assert.deepEqual(created.value.entries[0]?.processorVersions, ['spreadsheet-auditor@1']); + const repeated = await service.create(tenantContext, { + manifestId: '00000000-0000-4000-8000-000000000741', + versionIds: [versionId], + approvalState: 'APPROVED', + createdAt: '2026-01-03T00:00:00.000Z', + }); + assert.deepEqual(repeated, created); +}); From 98bcb59072148d0449665f921a2900241fb5797e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:17:40 +0700 Subject: [PATCH 040/177] feat(dsm): expose governed dataset publication APIs --- .../dsm/api/governed-dataset.controller.ts | 71 +++++++++++++++- .../features/dsm/api/governed-dataset.dto.ts | 10 +++ .../application/governed-dataset.service.ts | 12 +++ .../dsm/governed-dataset.controller.test.ts | 83 +++++++++++++++++++ 4 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 services/api/test/features/dsm/governed-dataset.controller.test.ts diff --git a/services/api/src/features/dsm/api/governed-dataset.controller.ts b/services/api/src/features/dsm/api/governed-dataset.controller.ts index 976d8e03..3247f46b 100644 --- a/services/api/src/features/dsm/api/governed-dataset.controller.ts +++ b/services/api/src/features/dsm/api/governed-dataset.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, Inject, Param, Post, Query, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; @@ -7,7 +7,7 @@ import { type GovernedDatasetRepositoryPortV1, } from '../application/governed-dataset-repository.port.js'; import { GovernedDatasetService } from '../application/governed-dataset.service.js'; -import { CreateGovernedDatasetDto } from './governed-dataset.dto.js'; +import { CreateGovernedDatasetDto, PublishGovernedDatasetDto } from './governed-dataset.dto.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -50,4 +50,71 @@ export class GovernedDatasetController { if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; return this.datasets.list(context, datasetId.value); } + + @Get(':datasetId/versions/:versionId') + @ApiOperation({ summary: 'Read one exact immutable governed dataset definition' }) + async getVersion( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + @Param('versionId') versionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!datasetId.accepted || !versionId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const result = await this.datasets.find(context, versionId.value); + if (!result.accepted || result.value.datasetId !== datasetId.value) + return { accepted: false, code: 'VERSION_NOT_FOUND' as const }; + return result; + } + + @Post(':datasetId/versions/:versionId/publish') + @HttpCode(200) + @ApiOperation({ summary: 'Publish a governed dataset definition as a new immutable version' }) + @ApiBody({ type: PublishGovernedDatasetDto }) + async publish( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + @Param('versionId') versionIdInput: string, + @Body() input: PublishGovernedDatasetDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!datasetId.accepted || !versionId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const current = await this.datasets.find(context, versionId.value); + if (!current.accepted || current.value.datasetId !== datasetId.value) + return { accepted: false, code: 'VERSION_NOT_FOUND' as const }; + return this.datasets.publish(context, versionId.value, input.nextVersionId, input.publishedAt); + } + + @Get(':datasetId/compatibility') + @ApiOperation({ summary: 'Classify compatibility between two exact schema versions' }) + async compare( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + @Query('previousVersionId') previousVersionIdInput: string, + @Query('nextVersionId') nextVersionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + const previousVersionId = parseStableIdentifierV1(previousVersionIdInput); + const nextVersionId = parseStableIdentifierV1(nextVersionIdInput); + if (!datasetId.accepted || !previousVersionId.accepted || !nextVersionId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const [previous, next] = await Promise.all([ + this.datasets.find(context, previousVersionId.value), + this.datasets.find(context, nextVersionId.value), + ]); + if ( + !previous.accepted || + !next.accepted || + previous.value.datasetId !== datasetId.value || + next.value.datasetId !== datasetId.value + ) + return { accepted: false, code: 'VERSION_NOT_FOUND' as const }; + return this.datasets.compare(context, previousVersionId.value, nextVersionId.value); + } } diff --git a/services/api/src/features/dsm/api/governed-dataset.dto.ts b/services/api/src/features/dsm/api/governed-dataset.dto.ts index f5152f27..44093518 100644 --- a/services/api/src/features/dsm/api/governed-dataset.dto.ts +++ b/services/api/src/features/dsm/api/governed-dataset.dto.ts @@ -96,3 +96,13 @@ export class CreateGovernedDatasetDto { @MaxLength(64) canonicalHash!: string; } + +export class PublishGovernedDatasetDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + nextVersionId!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + publishedAt!: string; +} diff --git a/services/api/src/features/dsm/application/governed-dataset.service.ts b/services/api/src/features/dsm/application/governed-dataset.service.ts index 7ec1903b..8363f5d8 100644 --- a/services/api/src/features/dsm/application/governed-dataset.service.ts +++ b/services/api/src/features/dsm/application/governed-dataset.service.ts @@ -78,4 +78,16 @@ export class GovernedDatasetService { transaction.list(context, datasetId), ); } + + public async find( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + ): Promise> { + return this.repository.withTransaction(context, async (transaction) => { + const definition = await transaction.find(context, versionId); + return definition + ? Object.freeze({ accepted: true as const, value: definition }) + : Object.freeze({ accepted: false as const, code: 'VERSION_NOT_FOUND' as const }); + }); + } } diff --git a/services/api/test/features/dsm/governed-dataset.controller.test.ts b/services/api/test/features/dsm/governed-dataset.controller.test.ts new file mode 100644 index 00000000..f0cea603 --- /dev/null +++ b/services/api/test/features/dsm/governed-dataset.controller.test.ts @@ -0,0 +1,83 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryGovernedDatasetRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-governed-dataset-repository.adapter.js'; +import { GovernedDatasetService } from '../../../src/features/dsm/application/governed-dataset.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000751'; +const workspaceId = '00000000-0000-4000-8000-000000000752'; +const datasetId = '00000000-0000-4000-8000-000000000753'; +const versionId = '00000000-0000-4000-8000-000000000754'; +const publishedVersionId = '00000000-0000-4000-8000-000000000755'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000756', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000757', + idempotencyKey: 'dataset-controller', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[DSM-005, DSM-006, DSM-018, DSM-021] governed dataset HTTP surfaces publish and compare immutable versions', async () => { + const repository = new InMemoryGovernedDatasetRepositoryAdapter(); + const tenantContext = context(); + const service = new GovernedDatasetService(repository); + const created = await service.create(tenantContext, { + datasetId, + versionId, + tenantScope: tenantContext.tenantScope, + name: 'Orders', + fields: [ + { + fieldId: '00000000-0000-4000-8000-000000000758', + name: 'amount', + type: 'DECIMAL', + nullable: true, + }, + ], + createdAt: '2026-01-01T00:00:00.000Z', + canonicalHash: 'a'.repeat(64), + }); + assert.equal(created.accepted, true); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + governedDatasetRepository: repository, + requestTenantContext, + }); + try { + const published = await app.inject({ + method: 'POST', + url: `/v1/datasets/${datasetId}/versions/${versionId}/publish`, + payload: { + nextVersionId: publishedVersionId, + publishedAt: '2026-01-01T00:01:00.000Z', + }, + }); + assert.equal(published.statusCode, 200); + assert.equal(published.json().value.status, 'PUBLISHED'); + const read = await app.inject({ + method: 'GET', + url: `/v1/datasets/${datasetId}/versions/${publishedVersionId}`, + }); + assert.equal(read.statusCode, 200); + assert.equal(read.json().value.versionId, publishedVersionId); + const comparison = await app.inject({ + method: 'GET', + url: `/v1/datasets/${datasetId}/compatibility?previousVersionId=${versionId}&nextVersionId=${publishedVersionId}`, + }); + assert.equal(comparison.statusCode, 200); + assert.deepEqual(comparison.json(), { accepted: true, value: 'ADDITIVE_COMPATIBLE' }); + } finally { + await app.close(); + } +}); From aef376c591dc89f6d96d87fef9e231802a234cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:18:53 +0700 Subject: [PATCH 041/177] feat(dsm): expose mapping publication endpoint --- .../features/dsm/api/mapping.controller.ts | 22 +++++- .../api/src/features/dsm/api/mapping.dto.ts | 10 +++ .../features/dsm/mapping.controller.test.ts | 68 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 services/api/test/features/dsm/mapping.controller.test.ts diff --git a/services/api/src/features/dsm/api/mapping.controller.ts b/services/api/src/features/dsm/api/mapping.controller.ts index bc142d38..76a84ef6 100644 --- a/services/api/src/features/dsm/api/mapping.controller.ts +++ b/services/api/src/features/dsm/api/mapping.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, Inject, Param, Post, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; @@ -7,7 +7,7 @@ import { type MappingRepositoryPortV1, } from '../application/mapping-repository.port.js'; import { MappingService } from '../application/mapping.service.js'; -import { CreateMappingDto } from './mapping.dto.js'; +import { CreateMappingDto, PublishDefinitionDto } from './mapping.dto.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -55,4 +55,22 @@ export class MappingController { if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; return this.mappings.list(context, datasetId.value); } + + @Post(':versionId/publish') + @HttpCode(200) + @ApiOperation({ summary: 'Publish a mapping definition as a new immutable version' }) + @ApiBody({ type: PublishDefinitionDto }) + async publish( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + @Param('versionId') versionIdInput: string, + @Body() input: PublishDefinitionDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!datasetId.accepted || !versionId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.mappings.publish(context, versionId.value, input.nextVersionId, input.publishedAt); + } } diff --git a/services/api/src/features/dsm/api/mapping.dto.ts b/services/api/src/features/dsm/api/mapping.dto.ts index 4b087f02..3b613e6a 100644 --- a/services/api/src/features/dsm/api/mapping.dto.ts +++ b/services/api/src/features/dsm/api/mapping.dto.ts @@ -95,3 +95,13 @@ export class CreateRuleSetDto { @MaxLength(64) canonicalHash!: string; } + +export class PublishDefinitionDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + nextVersionId!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + publishedAt!: string; +} diff --git a/services/api/test/features/dsm/mapping.controller.test.ts b/services/api/test/features/dsm/mapping.controller.test.ts new file mode 100644 index 00000000..5646ba24 --- /dev/null +++ b/services/api/test/features/dsm/mapping.controller.test.ts @@ -0,0 +1,68 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryMappingRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-mapping-repository.adapter.js'; +import { MappingService } from '../../../src/features/dsm/application/mapping.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000761'; +const workspaceId = '00000000-0000-4000-8000-000000000762'; +const datasetId = '00000000-0000-4000-8000-000000000763'; +const versionId = '00000000-0000-4000-8000-000000000764'; +const nextVersionId = '00000000-0000-4000-8000-000000000765'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000766', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000767', + idempotencyKey: 'mapping-controller', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[DSM-009, DSM-010, DSM-021] mapping publication is exposed as an immutable version transition', async () => { + const repository = new InMemoryMappingRepositoryAdapter(); + const tenantContext = context(); + const service = new MappingService(repository); + const created = await service.create(tenantContext, { + datasetId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceSchemaVersionId: '00000000-0000-4000-8000-000000000768', + targetSchemaVersionId: '00000000-0000-4000-8000-000000000769', + steps: [ + { + sourceFieldId: '00000000-0000-4000-8000-000000000770', + targetFieldId: '00000000-0000-4000-8000-000000000771', + transform: 'TRIM', + }, + ], + createdAt: '2026-01-01T00:00:00.000Z', + canonicalHash: 'a'.repeat(64), + }); + assert.equal(created.accepted, true); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + mappingRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: `/v1/datasets/${datasetId}/mappings/${versionId}/publish`, + payload: { nextVersionId, publishedAt: '2026-01-01T00:01:00.000Z' }, + }); + assert.equal(response.statusCode, 200); + assert.equal(response.json().value.status, 'PUBLISHED'); + } finally { + await app.close(); + } +}); From 2269787dfb74ef389f8eb3d3fb403e5ae253e40f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:18:53 +0700 Subject: [PATCH 042/177] feat(dsm): expose rule-set publication endpoint --- .../features/dsm/api/rule-set.controller.ts | 22 +++++- .../features/dsm/rule-set.controller.test.ts | 69 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 services/api/test/features/dsm/rule-set.controller.test.ts diff --git a/services/api/src/features/dsm/api/rule-set.controller.ts b/services/api/src/features/dsm/api/rule-set.controller.ts index dcf3187e..cf813372 100644 --- a/services/api/src/features/dsm/api/rule-set.controller.ts +++ b/services/api/src/features/dsm/api/rule-set.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, Inject, Param, Post, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; @@ -7,7 +7,7 @@ import { type RuleSetRepositoryPortV1, } from '../application/rule-set-repository.port.js'; import { RuleSetService } from '../application/rule-set.service.js'; -import { CreateRuleSetDto } from './mapping.dto.js'; +import { CreateRuleSetDto, PublishDefinitionDto } from './mapping.dto.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -55,4 +55,22 @@ export class RuleSetController { if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; return this.ruleSets.list(context, datasetId.value); } + + @Post(':versionId/publish') + @HttpCode(200) + @ApiOperation({ summary: 'Publish a quality rule set as a new immutable version' }) + @ApiBody({ type: PublishDefinitionDto }) + async publish( + @Req() request: unknown, + @Param('datasetId') datasetIdInput: string, + @Param('versionId') versionIdInput: string, + @Body() input: PublishDefinitionDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!datasetId.accepted || !versionId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.ruleSets.publish(context, versionId.value, input.nextVersionId, input.publishedAt); + } } diff --git a/services/api/test/features/dsm/rule-set.controller.test.ts b/services/api/test/features/dsm/rule-set.controller.test.ts new file mode 100644 index 00000000..b0918f65 --- /dev/null +++ b/services/api/test/features/dsm/rule-set.controller.test.ts @@ -0,0 +1,69 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryRuleSetRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-rule-set-repository.adapter.js'; +import { RuleSetService } from '../../../src/features/dsm/application/rule-set.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000781'; +const workspaceId = '00000000-0000-4000-8000-000000000782'; +const datasetId = '00000000-0000-4000-8000-000000000783'; +const versionId = '00000000-0000-4000-8000-000000000784'; +const nextVersionId = '00000000-0000-4000-8000-000000000785'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000786', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000787', + idempotencyKey: 'rule-controller', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[DSM-008, DSM-010, DSM-021] rule-set publication preserves typed deterministic rules', async () => { + const repository = new InMemoryRuleSetRepositoryAdapter(); + const tenantContext = context(); + const service = new RuleSetService(repository); + const created = await service.create(tenantContext, { + datasetId, + versionId, + tenantScope: tenantContext.tenantScope, + schemaVersionId: '00000000-0000-4000-8000-000000000788', + rules: [ + { + ruleId: '00000000-0000-4000-8000-000000000789', + fieldId: '00000000-0000-4000-8000-000000000790', + kind: 'REQUIRED', + severity: 'ERROR', + parameters: {}, + }, + ], + createdAt: '2026-01-01T00:00:00.000Z', + canonicalHash: 'a'.repeat(64), + }); + assert.equal(created.accepted, true); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + ruleSetRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: `/v1/datasets/${datasetId}/rules/${versionId}/publish`, + payload: { nextVersionId, publishedAt: '2026-01-01T00:01:00.000Z' }, + }); + assert.equal(response.statusCode, 200); + assert.equal(response.json().value.status, 'PUBLISHED'); + } finally { + await app.close(); + } +}); From 66d2a11849989a0f2e594cc6bb7a29d1399f0737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:19:47 +0700 Subject: [PATCH 043/177] feat(dsm): expose reference entity version history --- .../dsm/api/reference-entity.controller.ts | 30 +++++++++ .../application/reference-entity.service.ts | 12 ++++ .../dsm/reference-entity.controller.test.ts | 67 +++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 services/api/test/features/dsm/reference-entity.controller.test.ts diff --git a/services/api/src/features/dsm/api/reference-entity.controller.ts b/services/api/src/features/dsm/api/reference-entity.controller.ts index 1c6fcbfc..06aa3b33 100644 --- a/services/api/src/features/dsm/api/reference-entity.controller.ts +++ b/services/api/src/features/dsm/api/reference-entity.controller.ts @@ -50,4 +50,34 @@ export class ReferenceEntityController { if (!entityId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; return this.entities.listVersions(context, entityId.value); } + + @Get(':entityId/versions/:versionId') + @ApiOperation({ summary: 'Read one exact immutable business-party version' }) + async getVersion( + @Req() request: unknown, + @Param('entityId') entityIdInput: string, + @Param('versionId') versionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const entityId = parseStableIdentifierV1(entityIdInput); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!entityId.accepted || !versionId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const result = await this.entities.findVersion(context, versionId.value); + if (!result.accepted || result.value.entityId !== entityId.value) + return { accepted: false, code: 'ENTITY_NOT_FOUND' as const }; + return result; + } + + @Get(':entityId/resolutions') + @ApiOperation({ summary: 'List immutable merge and resolution history' }) + async resolutions( + @Req() request: unknown, + @Param('entityId') entityIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const entityId = parseStableIdentifierV1(entityIdInput); + if (!entityId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.entities.listResolutions(context, entityId.value); + } } diff --git a/services/api/src/features/dsm/application/reference-entity.service.ts b/services/api/src/features/dsm/application/reference-entity.service.ts index 249402f2..cdcbf381 100644 --- a/services/api/src/features/dsm/application/reference-entity.service.ts +++ b/services/api/src/features/dsm/application/reference-entity.service.ts @@ -83,6 +83,18 @@ export class ReferenceEntityService { ); } + public async findVersion( + context: IamTenantContextV1, + versionId: StableIdentifierV1, + ): Promise> { + return this.repository.withTransaction(context, async (transaction) => { + const version = await transaction.findVersion(context, versionId); + return version + ? Object.freeze({ accepted: true as const, value: version }) + : Object.freeze({ accepted: false as const, code: 'ENTITY_NOT_FOUND' as const }); + }); + } + public async listResolutions( context: IamTenantContextV1, entityId: StableIdentifierV1, diff --git a/services/api/test/features/dsm/reference-entity.controller.test.ts b/services/api/test/features/dsm/reference-entity.controller.test.ts new file mode 100644 index 00000000..9748a4e7 --- /dev/null +++ b/services/api/test/features/dsm/reference-entity.controller.test.ts @@ -0,0 +1,67 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryReferenceEntityRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-reference-entity-repository.adapter.js'; +import { ReferenceEntityService } from '../../../src/features/dsm/application/reference-entity.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000791'; +const workspaceId = '00000000-0000-4000-8000-000000000792'; +const entityId = '00000000-0000-4000-8000-000000000793'; +const versionId = '00000000-0000-4000-8000-000000000794'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000795', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000796', + idempotencyKey: 'reference-controller', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[DSM-025, DSM-026, DSM-027] reference entity API exposes exact versions and resolution history', async () => { + const repository = new InMemoryReferenceEntityRepositoryAdapter(); + const tenantContext = context(); + const service = new ReferenceEntityService(repository); + const created = await service.create(tenantContext, { + entityId, + versionId, + tenantScope: tenantContext.tenantScope, + displayName: 'Công ty Ánh Dương', + roles: ['SUPPLIER'], + aliases: ['Anh Duong'], + externalIdentifiers: [{ namespace: 'tax', value: '0101234567' }], + canonicalHash: 'a'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + referenceEntityRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'GET', + url: `/v1/reference-entities/${entityId}/versions/${versionId}`, + }); + assert.equal(response.statusCode, 200); + assert.equal(response.json().value.displayName, 'Công ty Ánh Dương'); + const resolutions = await app.inject({ + method: 'GET', + url: `/v1/reference-entities/${entityId}/resolutions`, + }); + assert.equal(resolutions.statusCode, 200); + assert.deepEqual(resolutions.json(), []); + } finally { + await app.close(); + } +}); From f23dc51ff4b3033f3b78216a6f56fb8929e6d470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:21:10 +0700 Subject: [PATCH 044/177] feat(dsm): persist immutable dataset result manifests --- ...mory-dataset-version-repository.adapter.ts | 67 +++++++++++++++++++ .../dsm/api/dataset-version.controller.ts | 53 +++++++++++++++ .../features/dsm/api/dataset-version.dto.ts | 62 +++++++++++++++++ .../dataset-version-repository.port.ts | 20 ++++++ .../application/dataset-version.service.ts | 45 +++++++++++++ services/api/src/features/dsm/dsm.module.ts | 13 ++++ .../dsm/dataset-version.controller.test.ts | 63 +++++++++++++++++ 7 files changed, 323 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts create mode 100644 services/api/src/features/dsm/api/dataset-version.controller.ts create mode 100644 services/api/src/features/dsm/api/dataset-version.dto.ts create mode 100644 services/api/src/features/dsm/application/dataset-version-repository.port.ts create mode 100644 services/api/src/features/dsm/application/dataset-version.service.ts create mode 100644 services/api/test/features/dsm/dataset-version.controller.test.ts diff --git a/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts new file mode 100644 index 00000000..6517e8d1 --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts @@ -0,0 +1,67 @@ +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { DatasetVersionManifestV1 } from '@databreeze/domain/dataset-governance/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetVersionRepositoryPortV1, + DatasetVersionTransactionPortV1, +} from '../application/dataset-version-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(version: DatasetVersionManifestV1): DatasetVersionManifestV1 { + return Object.freeze({ + ...version, + tenantScope: Object.freeze({ ...version.tenantScope }), + inputArtifactVersionIds: Object.freeze([...version.inputArtifactVersionIds]), + }); +} + +export class InMemoryDatasetVersionRepositoryAdapter implements DatasetVersionRepositoryPortV1 { + private versions = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, version: DatasetVersionManifestV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, version.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = this.versions.get(version.versionId); + if (existing && JSON.stringify(existing) !== JSON.stringify(version)) + throw new Error('DSM_IMMUTABLE_DATASET_VERSION'); + this.versions.set(version.versionId, clone(version)); + } + + public async find( + context: IamTenantContextV1, + versionId: DatasetVersionManifestV1['versionId'], + ): Promise { + await Promise.resolve(); + const version = this.versions.get(versionId); + return version && visible(context.tenantScope, version.tenantScope) + ? clone(version) + : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetVersionTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.versions); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + } catch (error) { + this.versions = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/dsm/api/dataset-version.controller.ts b/services/api/src/features/dsm/api/dataset-version.controller.ts new file mode 100644 index 00000000..87ce6576 --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-version.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + DATASET_VERSION_REPOSITORY_PORT, + type DatasetVersionRepositoryPortV1, +} from '../application/dataset-version-repository.port.js'; +import { DatasetVersionService } from '../application/dataset-version.service.js'; +import { RegisterDatasetVersionDto } from './dataset-version.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('datasets') +@ApiBearerAuth() +@Controller('v1/dataset-versions') +export class DatasetVersionController { + private readonly versions: DatasetVersionService; + + public constructor( + @Inject(DATASET_VERSION_REPOSITORY_PORT) repository: DatasetVersionRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.versions = new DatasetVersionService(repository); + } + + @Post() + @ApiOperation({ summary: 'Register an immutable dataset result manifest' }) + @ApiBody({ type: RegisterDatasetVersionDto }) + async register( + @Req() request: unknown, + @Body() input: RegisterDatasetVersionDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.versions.register(context, { + ...input, + tenantScope: context.tenantScope, + contentFingerprint: input.contentFingerprint, + lineageManifestHash: input.lineageManifestHash, + }); + } + + @Get(':versionId') + @ApiOperation({ summary: 'Read an exact immutable dataset result manifest' }) + async get(@Req() request: unknown, @Param('versionId') versionIdInput: string): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!versionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.versions.find(context, versionId.value); + } +} diff --git a/services/api/src/features/dsm/api/dataset-version.dto.ts b/services/api/src/features/dsm/api/dataset-version.dto.ts new file mode 100644 index 00000000..4c6b80b5 --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-version.dto.ts @@ -0,0 +1,62 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsInt, + IsISO8601, + IsUUID, + Max, + Min, + MinLength, +} from 'class-validator'; + +export class RegisterDatasetVersionDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + datasetId!: string; + + @ApiProperty({ format: 'uuid', type: [String] }) + @IsArray() + @ArrayMaxSize(1024) + @IsUUID('4', { each: true }) + inputArtifactVersionIds!: string[]; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + schemaVersionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + mappingVersionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + ruleSetVersionId!: string; + + @ApiProperty({ minLength: 1, maxLength: 128 }) + @MinLength(1) + engineBuild!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsUUID() + versionId!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @MinLength(64) + contentFingerprint!: string; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + rowCount!: number; + + @ApiProperty({ enum: ['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE'] }) + @IsIn(['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE']) + qualityState!: 'PASS' | 'PASS_WITH_WARNINGS' | 'BLOCKED' | 'INCOMPLETE'; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @MinLength(64) + lineageManifestHash!: string; +} diff --git a/services/api/src/features/dsm/application/dataset-version-repository.port.ts b/services/api/src/features/dsm/application/dataset-version-repository.port.ts new file mode 100644 index 00000000..2acc2085 --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-version-repository.port.ts @@ -0,0 +1,20 @@ +import type { DatasetVersionManifestV1 } from '@databreeze/domain/dataset-governance/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const DATASET_VERSION_REPOSITORY_PORT = Symbol('DATASET_VERSION_REPOSITORY_PORT'); + +export interface DatasetVersionTransactionPortV1 { + save(context: IamTenantContextV1, version: DatasetVersionManifestV1): Promise; + find( + context: IamTenantContextV1, + versionId: DatasetVersionManifestV1['versionId'], + ): Promise; +} + +export interface DatasetVersionRepositoryPortV1 extends DatasetVersionTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetVersionTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/dsm/application/dataset-version.service.ts b/services/api/src/features/dsm/application/dataset-version.service.ts new file mode 100644 index 00000000..e331f087 --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-version.service.ts @@ -0,0 +1,45 @@ +import { + createDatasetVersionManifestV1, + type DatasetVersionManifestV1, + type DatasetGovernanceResultV1, +} from '@databreeze/domain/dataset-governance/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { DatasetVersionRepositoryPortV1 } from './dataset-version-repository.port.js'; + +export type DatasetVersionServiceErrorV1 = 'VERSION_NOT_FOUND'; +export type DatasetVersionServiceResultV1 = + | DatasetGovernanceResultV1 + | { readonly accepted: false; readonly code: DatasetVersionServiceErrorV1 }; + +export class DatasetVersionService { + public constructor(private readonly repository: DatasetVersionRepositoryPortV1) {} + + public async register( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { + const created = createDatasetVersionManifestV1(input); + if (!created.accepted) return created; + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.versionId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return { accepted: true, value: existing }; + throw new Error('DSM_IMMUTABLE_DATASET_VERSION'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + versionId: DatasetVersionManifestV1['versionId'], + ): Promise> { + const found = await this.repository.find(context, versionId); + return found + ? Object.freeze({ accepted: true, value: found }) + : Object.freeze({ accepted: false, code: 'VERSION_NOT_FOUND' as const }); + } +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index 3bdb53f6..2a15d059 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -4,6 +4,7 @@ import { GovernedDatasetController } from './api/governed-dataset.controller.js' import { MappingController } from './api/mapping.controller.js'; import { ReferenceEntityController } from './api/reference-entity.controller.js'; import { RuleSetController } from './api/rule-set.controller.js'; +import { DatasetVersionController } from './api/dataset-version.controller.js'; import { InMemoryGovernedDatasetRepositoryAdapter } from './adapter/in-memory-governed-dataset-repository.adapter.js'; import { PrismaGovernedDatasetRepositoryAdapter, @@ -20,6 +21,7 @@ import { type ReferenceEntityDatabaseClientV1, } from './adapter/prisma-reference-entity-repository.adapter.js'; import { InMemoryRuleSetRepositoryAdapter } from './adapter/in-memory-rule-set-repository.adapter.js'; +import { InMemoryDatasetVersionRepositoryAdapter } from './adapter/in-memory-dataset-version-repository.adapter.js'; import { PrismaRuleSetRepositoryAdapter, type RuleSetDatabaseClientV1, @@ -40,6 +42,10 @@ import { RULE_SET_REPOSITORY_PORT, type RuleSetRepositoryPortV1, } from './application/rule-set-repository.port.js'; +import { + DATASET_VERSION_REPOSITORY_PORT, + type DatasetVersionRepositoryPortV1, +} from './application/dataset-version-repository.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -59,6 +65,7 @@ export interface DsmModuleOptions { readonly referenceEntityRepository?: ReferenceEntityRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly referenceEntityDatabase?: ReferenceEntityDatabaseClientV1; + readonly datasetVersionRepository?: DatasetVersionRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -72,6 +79,7 @@ export class DsmModule { MappingController, RuleSetController, ReferenceEntityController, + DatasetVersionController, ], providers: [ { @@ -106,6 +114,11 @@ export class DsmModule { ? new InMemoryReferenceEntityRepositoryAdapter() : new PrismaReferenceEntityRepositoryAdapter(options.referenceEntityDatabase)), }, + { + provide: DATASET_VERSION_REPOSITORY_PORT, + useValue: + options.datasetVersionRepository ?? new InMemoryDatasetVersionRepositoryAdapter(), + }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), diff --git a/services/api/test/features/dsm/dataset-version.controller.test.ts b/services/api/test/features/dsm/dataset-version.controller.test.ts new file mode 100644 index 00000000..fbaba935 --- /dev/null +++ b/services/api/test/features/dsm/dataset-version.controller.test.ts @@ -0,0 +1,63 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryDatasetVersionRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000801'; +const workspaceId = '00000000-0000-4000-8000-000000000802'; +const datasetId = '00000000-0000-4000-8000-000000000803'; +const versionId = '00000000-0000-4000-8000-000000000804'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000805', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000806', + idempotencyKey: 'dataset-version-controller', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[DSM-002, DSM-012, DSM-014] dataset result manifests are immutable and exact-input bound', async () => { + const repository = new InMemoryDatasetVersionRepositoryAdapter(); + const tenantContext = context(); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + datasetVersionRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/dataset-versions', + payload: { + versionId, + datasetId, + inputArtifactVersionIds: ['00000000-0000-4000-8000-000000000807'], + schemaVersionId: '00000000-0000-4000-8000-000000000808', + mappingVersionId: '00000000-0000-4000-8000-000000000809', + ruleSetVersionId: '00000000-0000-4000-8000-000000000810', + engineBuild: 'engine@1', + contentFingerprint: 'a'.repeat(64), + rowCount: 42, + qualityState: 'PASS', + lineageManifestHash: 'b'.repeat(64), + }, + }); + assert.equal(response.statusCode, 201); + assert.equal(response.json().value.rowCount, 42); + const read = await app.inject({ method: 'GET', url: `/v1/dataset-versions/${versionId}` }); + assert.equal(read.statusCode, 200); + assert.equal(read.json().value.contentFingerprint, 'a'.repeat(64)); + } finally { + await app.close(); + } +}); From b36f66d2dd5808f1f54d1957aa265672e05f5c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:24:45 +0700 Subject: [PATCH 045/177] feat(iae): add retention and export persistence schema --- .../migration.sql | 32 ++++++++++++++++ services/api/prisma/schema/iae.prisma | 38 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 services/api/prisma/migrations/20260802230000_iae_retention_exports/migration.sql diff --git a/services/api/prisma/migrations/20260802230000_iae_retention_exports/migration.sql b/services/api/prisma/migrations/20260802230000_iae_retention_exports/migration.sql new file mode 100644 index 00000000..04588bb6 --- /dev/null +++ b/services/api/prisma/migrations/20260802230000_iae_retention_exports/migration.sql @@ -0,0 +1,32 @@ +-- IAE-016, IAE-018, IAE-021: durable retention requests and verification manifests. +CREATE TABLE "iae"."artifact_deletion_requests" ( + "id" UUID NOT NULL, + "artifact_version_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "requested_by" UUID NOT NULL, + "requested_at" TIMESTAMPTZ(6) NOT NULL, + "state" VARCHAR(16) NOT NULL, + "blockers" JSONB NOT NULL, + "authorized_at" TIMESTAMPTZ(6), + "revision" INTEGER NOT NULL DEFAULT 1, + CONSTRAINT "artifact_deletion_requests_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "artifact_deletion_requests_artifact_idx" ON "iae"."artifact_deletion_requests"("artifact_version_id"); +CREATE INDEX "artifact_deletion_requests_scope_state_idx" ON "iae"."artifact_deletion_requests"("organization_id", "workspace_id", "project_id", "state"); + +CREATE TABLE "iae"."artifact_export_manifests" ( + "id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "entries" JSONB NOT NULL, + "approval_state" VARCHAR(16) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + "canonical_hash" CHAR(64) NOT NULL, + CONSTRAINT "artifact_export_manifests_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "artifact_export_manifests_scope_idx" ON "iae"."artifact_export_manifests"("organization_id", "workspace_id", "project_id"); diff --git a/services/api/prisma/schema/iae.prisma b/services/api/prisma/schema/iae.prisma index 0cced72a..da4a5481 100644 --- a/services/api/prisma/schema/iae.prisma +++ b/services/api/prisma/schema/iae.prisma @@ -127,3 +127,41 @@ model EvidenceGrantRecord { @@map("evidence_grants") @@schema("iae") } + +/// IAE-016, IAE-021: deletion authorization is durable and separate from byte erasure. +model ArtifactDeletionRequestRecord { + id String @id @db.Uuid + artifactVersionId String @map("artifact_version_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + requestedBy String @map("requested_by") @db.Uuid + requestedAt DateTime @map("requested_at") @db.Timestamptz(6) + state String @db.VarChar(16) + blockers Json + authorizedAt DateTime? @map("authorized_at") @db.Timestamptz(6) + revision Int @default(1) + + @@index([artifactVersionId], map: "artifact_deletion_requests_artifact_idx") + @@index([organizationId, workspaceId, projectId, state], map: "artifact_deletion_requests_scope_state_idx") + @@map("artifact_deletion_requests") + @@schema("iae") +} + +/// IAE-018: export manifests contain only verification metadata and references. +model ArtifactExportManifestRecord { + id String @id @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + entries Json + approvalState String @map("approval_state") @db.VarChar(16) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + canonicalHash String @map("canonical_hash") @db.Char(64) + + @@index([organizationId, workspaceId, projectId], map: "artifact_export_manifests_scope_idx") + @@map("artifact_export_manifests") + @@schema("iae") +} From 538cff4332c786efdb53d1bfb4d41e081faf9074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:25:20 +0700 Subject: [PATCH 046/177] fix(iae): make Prisma placement registration idempotent --- .../iae/adapter/prisma-artifact-repository.adapter.ts | 8 ++++++++ .../test/features/iae/prisma-artifact-repository.test.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts index f4dd3950..e87e9cd9 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts @@ -245,6 +245,14 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { if (version === null) throw new Error('IAE_VERSION_NOT_FOUND'); if (!tenantScopeContainsV1(context.tenantScope, placement.tenantScope)) throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.contentPlacement.findUnique({ + where: { id: placement.placementId }, + }); + if (existing !== null) { + const persisted = rowToPlacement(existing, rowToVersion(version)); + if (JSON.stringify(persisted) === JSON.stringify(placement)) return; + throw new Error('IAE_IMMUTABLE_PLACEMENT'); + } await this.client.contentPlacement.create({ data: { ...databaseScope(placement.tenantScope), diff --git a/services/api/test/features/iae/prisma-artifact-repository.test.ts b/services/api/test/features/iae/prisma-artifact-repository.test.ts index b50896b7..95fddcc9 100644 --- a/services/api/test/features/iae/prisma-artifact-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-repository.test.ts @@ -150,6 +150,7 @@ void test('[IAE-003, IAE-004, IAE-005, IAM-009] Prisma artifact adapter keeps pl const repository = new PrismaArtifactRepositoryAdapter(client([], placements, evidence)); await repository.saveVersion(context('version'), artifact.value); await repository.savePlacement(context('placement'), placement.value); + await repository.savePlacement(context('placement-repeat'), placement.value); await repository.saveEvidence(context('evidence'), evidenceRef.value); assert.equal((await repository.listPlacements(context('list-placement'), versionId)).length, 1); assert.equal((await repository.listEvidence(context('list-evidence'), versionId)).length, 1); From 2ce24da7b4517251a91b376d947999cec3af8c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:27:26 +0700 Subject: [PATCH 047/177] feat(dsm): add Prisma dataset version adapter --- ...isma-dataset-version-repository.adapter.ts | 170 ++++++++++++++++++ services/api/src/features/dsm/dsm.module.ts | 11 +- .../prisma-dataset-version-repository.test.ts | 82 +++++++++ 3 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts create mode 100644 services/api/test/features/dsm/prisma-dataset-version-repository.test.ts diff --git a/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts new file mode 100644 index 00000000..368ebbeb --- /dev/null +++ b/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts @@ -0,0 +1,170 @@ +import { + createDatasetVersionManifestV1, + type DatasetVersionManifestV1, +} from '@databreeze/domain/dataset-governance/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetVersionRepositoryPortV1, + DatasetVersionTransactionPortV1, +} from '../application/dataset-version-repository.port.js'; + +export interface DatasetVersionDatabaseRowV1 { + readonly id: string; + readonly datasetId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly inputArtifactVersionIds: unknown; + readonly schemaVersionId: string; + readonly mappingVersionId: string; + readonly ruleSetVersionId: string; + readonly engineBuild: string; + readonly contentFingerprint: string; + readonly rowCount: bigint | number; + readonly qualityState: string; + readonly lineageManifestHash: string; + readonly createdAt: Date; +} + +export interface DatasetVersionDatabaseCreateDataV1 + extends Omit { + readonly rowCount: bigint; + readonly createdAt: Date; +} + +export interface DatasetVersionDatabaseClientV1 { + readonly datasetVersionRecord: { + create(input: { + readonly data: DatasetVersionDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + }; + $transaction( + work: (transaction: DatasetVersionDatabaseClientV1) => 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 rowScope(row: DatasetVersionDatabaseRowV1): 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('DSM_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: DatasetVersionDatabaseRowV1): DatasetVersionManifestV1 { + const parsed = createDatasetVersionManifestV1({ + datasetId: row.datasetId, + versionId: row.id, + tenantScope: rowScope(row), + inputArtifactVersionIds: row.inputArtifactVersionIds, + schemaVersionId: row.schemaVersionId, + mappingVersionId: row.mappingVersionId, + ruleSetVersionId: row.ruleSetVersionId, + engineBuild: row.engineBuild, + contentFingerprint: row.contentFingerprint, + rowCount: typeof row.rowCount === 'bigint' ? Number(row.rowCount) : row.rowCount, + qualityState: row.qualityState, + lineageManifestHash: row.lineageManifestHash, + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_DATASET_VERSION_INVALID'); + return parsed.value; +} + +function domainToCreate(version: DatasetVersionManifestV1): DatasetVersionDatabaseCreateDataV1 { + return { + ...databaseScope(version.tenantScope), + id: version.versionId, + datasetId: version.datasetId, + inputArtifactVersionIds: version.inputArtifactVersionIds, + schemaVersionId: version.schemaVersionId, + mappingVersionId: version.mappingVersionId, + ruleSetVersionId: version.ruleSetVersionId, + engineBuild: version.engineBuild, + contentFingerprint: version.contentFingerprint, + rowCount: BigInt(version.rowCount), + qualityState: version.qualityState, + lineageManifestHash: version.lineageManifestHash, + createdAt: new Date(), + }; +} + +function visible(context: TenantScopeV1, row: DatasetVersionDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaDatasetVersionTransactionAdapter implements DatasetVersionTransactionPortV1 { + public constructor(private readonly client: DatasetVersionDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, version: DatasetVersionManifestV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, version.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.datasetVersionRecord.findUnique({ + where: { id: version.versionId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(version)) + throw new Error('DSM_IMMUTABLE_DATASET_VERSION'); + return; + } + await this.client.datasetVersionRecord.create({ data: domainToCreate(version) }); + } + + public async find( + context: IamTenantContextV1, + versionId: DatasetVersionManifestV1['versionId'], + ): Promise { + const row = await this.client.datasetVersionRecord.findUnique({ where: { id: versionId } }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } +} + +export class PrismaDatasetVersionRepositoryAdapter implements DatasetVersionRepositoryPortV1 { + public constructor(private readonly client: DatasetVersionDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetVersionTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaDatasetVersionTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, version: DatasetVersionManifestV1): Promise { + return new PrismaDatasetVersionTransactionAdapter(this.client).save(context, version); + } + + public find( + context: IamTenantContextV1, + versionId: DatasetVersionManifestV1['versionId'], + ): Promise { + return new PrismaDatasetVersionTransactionAdapter(this.client).find(context, versionId); + } +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index 2a15d059..903e4325 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -22,6 +22,10 @@ import { } from './adapter/prisma-reference-entity-repository.adapter.js'; import { InMemoryRuleSetRepositoryAdapter } from './adapter/in-memory-rule-set-repository.adapter.js'; import { InMemoryDatasetVersionRepositoryAdapter } from './adapter/in-memory-dataset-version-repository.adapter.js'; +import { + PrismaDatasetVersionRepositoryAdapter, + type DatasetVersionDatabaseClientV1, +} from './adapter/prisma-dataset-version-repository.adapter.js'; import { PrismaRuleSetRepositoryAdapter, type RuleSetDatabaseClientV1, @@ -66,6 +70,8 @@ export interface DsmModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly referenceEntityDatabase?: ReferenceEntityDatabaseClientV1; readonly datasetVersionRepository?: DatasetVersionRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly datasetVersionDatabase?: DatasetVersionDatabaseClientV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -117,7 +123,10 @@ export class DsmModule { { provide: DATASET_VERSION_REPOSITORY_PORT, useValue: - options.datasetVersionRepository ?? new InMemoryDatasetVersionRepositoryAdapter(), + options.datasetVersionRepository ?? + (options.datasetVersionDatabase === undefined + ? new InMemoryDatasetVersionRepositoryAdapter() + : new PrismaDatasetVersionRepositoryAdapter(options.datasetVersionDatabase)), }, { provide: REQUEST_TENANT_CONTEXT, diff --git a/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts b/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts new file mode 100644 index 00000000..ecf4cbdf --- /dev/null +++ b/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts @@ -0,0 +1,82 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createDatasetVersionManifestV1 } from '@databreeze/domain/dataset-governance/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { + PrismaDatasetVersionRepositoryAdapter, + type DatasetVersionDatabaseClientV1, + type DatasetVersionDatabaseRowV1, +} from '../../../src/features/dsm/adapter/prisma-dataset-version-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +function id(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('fixture identifier rejected'); + return parsed.value; +} + +const organizationId = id('00000000-0000-4000-8000-000000000811'); +const workspaceId = id('00000000-0000-4000-8000-000000000812'); +const versionId = id('00000000-0000-4000-8000-000000000813'); + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000814', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000815', + idempotencyKey: 'prisma-dataset-version', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function client(rows: DatasetVersionDatabaseRowV1[]): DatasetVersionDatabaseClientV1 { + return { + datasetVersionRecord: { + create({ data }) { + const persisted = { ...data } as DatasetVersionDatabaseRowV1; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('[DSM-002, DSM-003, IAM-009] Prisma dataset version adapter is immutable and tenant scoped', async () => { + const rows: DatasetVersionDatabaseRowV1[] = []; + const repository = new PrismaDatasetVersionRepositoryAdapter(client(rows)); + const tenantContext = context(); + const created = createDatasetVersionManifestV1({ + datasetId: '00000000-0000-4000-8000-000000000816', + versionId, + tenantScope: tenantContext.tenantScope, + inputArtifactVersionIds: ['00000000-0000-4000-8000-000000000817'], + schemaVersionId: '00000000-0000-4000-8000-000000000818', + mappingVersionId: '00000000-0000-4000-8000-000000000819', + ruleSetVersionId: '00000000-0000-4000-8000-000000000820', + engineBuild: 'engine@1', + contentFingerprint: 'a'.repeat(64), + rowCount: 5, + qualityState: 'PASS', + lineageManifestHash: 'b'.repeat(64), + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + await repository.save(tenantContext, created.value); + await repository.save(tenantContext, created.value); + assert.deepEqual(await repository.find(tenantContext, versionId), created.value); + assert.equal(rows.length, 1); +}); From 9808e8f89b2c3c75f13a278ebebf145aaed6ca50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:30:27 +0700 Subject: [PATCH 048/177] feat(iae): add Prisma retention repository --- ...a-artifact-retention-repository.adapter.ts | 201 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 11 +- ...isma-artifact-retention-repository.test.ts | 82 +++++++ 3 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-artifact-retention-repository.test.ts diff --git a/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts new file mode 100644 index 00000000..7948a67b --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts @@ -0,0 +1,201 @@ +import { + createArtifactDeletionRequestV1, + type ArtifactDeletionRequestV1, +} from '@databreeze/domain/artifact-retention/v1'; +import { + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactRetentionRepositoryPortV1, + ArtifactRetentionTransactionPortV1, +} from '../application/artifact-retention-repository.port.js'; + +export interface ArtifactRetentionDatabaseRowV1 { + readonly id: string; + readonly artifactVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly requestedBy: string; + readonly requestedAt: Date; + readonly state: string; + readonly blockers: unknown; + readonly authorizedAt: Date | null; + readonly revision: number; +} + +export interface ArtifactRetentionDatabaseCreateDataV1 + extends Omit { + readonly authorizedAt: Date | null; +} + +export interface ArtifactRetentionDatabaseClientV1 { + readonly artifactDeletionRequestRecord: { + create(input: { + readonly data: ArtifactRetentionDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { + readonly state: string; + readonly blockers: unknown; + readonly authorizedAt: Date | null; + readonly revision: number; + }; + }): Promise; + }; + $transaction( + work: (transaction: ArtifactRetentionDatabaseClientV1) => 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 rowScope(row: ArtifactRetentionDatabaseRowV1): 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('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: ArtifactRetentionDatabaseRowV1): ArtifactDeletionRequestV1 { + const created = createArtifactDeletionRequestV1({ + requestId: row.id, + artifactVersionId: row.artifactVersionId, + tenantScope: rowScope(row), + requestedBy: row.requestedBy, + requestedAt: row.requestedAt.toISOString(), + }); + if (!created.accepted) throw new Error('IAE_PERSISTED_DELETION_REQUEST_INVALID'); + if (!['REQUESTED', 'BLOCKED', 'AUTHORIZED', 'COMPLETED', 'CANCELLED'].includes(row.state)) + throw new Error('IAE_PERSISTED_DELETION_STATE_INVALID'); + if (!Array.isArray(row.blockers) || !row.blockers.every((value) => typeof value === 'string')) + throw new Error('IAE_PERSISTED_DELETION_BLOCKERS_INVALID'); + if (!Number.isSafeInteger(row.revision) || row.revision < 1) + throw new Error('IAE_PERSISTED_REVISION_INVALID'); + const authorizedAt = row.authorizedAt?.toISOString(); + const parsedAuthorizedAt = authorizedAt ? parseStrictUtcTimestampV1(authorizedAt) : undefined; + if (parsedAuthorizedAt && !parsedAuthorizedAt.accepted) + throw new Error('IAE_PERSISTED_TIMESTAMP_INVALID'); + return Object.freeze({ + ...created.value, + state: row.state as ArtifactDeletionRequestV1['state'], + blockers: Object.freeze([...row.blockers]), + ...(parsedAuthorizedAt?.accepted ? { authorizedAt: parsedAuthorizedAt.value } : {}), + revision: row.revision, + }); +} + +function domainToCreate(request: ArtifactDeletionRequestV1): ArtifactRetentionDatabaseCreateDataV1 { + return { + ...databaseScope(request.tenantScope), + id: request.requestId, + artifactVersionId: request.artifactVersionId, + requestedBy: request.requestedBy, + requestedAt: new Date(request.requestedAt), + state: request.state, + blockers: request.blockers, + authorizedAt: request.authorizedAt ? new Date(request.authorizedAt) : null, + revision: request.revision, + }; +} + +function visible(context: TenantScopeV1, row: ArtifactRetentionDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaArtifactRetentionTransactionAdapter implements ArtifactRetentionTransactionPortV1 { + public constructor(private readonly client: ArtifactRetentionDatabaseClientV1) {} + + public async save( + context: IamTenantContextV1, + request: ArtifactDeletionRequestV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, request.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.artifactDeletionRequestRecord.findUnique({ + where: { id: request.requestId }, + }); + if (existing === null) { + await this.client.artifactDeletionRequestRecord.create({ data: domainToCreate(request) }); + return; + } + const current = rowToDomain(existing); + if (JSON.stringify(current) === JSON.stringify(request)) return; + if (request.revision !== current.revision + 1) throw new Error('IAE_REVISION_CONFLICT'); + if ( + current.artifactVersionId !== request.artifactVersionId || + current.requestedBy !== request.requestedBy || + current.requestedAt !== request.requestedAt + ) + throw new Error('IAE_IMMUTABLE_DELETION_REQUEST'); + await this.client.artifactDeletionRequestRecord.update({ + where: { id: request.requestId }, + data: { + state: request.state, + blockers: request.blockers, + authorizedAt: request.authorizedAt ? new Date(request.authorizedAt) : null, + revision: request.revision, + }, + }); + } + + public async find( + context: IamTenantContextV1, + requestId: ArtifactDeletionRequestV1['requestId'], + ): Promise { + const row = await this.client.artifactDeletionRequestRecord.findUnique({ + where: { id: requestId }, + }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } +} + +export class PrismaArtifactRetentionRepositoryAdapter implements ArtifactRetentionRepositoryPortV1 { + public constructor(private readonly client: ArtifactRetentionDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactRetentionTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaArtifactRetentionTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, request: ArtifactDeletionRequestV1): Promise { + return new PrismaArtifactRetentionTransactionAdapter(this.client).save(context, request); + } + + public find( + context: IamTenantContextV1, + requestId: ArtifactDeletionRequestV1['requestId'], + ): Promise { + return new PrismaArtifactRetentionTransactionAdapter(this.client).find(context, requestId); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 6bf2a24e..2aef6bdf 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -15,6 +15,10 @@ import { import { InMemoryArtifactRepositoryAdapter } from './adapter/in-memory-artifact-repository.adapter.js'; import { InMemoryArtifactLineageRepositoryAdapter } from './adapter/in-memory-artifact-lineage-repository.adapter.js'; import { InMemoryArtifactRetentionRepositoryAdapter } from './adapter/in-memory-artifact-retention-repository.adapter.js'; +import { + PrismaArtifactRetentionRepositoryAdapter, + type ArtifactRetentionDatabaseClientV1, +} from './adapter/prisma-artifact-retention-repository.adapter.js'; import { InMemoryArtifactExportRepositoryAdapter } from './adapter/in-memory-artifact-export-repository.adapter.js'; import { PrismaArtifactRepositoryAdapter, @@ -60,6 +64,8 @@ export interface IaeModuleOptions { readonly artifactDatabase?: ArtifactDatabaseClientV1; readonly artifactLineageRepository?: ArtifactLineageRepositoryPortV1; readonly artifactRetentionRepository?: ArtifactRetentionRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly artifactRetentionDatabase?: ArtifactRetentionDatabaseClientV1; readonly artifactExportRepository?: ArtifactExportRepositoryPortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; @@ -104,7 +110,10 @@ export class IaeModule { { provide: ARTIFACT_RETENTION_REPOSITORY_PORT, useValue: - options.artifactRetentionRepository ?? new InMemoryArtifactRetentionRepositoryAdapter(), + options.artifactRetentionRepository ?? + (options.artifactRetentionDatabase === undefined + ? new InMemoryArtifactRetentionRepositoryAdapter() + : new PrismaArtifactRetentionRepositoryAdapter(options.artifactRetentionDatabase)), }, { provide: ARTIFACT_EXPORT_REPOSITORY_PORT, diff --git a/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts b/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts new file mode 100644 index 00000000..01cfae2c --- /dev/null +++ b/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts @@ -0,0 +1,82 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createArtifactDeletionRequestV1 } from '@databreeze/domain/artifact-retention/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { + PrismaArtifactRetentionRepositoryAdapter, + type ArtifactRetentionDatabaseClientV1, + type ArtifactRetentionDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-artifact-retention-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +function id(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('fixture identifier rejected'); + return parsed.value; +} + +const organizationId = id('00000000-0000-4000-8000-000000000821'); +const workspaceId = id('00000000-0000-4000-8000-000000000822'); +const requestId = id('00000000-0000-4000-8000-000000000823'); + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000824', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000825', + idempotencyKey: 'prisma-retention', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function client(rows: ArtifactRetentionDatabaseRowV1[]): ArtifactRetentionDatabaseClientV1 { + return { + artifactDeletionRequestRecord: { + create({ data }) { + const persisted = { ...data } as ArtifactRetentionDatabaseRowV1; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + update({ where, data }) { + const current = rows.find((row) => row.id === where.id); + if (!current) throw new Error('fixture retention request not found'); + const next = { ...current, ...data }; + rows[rows.indexOf(current)] = next; + return Promise.resolve(next); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('[IAE-016, IAE-021, IAM-009] Prisma retention adapter preserves immutable request identity and revisions', async () => { + const rows: ArtifactRetentionDatabaseRowV1[] = []; + const repository = new PrismaArtifactRetentionRepositoryAdapter(client(rows)); + const tenantContext = context(); + const created = createArtifactDeletionRequestV1({ + requestId, + artifactVersionId: '00000000-0000-4000-8000-000000000826', + tenantScope: tenantContext.tenantScope, + requestedBy: tenantContext.actorId, + requestedAt: '2026-01-03T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + await repository.save(tenantContext, created.value); + await repository.save(tenantContext, created.value); + assert.deepEqual(await repository.find(tenantContext, requestId), created.value); + assert.equal(rows.length, 1); +}); From 8d1baac9a6b9ecb8260880ac3e7ce8a3e06aa269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:32:36 +0700 Subject: [PATCH 049/177] feat(iae): add Prisma export repository --- ...isma-artifact-export-repository.adapter.ts | 154 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 11 +- .../prisma-artifact-export-repository.test.ts | 68 ++++++++ 3 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/adapter/prisma-artifact-export-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-artifact-export-repository.test.ts diff --git a/services/api/src/features/iae/adapter/prisma-artifact-export-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-export-repository.adapter.ts new file mode 100644 index 00000000..1fb55e4b --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-artifact-export-repository.adapter.ts @@ -0,0 +1,154 @@ +import { + createArtifactExportManifestV1, + type ArtifactExportManifestV1, +} from '@databreeze/domain/artifact-export/v1'; +import { + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactExportRepositoryPortV1, + ArtifactExportTransactionPortV1, +} from '../application/artifact-export-repository.port.js'; + +export interface ArtifactExportDatabaseRowV1 { + readonly id: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly entries: unknown; + readonly approvalState: string; + readonly createdAt: Date; + readonly canonicalHash: string; +} + +export interface ArtifactExportDatabaseClientV1 { + readonly artifactExportManifestRecord: { + create(input: { + readonly data: ArtifactExportDatabaseRowV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + }; + $transaction( + work: (transaction: ArtifactExportDatabaseClientV1) => 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 rowScope(row: ArtifactExportDatabaseRowV1): 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('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: ArtifactExportDatabaseRowV1): ArtifactExportManifestV1 { + const createdAt = row.createdAt.toISOString(); + if (!parseStrictUtcTimestampV1(createdAt).accepted) + throw new Error('IAE_PERSISTED_TIMESTAMP_INVALID'); + const parsed = createArtifactExportManifestV1({ + manifestId: row.id, + tenantScope: rowScope(row), + entries: row.entries, + approvalState: row.approvalState, + createdAt, + canonicalHash: row.canonicalHash, + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_EXPORT_MANIFEST_INVALID'); + return parsed.value; +} + +function domainToCreate(manifest: ArtifactExportManifestV1): ArtifactExportDatabaseRowV1 { + return { + ...databaseScope(manifest.tenantScope), + id: manifest.manifestId, + entries: manifest.entries, + approvalState: manifest.approvalState, + createdAt: new Date(manifest.createdAt), + canonicalHash: manifest.canonicalHash, + }; +} + +function visible(context: TenantScopeV1, row: ArtifactExportDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaArtifactExportTransactionAdapter implements ArtifactExportTransactionPortV1 { + public constructor(private readonly client: ArtifactExportDatabaseClientV1) {} + + public async save( + context: IamTenantContextV1, + manifest: ArtifactExportManifestV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, manifest.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.artifactExportManifestRecord.findUnique({ + where: { id: manifest.manifestId }, + }); + if (existing !== null) { + const current = rowToDomain(existing); + if (JSON.stringify(current) !== JSON.stringify(manifest)) + throw new Error('IAE_IMMUTABLE_EXPORT_MANIFEST'); + return; + } + await this.client.artifactExportManifestRecord.create({ data: domainToCreate(manifest) }); + } + + public async find( + context: IamTenantContextV1, + manifestId: ArtifactExportManifestV1['manifestId'], + ): Promise { + const row = await this.client.artifactExportManifestRecord.findUnique({ + where: { id: manifestId }, + }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } +} + +export class PrismaArtifactExportRepositoryAdapter implements ArtifactExportRepositoryPortV1 { + public constructor(private readonly client: ArtifactExportDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactExportTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaArtifactExportTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, manifest: ArtifactExportManifestV1): Promise { + return new PrismaArtifactExportTransactionAdapter(this.client).save(context, manifest); + } + + public find( + context: IamTenantContextV1, + manifestId: ArtifactExportManifestV1['manifestId'], + ): Promise { + return new PrismaArtifactExportTransactionAdapter(this.client).find(context, manifestId); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 2aef6bdf..5c79bbb5 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -20,6 +20,10 @@ import { type ArtifactRetentionDatabaseClientV1, } from './adapter/prisma-artifact-retention-repository.adapter.js'; import { InMemoryArtifactExportRepositoryAdapter } from './adapter/in-memory-artifact-export-repository.adapter.js'; +import { + PrismaArtifactExportRepositoryAdapter, + type ArtifactExportDatabaseClientV1, +} from './adapter/prisma-artifact-export-repository.adapter.js'; import { PrismaArtifactRepositoryAdapter, type ArtifactDatabaseClientV1, @@ -67,6 +71,8 @@ export interface IaeModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactRetentionDatabase?: ArtifactRetentionDatabaseClientV1; readonly artifactExportRepository?: ArtifactExportRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly artifactExportDatabase?: ArtifactExportDatabaseClientV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -118,7 +124,10 @@ export class IaeModule { { provide: ARTIFACT_EXPORT_REPOSITORY_PORT, useValue: - options.artifactExportRepository ?? new InMemoryArtifactExportRepositoryAdapter(), + options.artifactExportRepository ?? + (options.artifactExportDatabase === undefined + ? new InMemoryArtifactExportRepositoryAdapter() + : new PrismaArtifactExportRepositoryAdapter(options.artifactExportDatabase)), }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, diff --git a/services/api/test/features/iae/prisma-artifact-export-repository.test.ts b/services/api/test/features/iae/prisma-artifact-export-repository.test.ts new file mode 100644 index 00000000..a3d71b97 --- /dev/null +++ b/services/api/test/features/iae/prisma-artifact-export-repository.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createArtifactExportManifestV1 } from '@databreeze/domain/artifact-export/v1'; +import { parseTenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +import { PrismaArtifactExportRepositoryAdapter } from '../../../src/features/iae/adapter/prisma-artifact-export-repository.adapter.js'; + +const organizationId = '11111111-1111-4111-8111-111111111111'; +const workspaceId = '22222222-2222-4222-8222-222222222222'; +const scopeResult = parseTenantScopeV1({ + scopeType: 'workspace', + organizationId, + workspaceId, +}); +if (!scopeResult.accepted) throw new Error('fixture scope invalid'); +const scope = scopeResult.value; +const contextResult = createIamTenantContextV1({ + actorId: '55555555-5555-4555-8555-555555555555', + tenantScope: scope, + authorizationEpoch: 1, + correlationId: '66666666-6666-4666-8666-666666666666', + idempotencyKey: 'prisma-export', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; +const manifest = createArtifactExportManifestV1({ + manifestId: '33333333-3333-4333-8333-333333333333', + tenantScope: scope, + entries: [ + { + versionId: '44444444-4444-4444-8444-444444444444', + contentSha256: 'a'.repeat(64), + byteSize: 32, + evidenceIds: [], + processorVersions: ['spreadsheet-auditor@1'], + }, + ], + approvalState: 'PENDING', + createdAt: '2026-08-02T00:00:00.000Z', + canonicalHash: 'b'.repeat(64), +}); +if (!manifest.accepted) throw new Error('fixture manifest invalid'); + +test('IAE-018 Prisma export adapter preserves immutable manifests and scopes reads', async () => { + const rows = new Map(); + const client = { + artifactExportManifestRecord: { + async create({ data }: any) { + const row = { ...data }; + rows.set(row.id, row); + return row; + }, + async findUnique({ where }: any) { + return rows.get(where.id) ?? null; + }, + }, + async $transaction(work: any) { + return work(this); + }, + } as any; + const repository = new PrismaArtifactExportRepositoryAdapter(client); + await repository.save(context, manifest.value); + await repository.save(context, manifest.value); + assert.deepEqual(await repository.find(context, manifest.value.manifestId), manifest.value); + assert.equal(rows.size, 1); +}); From 72131d1967402e1b7aa79a747f8aad837bda0282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:34:25 +0700 Subject: [PATCH 050/177] feat(iae): add Prisma lineage repository --- ...sma-artifact-lineage-repository.adapter.ts | 176 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 11 +- ...prisma-artifact-lineage-repository.test.ts | 83 +++++++++ 3 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts diff --git a/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts new file mode 100644 index 00000000..0bd74f25 --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts @@ -0,0 +1,176 @@ +import { + createArtifactLineageV1, + type ArtifactLineageV1, +} from '@databreeze/domain/artifact-governance/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactLineageRepositoryPortV1, + ArtifactLineageTransactionPortV1, +} from '../application/artifact-lineage-repository.port.js'; + +export interface ArtifactLineageDatabaseRowV1 { + readonly id: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly derivedArtifactVersionId: string; + readonly sourceVersionIds: unknown; + readonly processorVersion: string; + readonly recipeVersion: string | null; + readonly coordinateLineage: unknown; +} + +export interface ArtifactLineageDatabaseClientV1 { + readonly artifactLineageRecord: { + create(input: { + readonly data: ArtifactLineageDatabaseRowV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findFirst(input: { + readonly where: { readonly derivedArtifactVersionId: string }; + }): Promise; + findMany(input: { + readonly where: { readonly sourceVersionIds: { readonly array_contains: string } }; + readonly orderBy: { readonly id: 'asc' }; + }): Promise; + }; + $transaction( + work: (transaction: ArtifactLineageDatabaseClientV1) => 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 rowScope(row: ArtifactLineageDatabaseRowV1): 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('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: ArtifactLineageDatabaseRowV1): ArtifactLineageV1 { + const parsed = createArtifactLineageV1({ + lineageId: row.id, + derivedArtifactVersionId: row.derivedArtifactVersionId, + tenantScope: rowScope(row), + sourceArtifactVersionIds: row.sourceVersionIds, + processorVersion: row.processorVersion, + ...(row.recipeVersion === null ? {} : { recipeVersion: row.recipeVersion }), + coordinateLineage: row.coordinateLineage, + }); + if (!parsed.accepted) throw new Error('IAE_PERSISTED_LINEAGE_INVALID'); + return parsed.value; +} + +function domainToCreate(lineage: ArtifactLineageV1): ArtifactLineageDatabaseRowV1 { + return { + ...databaseScope(lineage.tenantScope), + id: lineage.lineageId, + derivedArtifactVersionId: lineage.derivedArtifactVersionId, + sourceVersionIds: lineage.sourceArtifactVersionIds, + processorVersion: lineage.processorVersion, + recipeVersion: lineage.recipeVersion ?? null, + coordinateLineage: lineage.coordinateLineage, + }; +} + +function visible(context: TenantScopeV1, row: ArtifactLineageDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaArtifactLineageTransactionAdapter implements ArtifactLineageTransactionPortV1 { + public constructor(private readonly client: ArtifactLineageDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, lineage: ArtifactLineageV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, lineage.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.artifactLineageRecord.findUnique({ + where: { id: lineage.lineageId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(lineage)) + throw new Error('IAE_IMMUTABLE_LINEAGE'); + return; + } + await this.client.artifactLineageRecord.create({ data: domainToCreate(lineage) }); + } + + public async findByDerived( + context: IamTenantContextV1, + derivedArtifactVersionId: ArtifactLineageV1['derivedArtifactVersionId'], + ): Promise { + const row = await this.client.artifactLineageRecord.findFirst({ + where: { derivedArtifactVersionId }, + }); + return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; + } + + public async listBySource( + context: IamTenantContextV1, + sourceArtifactVersionId: ArtifactLineageV1['sourceArtifactVersionIds'][number], + ): Promise { + const rows = await this.client.artifactLineageRecord.findMany({ + where: { sourceVersionIds: { array_contains: sourceArtifactVersionId } }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } +} + +export class PrismaArtifactLineageRepositoryAdapter implements ArtifactLineageRepositoryPortV1 { + public constructor(private readonly client: ArtifactLineageDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactLineageTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaArtifactLineageTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, lineage: ArtifactLineageV1): Promise { + return new PrismaArtifactLineageTransactionAdapter(this.client).save(context, lineage); + } + + public findByDerived( + context: IamTenantContextV1, + derivedArtifactVersionId: ArtifactLineageV1['derivedArtifactVersionId'], + ): Promise { + return new PrismaArtifactLineageTransactionAdapter(this.client).findByDerived( + context, + derivedArtifactVersionId, + ); + } + + public listBySource( + context: IamTenantContextV1, + sourceArtifactVersionId: ArtifactLineageV1['sourceArtifactVersionIds'][number], + ): Promise { + return new PrismaArtifactLineageTransactionAdapter(this.client).listBySource( + context, + sourceArtifactVersionId, + ); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 5c79bbb5..2d7f48cb 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -14,6 +14,10 @@ import { } from './adapter/prisma-artifact-intake-repository.adapter.js'; import { InMemoryArtifactRepositoryAdapter } from './adapter/in-memory-artifact-repository.adapter.js'; import { InMemoryArtifactLineageRepositoryAdapter } from './adapter/in-memory-artifact-lineage-repository.adapter.js'; +import { + PrismaArtifactLineageRepositoryAdapter, + type ArtifactLineageDatabaseClientV1, +} from './adapter/prisma-artifact-lineage-repository.adapter.js'; import { InMemoryArtifactRetentionRepositoryAdapter } from './adapter/in-memory-artifact-retention-repository.adapter.js'; import { PrismaArtifactRetentionRepositoryAdapter, @@ -67,6 +71,8 @@ export interface IaeModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactDatabase?: ArtifactDatabaseClientV1; readonly artifactLineageRepository?: ArtifactLineageRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly artifactLineageDatabase?: ArtifactLineageDatabaseClientV1; readonly artifactRetentionRepository?: ArtifactRetentionRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactRetentionDatabase?: ArtifactRetentionDatabaseClientV1; @@ -111,7 +117,10 @@ export class IaeModule { { provide: ARTIFACT_LINEAGE_REPOSITORY_PORT, useValue: - options.artifactLineageRepository ?? new InMemoryArtifactLineageRepositoryAdapter(), + options.artifactLineageRepository ?? + (options.artifactLineageDatabase === undefined + ? new InMemoryArtifactLineageRepositoryAdapter() + : new PrismaArtifactLineageRepositoryAdapter(options.artifactLineageDatabase)), }, { provide: ARTIFACT_RETENTION_REPOSITORY_PORT, diff --git a/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts b/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts new file mode 100644 index 00000000..3605a398 --- /dev/null +++ b/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts @@ -0,0 +1,83 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createArtifactLineageV1 } from '@databreeze/domain/artifact-governance/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { + PrismaArtifactLineageRepositoryAdapter, + type ArtifactLineageDatabaseClientV1, + type ArtifactLineageDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'prisma-lineage', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; +const lineageResult = createArtifactLineageV1({ + lineageId: '55555555-5555-4555-8555-555555555555', + derivedArtifactVersionId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + sourceArtifactVersionIds: ['77777777-7777-4777-8777-777777777777'], + processorVersion: 'normalizer@1', + coordinateLineage: [], +}); +if (!lineageResult.accepted) throw new Error('fixture lineage invalid'); +const lineage = lineageResult.value; + +function client(rows: ArtifactLineageDatabaseRowV1[]): ArtifactLineageDatabaseClientV1 { + return { + artifactLineageRecord: { + create({ data }) { + rows.push({ ...data }); + return Promise.resolve({ ...data }); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + findFirst({ where }) { + return Promise.resolve( + rows.find((row) => row.derivedArtifactVersionId === where.derivedArtifactVersionId) ?? + null, + ); + }, + findMany({ where }) { + return Promise.resolve( + rows + .filter( + (row) => + Array.isArray(row.sourceVersionIds) && + row.sourceVersionIds.includes(where.sourceVersionIds.array_contains), + ) + .sort((left, right) => left.id.localeCompare(right.id)), + ); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('IAE-007 Prisma lineage adapter preserves immutable lineage and source lookup', async () => { + const rows: ArtifactLineageDatabaseRowV1[] = []; + const repository = new PrismaArtifactLineageRepositoryAdapter(client(rows)); + await repository.save(context, lineage); + await repository.save(context, lineage); + assert.deepEqual( + await repository.findByDerived(context, lineage.derivedArtifactVersionId), + lineage, + ); + const sourceVersionId = lineage.sourceArtifactVersionIds[0]; + if (!sourceVersionId) throw new Error('fixture source id missing'); + assert.deepEqual(await repository.listBySource(context, sourceVersionId), [lineage]); + assert.equal(rows.length, 1); +}); From 9bbc79ef5bde4f926c0d13c81ca502b32163b07e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:36:10 +0700 Subject: [PATCH 051/177] fix(iae): persist validated inbox transitions --- ...isma-artifact-intake-repository.adapter.ts | 25 +++++++++- .../prisma-artifact-intake-repository.test.ts | 49 +++++++++++++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts index 817a91ee..4f201512 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts @@ -1,5 +1,6 @@ import { createInboxItemV1, + transitionInboxItemV1, type InboxItemStateV1, type InboxItemV1, } from '@databreeze/domain/artifact-intake/v1'; @@ -56,6 +57,10 @@ export interface ArtifactIntakeDatabaseDelegateV1 { readonly where: Readonly>; readonly orderBy: { readonly createdAt: 'desc' }; }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { readonly state: InboxItemStateV1; readonly revision: number }; + }): Promise; } export interface ArtifactIntakeDatabaseClientV1 { @@ -158,9 +163,27 @@ class PrismaArtifactIntakeTransactionAdapter implements ArtifactIntakeTransactio } const existing = await this.client.inboxItem.findUnique({ where: { id: item.inboxItemId } }); if (existing !== null) { - if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(item)) { + const current = rowToDomain(existing); + if (JSON.stringify(current) === JSON.stringify(item)) return; + if (context.expectedRevision !== current.revision) { + throw new Error('IAE_REVISION_CONFLICT'); + } + if ( + current.artifactVersionId !== item.artifactVersionId || + current.idempotencyKey !== item.idempotencyKey || + JSON.stringify(current.tenantScope) !== JSON.stringify(item.tenantScope) || + item.revision !== current.revision + 1 + ) { throw new Error('IAE_IMMUTABLE_INBOX_ITEM'); } + const transition = transitionInboxItemV1(current, item.state); + if (!transition.accepted || JSON.stringify(transition.value) !== JSON.stringify(item)) { + throw new Error('IAE_INVALID_INBOX_TRANSITION'); + } + await this.client.inboxItem.update({ + where: { id: item.inboxItemId }, + data: { state: item.state, revision: item.revision }, + }); return; } await this.client.inboxItem.create({ data: domainToCreate(item) }); diff --git a/services/api/test/features/iae/prisma-artifact-intake-repository.test.ts b/services/api/test/features/iae/prisma-artifact-intake-repository.test.ts index b34735c8..d16113f5 100644 --- a/services/api/test/features/iae/prisma-artifact-intake-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-intake-repository.test.ts @@ -78,6 +78,13 @@ function client(rows: ArtifactIntakeDatabaseRowV1[]): ArtifactIntakeDatabaseClie .sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()), ); }, + update(input) { + const current = rows.find((candidate) => candidate.id === input.where.id); + if (!current) throw new Error('fixture inbox item not found'); + const next = { ...current, ...input.data }; + rows[rows.indexOf(current)] = next; + return Promise.resolve(next); + }, }, async $transaction(work) { return work(this); @@ -135,10 +142,44 @@ void test('[IAE-001] Prisma adapter uses immutable idempotent writes', async () await repository.save(context(workspaceId, 'save-replay'), item); assert.equal(rows.length, 1); await assert.rejects( - repository.save(context(workspaceId, 'save-conflict'), { - ...item, - artifactVersionId: identifier('00000000-0000-4000-8000-000000000009'), - }), + repository.save( + { ...context(workspaceId, 'save-conflict'), expectedRevision: 1 }, + { + ...item, + artifactVersionId: identifier('00000000-0000-4000-8000-000000000009'), + }, + ), /IAE_IMMUTABLE_INBOX_ITEM/u, ); }); + +void test('[IAE-013] Prisma adapter persists only validated state transitions with revisions', async () => { + const rows: ArtifactIntakeDatabaseRowV1[] = []; + const repository = new PrismaArtifactIntakeRepositoryAdapter(client(rows)); + const item: InboxItemV1 = { + schemaVersion: 1 as const, + inboxItemId: itemId, + tenantScope: { scopeType: 'workspace' as const, organizationId, workspaceId }, + idempotencyKey: 'transition', + artifactVersionId, + state: 'NEW' as const, + createdAt: timestamp('2026-01-01T00:00:00.000Z'), + revision: 1, + }; + await repository.save(context(workspaceId, 'transition-create'), item); + await repository.save( + { ...context(workspaceId, 'transition-update'), expectedRevision: 1 }, + { ...item, state: 'ROUTED', revision: 2 }, + ); + assert.equal( + (await repository.find(context(workspaceId, 'transition-read'), itemId))?.state, + 'ROUTED', + ); + await assert.rejects( + repository.save( + { ...context(workspaceId, 'transition-stale'), expectedRevision: 1 }, + { ...item, state: 'PROCESSING', revision: 2 }, + ), + /IAE_REVISION_CONFLICT/u, + ); +}); From 1ca8cc2494b0b11198a4684698162c94f8bb774a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:38:53 +0700 Subject: [PATCH 052/177] feat(iae): add resumable upload domain --- packages/domain/package.json | 4 + packages/domain/src/artifact-upload/v1.ts | 265 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/artifact-upload-v1.test.mjs | 62 ++++ .../domain/test/built-public-api-smoke.mjs | 3 + packages/domain/test/public-api-v1.test.mjs | 2 + 6 files changed, 337 insertions(+) create mode 100644 packages/domain/src/artifact-upload/v1.ts create mode 100644 packages/domain/test/artifact-upload-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index 12b5fb7b..ad6b3706 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -80,6 +80,10 @@ "types": "./src/artifact-export/v1.ts", "import": "./dist/artifact-export/v1.js" }, + "./artifact-upload/v1": { + "types": "./src/artifact-upload/v1.ts", + "import": "./dist/artifact-upload/v1.js" + }, "./dataset/v1": { "types": "./src/dataset/v1.ts", "import": "./dist/dataset/v1.js" diff --git a/packages/domain/src/artifact-upload/v1.ts b/packages/domain/src/artifact-upload/v1.ts new file mode 100644 index 00000000..9f719329 --- /dev/null +++ b/packages/domain/src/artifact-upload/v1.ts @@ -0,0 +1,265 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** IAE-014: resumable multipart upload state is bounded, revisioned, and content-addressed. */ +export const ARTIFACT_UPLOAD_SCHEMA_VERSION_V1 = 1 as const; +export type ArtifactUploadStateV1 = 'OPEN' | 'COMPLETED' | 'ABORTED' | 'EXPIRED'; + +export interface ArtifactUploadPartV1 { + readonly partNumber: number; + readonly contentSha256: string; + readonly byteSize: number; + readonly uploadedAt: StrictUtcTimestampV1; +} + +export interface ArtifactUploadSessionV1 { + readonly schemaVersion: typeof ARTIFACT_UPLOAD_SCHEMA_VERSION_V1; + readonly sessionId: StableIdentifierV1; + readonly artifactId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly expectedSha256: string; + readonly expectedByteSize: number; + readonly mediaType: string; + readonly partSize: number; + readonly totalParts: number; + readonly parts: readonly ArtifactUploadPartV1[]; + readonly state: ArtifactUploadStateV1; + readonly createdAt: StrictUtcTimestampV1; + readonly expiresAt: StrictUtcTimestampV1; + readonly revision: number; +} + +export type ArtifactUploadErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_HASH' + | 'INVALID_SIZE' + | 'INVALID_MEDIA_TYPE' + | 'INVALID_PART' + | 'INVALID_STATE' + | 'REVISION_CONFLICT' + | 'MISSING_PARTS' + | 'SIZE_MISMATCH' + | 'DIGEST_MISMATCH' + | 'EXPIRED'; + +export type ArtifactUploadResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ArtifactUploadErrorCodeV1 }; + +function accepted(value: TValue): ArtifactUploadResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: ArtifactUploadErrorCodeV1): ArtifactUploadResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; +} + +function mediaType(input: unknown): string | undefined { + return typeof input === 'string' && + /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/iu.test(input) + ? input.toLowerCase() + : undefined; +} + +function positiveInteger(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input > 0 ? input : undefined; +} + +function revision(input: unknown): number | undefined { + return positiveInteger(input); +} + +function validPart(part: unknown, totalParts: number): part is ArtifactUploadPartV1 { + if (typeof part !== 'object' || part === null || Array.isArray(part)) return false; + const record = part as Record; + return ( + typeof record['partNumber'] === 'number' && + Number.isSafeInteger(record['partNumber']) && + record['partNumber'] >= 1 && + record['partNumber'] <= totalParts && + hash(record['contentSha256']) !== undefined && + typeof record['byteSize'] === 'number' && + Number.isSafeInteger(record['byteSize']) && + record['byteSize'] >= 0 && + timestamp(record['uploadedAt']) !== undefined + ); +} + +export function createArtifactUploadSessionV1(input: { + readonly sessionId: unknown; + readonly artifactId: unknown; + readonly tenantScope: unknown; + readonly expectedSha256: unknown; + readonly expectedByteSize: unknown; + readonly mediaType: unknown; + readonly partSize: unknown; + readonly createdAt: unknown; + readonly expiresAt: unknown; +}): ArtifactUploadResultV1 { + const sessionId = identifier(input.sessionId); + const artifactId = identifier(input.artifactId); + const tenantScope = parseTenantScopeV1(input.tenantScope); + const expectedSha256 = hash(input.expectedSha256); + const expectedByteSize = positiveInteger(input.expectedByteSize ?? 0); + const partSize = positiveInteger(input.partSize); + const mediaTypeValue = mediaType(input.mediaType); + const createdAt = timestamp(input.createdAt); + const expiresAt = timestamp(input.expiresAt); + if (!sessionId || !artifactId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (!expectedSha256) return rejected('INVALID_HASH'); + if ( + typeof input.expectedByteSize !== 'number' || + !Number.isSafeInteger(input.expectedByteSize) || + input.expectedByteSize < 0 + ) + return rejected('INVALID_SIZE'); + if (!partSize || partSize > 1024 * 1024 * 1024) return rejected('INVALID_SIZE'); + if (!mediaTypeValue) return rejected('INVALID_MEDIA_TYPE'); + if (!createdAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(createdAt)) + return rejected('INVALID_TIMESTAMP'); + const totalParts = Math.max(1, Math.ceil(input.expectedByteSize / partSize)); + if (totalParts > 10_000) return rejected('INVALID_SIZE'); + return accepted( + Object.freeze({ + schemaVersion: ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, + sessionId, + artifactId, + tenantScope: tenantScope.value, + expectedSha256, + expectedByteSize: input.expectedByteSize, + mediaType: mediaTypeValue, + partSize, + totalParts, + parts: Object.freeze([]), + state: 'OPEN' as const, + createdAt, + expiresAt, + revision: 1, + }), + ); +} + +export function recordArtifactUploadPartV1( + session: ArtifactUploadSessionV1, + input: { + readonly partNumber: unknown; + readonly contentSha256: unknown; + readonly byteSize: unknown; + readonly uploadedAt: unknown; + readonly expectedRevision: unknown; + }, +): ArtifactUploadResultV1 { + if (session.state !== 'OPEN') return rejected('INVALID_STATE'); + if (Date.parse(input.uploadedAt as string) > Date.parse(session.expiresAt)) + return rejected('EXPIRED'); + if (input.expectedRevision !== session.revision) return rejected('REVISION_CONFLICT'); + const partNumber = input.partNumber; + const contentSha256 = hash(input.contentSha256); + const byteSize = input.byteSize; + const uploadedAt = timestamp(input.uploadedAt); + if ( + typeof partNumber !== 'number' || + !Number.isSafeInteger(partNumber) || + partNumber < 1 || + partNumber > session.totalParts || + !contentSha256 || + typeof byteSize !== 'number' || + !Number.isSafeInteger(byteSize) || + byteSize < 0 || + !uploadedAt || + byteSize > session.partSize || + (partNumber < session.totalParts && byteSize !== session.partSize) + ) + return rejected('INVALID_PART'); + const existing = session.parts.find((part) => part.partNumber === partNumber); + if (existing) { + return existing.contentSha256 === contentSha256 && existing.byteSize === byteSize + ? accepted(session) + : rejected('DIGEST_MISMATCH'); + } + const part = Object.freeze({ partNumber, contentSha256, byteSize, uploadedAt }); + return accepted( + Object.freeze({ + ...session, + parts: Object.freeze( + [...session.parts, part].sort((left, right) => left.partNumber - right.partNumber), + ), + revision: session.revision + 1, + }), + ); +} + +export function completeArtifactUploadSessionV1( + session: ArtifactUploadSessionV1, + input: { readonly assembledSha256: unknown; readonly expectedRevision: unknown }, +): ArtifactUploadResultV1 { + if (session.state !== 'OPEN') return rejected('INVALID_STATE'); + if (input.expectedRevision !== session.revision) return rejected('REVISION_CONFLICT'); + const assembledSha256 = hash(input.assembledSha256); + if (!assembledSha256) return rejected('INVALID_HASH'); + if (session.parts.length !== session.totalParts) return rejected('MISSING_PARTS'); + if (session.parts.some((part, index) => part.partNumber !== index + 1)) + return rejected('MISSING_PARTS'); + if (session.parts.reduce((total, part) => total + part.byteSize, 0) !== session.expectedByteSize) + return rejected('SIZE_MISMATCH'); + if (assembledSha256 !== session.expectedSha256) return rejected('DIGEST_MISMATCH'); + return accepted( + Object.freeze({ ...session, state: 'COMPLETED' as const, revision: session.revision + 1 }), + ); +} + +export function abortArtifactUploadSessionV1( + session: ArtifactUploadSessionV1, + expectedRevision: unknown, +): ArtifactUploadResultV1 { + if (session.state !== 'OPEN') return rejected('INVALID_STATE'); + if (expectedRevision !== session.revision) return rejected('REVISION_CONFLICT'); + return accepted( + Object.freeze({ ...session, state: 'ABORTED' as const, revision: session.revision + 1 }), + ); +} + +export function expireArtifactUploadSessionV1( + session: ArtifactUploadSessionV1, + now: unknown, +): ArtifactUploadResultV1 { + const timestampValue = timestamp(now); + if (!timestampValue) return rejected('INVALID_TIMESTAMP'); + if (session.state !== 'OPEN') return rejected('INVALID_STATE'); + if (Date.parse(timestampValue) < Date.parse(session.expiresAt)) return rejected('EXPIRED'); + return accepted( + Object.freeze({ ...session, state: 'EXPIRED' as const, revision: session.revision + 1 }), + ); +} + +export function isArtifactUploadPartV1( + part: unknown, + totalParts: number, +): part is ArtifactUploadPartV1 { + return validPart(part, totalParts); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 2034d52e..8826736f 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -5,6 +5,7 @@ export * from './artifact-intake/v1.js'; export * from './artifact-governance/v1.js'; export * from './artifact-retention/v1.js'; export * from './artifact-export/v1.js'; +export * from './artifact-upload/v1.js'; export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; export * from './jobs/v1.js'; diff --git a/packages/domain/test/artifact-upload-v1.test.mjs b/packages/domain/test/artifact-upload-v1.test.mjs new file mode 100644 index 00000000..d40778a0 --- /dev/null +++ b/packages/domain/test/artifact-upload-v1.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + completeArtifactUploadSessionV1, + createArtifactUploadSessionV1, + recordArtifactUploadPartV1, +} from '../dist/artifact-upload/v1.js'; + +const base = { + sessionId: '11111111-1111-4111-8111-111111111111', + artifactId: '22222222-2222-4222-8222-222222222222', + tenantScope: { + scopeType: 'workspace', + organizationId: '33333333-3333-4333-8333-333333333333', + workspaceId: '44444444-4444-4444-8444-444444444444', + }, + expectedSha256: 'a'.repeat(64), + expectedByteSize: 8, + mediaType: 'application/octet-stream', + partSize: 4, + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T01:00:00.000Z', +}; + +void test('[IAE-014] upload sessions require every bounded part before completion', () => { + const created = createArtifactUploadSessionV1(base); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const first = recordArtifactUploadPartV1(created.value, { + partNumber: 1, + contentSha256: 'b'.repeat(64), + byteSize: 4, + uploadedAt: '2026-08-02T00:10:00.000Z', + expectedRevision: 1, + }); + assert.equal(first.accepted, true); + if (!first.accepted) return; + assert.deepEqual( + completeArtifactUploadSessionV1(first.value, { + assembledSha256: base.expectedSha256, + expectedRevision: 2, + }), + { accepted: false, code: 'MISSING_PARTS' }, + ); + const second = recordArtifactUploadPartV1(first.value, { + partNumber: 2, + contentSha256: 'c'.repeat(64), + byteSize: 4, + uploadedAt: '2026-08-02T00:11:00.000Z', + expectedRevision: 2, + }); + assert.equal(second.accepted, true); + if (!second.accepted) return; + assert.equal( + completeArtifactUploadSessionV1(second.value, { + assembledSha256: base.expectedSha256, + expectedRevision: 3, + }).value.state, + 'COMPLETED', + ); +}); diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index a57af3f5..f6467ab0 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -10,6 +10,7 @@ const [ artifactGovernance, artifactRetention, artifactExport, + artifactUpload, dataset, datasetGovernance, dataMode, @@ -34,6 +35,7 @@ const [ import('@databreeze/domain/artifact-governance/v1'), import('@databreeze/domain/artifact-retention/v1'), import('@databreeze/domain/artifact-export/v1'), + import('@databreeze/domain/artifact-upload/v1'), import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/data-mode/v1'), @@ -60,6 +62,7 @@ assert.equal(artifactIntake.ARTIFACT_INTAKE_SCHEMA_VERSION_V1, 1); assert.equal(artifactGovernance.ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(artifactRetention.ARTIFACT_RETENTION_SCHEMA_VERSION_V1, 1); assert.equal(artifactExport.ARTIFACT_EXPORT_SCHEMA_VERSION_V1, 1); +assert.equal(artifactUpload.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 78400cdf..aca1d84d 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -28,6 +28,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './artifact-governance/v1', './artifact-retention/v1', './artifact-export/v1', + './artifact-upload/v1', './dataset/v1', './dataset-governance/v1', './jobs/v1', @@ -66,6 +67,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); + assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.createScopedAuthorizationEvaluatorV1, 'function'); assert.equal(aggregate.MAPPING_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.RULE_SET_SCHEMA_VERSION_V1, 1); From 67713f2d7a9226f1d53dae5e3a91d4b184abc97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:40:11 +0700 Subject: [PATCH 053/177] feat(iae): coordinate resumable upload sessions --- ...mory-artifact-upload-repository.adapter.ts | 80 ++++++++++++++ .../artifact-upload-repository.port.ts | 20 ++++ .../application/artifact-upload.service.ts | 101 ++++++++++++++++++ .../iae/artifact-upload.service.test.ts | 53 +++++++++ 4 files changed, 254 insertions(+) create mode 100644 services/api/src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.ts create mode 100644 services/api/src/features/iae/application/artifact-upload-repository.port.ts create mode 100644 services/api/src/features/iae/application/artifact-upload.service.ts create mode 100644 services/api/test/features/iae/artifact-upload.service.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.ts new file mode 100644 index 00000000..2e2043eb --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.ts @@ -0,0 +1,80 @@ +import { + tenantScopeContainsV1, + type ArtifactUploadSessionV1, + type TenantScopeV1, +} from '@databreeze/domain/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactUploadRepositoryPortV1, + ArtifactUploadTransactionPortV1, +} from '../application/artifact-upload-repository.port.js'; + +function clone(session: ArtifactUploadSessionV1): ArtifactUploadSessionV1 { + return Object.freeze({ + ...session, + tenantScope: Object.freeze({ ...session.tenantScope }), + parts: Object.freeze(session.parts.map((part) => Object.freeze({ ...part }))), + }); +} + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +export class InMemoryArtifactUploadRepositoryAdapter implements ArtifactUploadRepositoryPortV1 { + private sessions = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, session: ArtifactUploadSessionV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, session.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = this.sessions.get(session.sessionId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(session)) return; + if (session.revision !== existing.revision + 1) + throw new Error('IAE_UPLOAD_REVISION_CONFLICT'); + if ( + existing.artifactId !== session.artifactId || + existing.expectedSha256 !== session.expectedSha256 || + existing.expectedByteSize !== session.expectedByteSize || + JSON.stringify(existing.tenantScope) !== JSON.stringify(session.tenantScope) + ) + throw new Error('IAE_UPLOAD_IMMUTABLE_IDENTITY'); + } + this.sessions.set(session.sessionId, clone(session)); + } + + public async find( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + ): Promise { + await Promise.resolve(); + const session = this.sessions.get(sessionId); + return session && visible(context.tenantScope, session.tenantScope) + ? clone(session) + : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactUploadTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.sessions); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + } catch (error) { + this.sessions = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iae/application/artifact-upload-repository.port.ts b/services/api/src/features/iae/application/artifact-upload-repository.port.ts new file mode 100644 index 00000000..f05d2961 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-upload-repository.port.ts @@ -0,0 +1,20 @@ +import type { ArtifactUploadSessionV1 } from '@databreeze/domain/artifact-upload/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const ARTIFACT_UPLOAD_REPOSITORY_PORT = Symbol('ARTIFACT_UPLOAD_REPOSITORY_PORT'); + +export interface ArtifactUploadTransactionPortV1 { + save(context: IamTenantContextV1, session: ArtifactUploadSessionV1): Promise; + find( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + ): Promise; +} + +export interface ArtifactUploadRepositoryPortV1 extends ArtifactUploadTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactUploadTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/iae/application/artifact-upload.service.ts b/services/api/src/features/iae/application/artifact-upload.service.ts new file mode 100644 index 00000000..8a3a8214 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-upload.service.ts @@ -0,0 +1,101 @@ +import { + abortArtifactUploadSessionV1, + completeArtifactUploadSessionV1, + createArtifactUploadSessionV1, + expireArtifactUploadSessionV1, + recordArtifactUploadPartV1, + type ArtifactUploadResultV1, + type ArtifactUploadSessionV1, +} from '@databreeze/domain/artifact-upload/v1'; +import { tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { ArtifactUploadRepositoryPortV1 } from './artifact-upload-repository.port.js'; + +export type ArtifactUploadServiceErrorV1 = 'UPLOAD_NOT_FOUND' | 'UPLOAD_SCOPE_NARROWING_REQUIRED'; +export type ArtifactUploadServiceResultV1 = + | ArtifactUploadResultV1 + | { readonly accepted: false; readonly code: ArtifactUploadServiceErrorV1 }; + +/** Coordinates revisioned upload state without accepting paths, URLs, or raw bytes. */ +export class ArtifactUploadService { + public constructor(private readonly repository: ArtifactUploadRepositoryPortV1) {} + + public async create( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { + const created = createArtifactUploadSessionV1(input); + if (!created.accepted) return created; + if (!this.scopeAllowed(context, created.value)) + return Object.freeze({ accepted: false, code: 'UPLOAD_SCOPE_NARROWING_REQUIRED' as const }); + await this.repository.save(context, created.value); + return created; + } + + public async find( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + ): Promise { + return this.repository.find(context, sessionId); + } + + public async recordPart( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + input: Parameters[1], + ): Promise> { + return this.mutate(context, sessionId, (session) => recordArtifactUploadPartV1(session, input)); + } + + public async complete( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + input: Parameters[1], + ): Promise> { + return this.mutate(context, sessionId, (session) => + completeArtifactUploadSessionV1(session, input), + ); + } + + public async abort( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + expectedRevision: unknown, + ): Promise> { + return this.mutate(context, sessionId, (session) => + abortArtifactUploadSessionV1(session, expectedRevision), + ); + } + + public async expire( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + now: unknown, + ): Promise> { + return this.mutate(context, sessionId, (session) => + expireArtifactUploadSessionV1(session, now), + ); + } + + private async mutate( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + operation: ( + session: ArtifactUploadSessionV1, + ) => ArtifactUploadResultV1, + ): Promise> { + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, sessionId); + if (!current) return Object.freeze({ accepted: false, code: 'UPLOAD_NOT_FOUND' as const }); + const next = operation(current); + if (!next.accepted) return next; + await transaction.save(context, next.value); + return next; + }); + } + + private scopeAllowed(context: IamTenantContextV1, session: ArtifactUploadSessionV1): boolean { + return tenantScopeContainsV1(context.tenantScope, session.tenantScope); + } +} diff --git a/services/api/test/features/iae/artifact-upload.service.test.ts b/services/api/test/features/iae/artifact-upload.service.test.ts new file mode 100644 index 00000000..a76016bc --- /dev/null +++ b/services/api/test/features/iae/artifact-upload.service.test.ts @@ -0,0 +1,53 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { ArtifactUploadService } from '../../../src/features/iae/application/artifact-upload.service.js'; +import { InMemoryArtifactUploadRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'upload-service', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +void test('IAE-014 service persists parts and rejects stale completion', async () => { + const service = new ArtifactUploadService(new InMemoryArtifactUploadRepositoryAdapter()); + const created = await service.create(context, { + sessionId: '55555555-5555-4555-8555-555555555555', + artifactId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + expectedSha256: 'a'.repeat(64), + expectedByteSize: 4, + mediaType: 'application/octet-stream', + partSize: 4, + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T01:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const part = await service.recordPart(context, created.value.sessionId, { + partNumber: 1, + contentSha256: 'b'.repeat(64), + byteSize: 4, + uploadedAt: '2026-08-02T00:10:00.000Z', + expectedRevision: 1, + }); + assert.equal(part.accepted, true); + if (!part.accepted) return; + const completed = await service.complete(context, created.value.sessionId, { + assembledSha256: 'a'.repeat(64), + expectedRevision: 2, + }); + assert.equal(completed.accepted, true); + if (!completed.accepted) return; + assert.equal(completed.value.state, 'COMPLETED'); +}); From 3fbb5814990232b6ba933b31c829a14636754551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:40:32 +0700 Subject: [PATCH 054/177] feat(iae): persist resumable upload sessions --- .../migration.sql | 27 +++++++++++++++++++ services/api/prisma/schema/iae.prisma | 26 ++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 services/api/prisma/migrations/20260802240000_iae_upload_sessions/migration.sql diff --git a/services/api/prisma/migrations/20260802240000_iae_upload_sessions/migration.sql b/services/api/prisma/migrations/20260802240000_iae_upload_sessions/migration.sql new file mode 100644 index 00000000..6d5b36ea --- /dev/null +++ b/services/api/prisma/migrations/20260802240000_iae_upload_sessions/migration.sql @@ -0,0 +1,27 @@ +CREATE TABLE "iae"."artifact_upload_sessions" ( + "id" UUID NOT NULL, + "artifact_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "expected_sha256" CHAR(64) NOT NULL, + "expected_byte_size" BIGINT NOT NULL, + "media_type" VARCHAR(255) NOT NULL, + "part_size" INTEGER NOT NULL, + "total_parts" INTEGER NOT NULL, + "parts" JSONB NOT NULL, + "state" VARCHAR(16) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + + CONSTRAINT "artifact_upload_sessions_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "artifact_upload_sessions_artifact_idx" + ON "iae"."artifact_upload_sessions"("artifact_id"); +CREATE INDEX "artifact_upload_sessions_scope_state_idx" + ON "iae"."artifact_upload_sessions"("organization_id", "workspace_id", "project_id", "state"); +CREATE INDEX "artifact_upload_sessions_expiry_idx" + ON "iae"."artifact_upload_sessions"("expires_at"); diff --git a/services/api/prisma/schema/iae.prisma b/services/api/prisma/schema/iae.prisma index da4a5481..4ed5343f 100644 --- a/services/api/prisma/schema/iae.prisma +++ b/services/api/prisma/schema/iae.prisma @@ -165,3 +165,29 @@ model ArtifactExportManifestRecord { @@map("artifact_export_manifests") @@schema("iae") } + +/// IAE-014: resumable, content-addressed upload sessions with bounded part metadata. +model ArtifactUploadSessionRecord { + id String @id @db.Uuid + artifactId String @map("artifact_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + expectedSha256 String @map("expected_sha256") @db.Char(64) + expectedByteSize BigInt @map("expected_byte_size") + mediaType String @map("media_type") @db.VarChar(255) + partSize Int @map("part_size") + totalParts Int @map("total_parts") + parts Json + state String @db.VarChar(16) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + revision Int @default(1) + + @@index([artifactId], map: "artifact_upload_sessions_artifact_idx") + @@index([organizationId, workspaceId, projectId, state], map: "artifact_upload_sessions_scope_state_idx") + @@index([expiresAt], map: "artifact_upload_sessions_expiry_idx") + @@map("artifact_upload_sessions") + @@schema("iae") +} From 270ceb1ce8396a749454e285f21715bb48c6ae50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 00:42:25 +0700 Subject: [PATCH 055/177] feat(iae): add Prisma upload session adapter --- ...isma-artifact-upload-repository.adapter.ts | 239 ++++++++++++++++++ .../prisma-artifact-upload-repository.test.ts | 85 +++++++ 2 files changed, 324 insertions(+) create mode 100644 services/api/src/features/iae/adapter/prisma-artifact-upload-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-artifact-upload-repository.test.ts diff --git a/services/api/src/features/iae/adapter/prisma-artifact-upload-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-upload-repository.adapter.ts new file mode 100644 index 00000000..69251dd1 --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-artifact-upload-repository.adapter.ts @@ -0,0 +1,239 @@ +import { + abortArtifactUploadSessionV1, + completeArtifactUploadSessionV1, + createArtifactUploadSessionV1, + expireArtifactUploadSessionV1, + recordArtifactUploadPartV1, + type ArtifactUploadSessionV1, +} from '@databreeze/domain/artifact-upload/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactUploadRepositoryPortV1, + ArtifactUploadTransactionPortV1, +} from '../application/artifact-upload-repository.port.js'; + +export interface ArtifactUploadDatabaseRowV1 { + readonly id: string; + readonly artifactId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly expectedSha256: string; + readonly expectedByteSize: bigint | number; + readonly mediaType: string; + readonly partSize: number; + readonly totalParts: number; + readonly parts: unknown; + readonly state: string; + readonly createdAt: Date; + readonly expiresAt: Date; + readonly revision: number; +} + +export interface ArtifactUploadDatabaseCreateDataV1 { + readonly id: string; + readonly artifactId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly expectedSha256: string; + readonly expectedByteSize: bigint; + readonly mediaType: string; + readonly partSize: number; + readonly totalParts: number; + readonly parts: unknown; + readonly state: string; + readonly createdAt: Date; + readonly expiresAt: Date; + readonly revision: number; +} + +export interface ArtifactUploadDatabaseClientV1 { + readonly artifactUploadSessionRecord: { + create(input: { + readonly data: ArtifactUploadDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { readonly parts: unknown; readonly state: string; readonly revision: number }; + }): Promise; + }; + $transaction( + work: (transaction: ArtifactUploadDatabaseClientV1) => 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 rowScope(row: ArtifactUploadDatabaseRowV1): 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('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: ArtifactUploadDatabaseRowV1): ArtifactUploadSessionV1 { + const expectedByteSize = + typeof row.expectedByteSize === 'bigint' ? Number(row.expectedByteSize) : row.expectedByteSize; + if (!Number.isSafeInteger(expectedByteSize) || expectedByteSize < 0) + throw new Error('IAE_PERSISTED_UPLOAD_SIZE_INVALID'); + const created = createArtifactUploadSessionV1({ + sessionId: row.id, + artifactId: row.artifactId, + tenantScope: rowScope(row), + expectedSha256: row.expectedSha256, + expectedByteSize, + mediaType: row.mediaType, + partSize: row.partSize, + createdAt: row.createdAt.toISOString(), + expiresAt: row.expiresAt.toISOString(), + }); + if (!created.accepted) throw new Error('IAE_PERSISTED_UPLOAD_INVALID'); + if (!Array.isArray(row.parts)) throw new Error('IAE_PERSISTED_UPLOAD_PARTS_INVALID'); + let session = created.value; + for (const part of row.parts) { + if (typeof part !== 'object' || part === null || Array.isArray(part)) + throw new Error('IAE_PERSISTED_UPLOAD_PART_INVALID'); + const persistedPart = part as { + readonly partNumber: unknown; + readonly contentSha256: unknown; + readonly byteSize: unknown; + readonly uploadedAt: unknown; + }; + const next = recordArtifactUploadPartV1(session, { + ...persistedPart, + expectedRevision: session.revision, + }); + if (!next.accepted) throw new Error('IAE_PERSISTED_UPLOAD_PART_INVALID'); + session = next.value; + } + if (row.state === 'COMPLETED') { + const completed = completeArtifactUploadSessionV1(session, { + assembledSha256: row.expectedSha256, + expectedRevision: session.revision, + }); + if (!completed.accepted) throw new Error('IAE_PERSISTED_UPLOAD_STATE_INVALID'); + session = completed.value; + } else if (row.state === 'ABORTED') { + const aborted = abortArtifactUploadSessionV1(session, session.revision); + if (!aborted.accepted) throw new Error('IAE_PERSISTED_UPLOAD_STATE_INVALID'); + session = aborted.value; + } else if (row.state === 'EXPIRED') { + const expired = expireArtifactUploadSessionV1(session, row.expiresAt.toISOString()); + if (!expired.accepted) throw new Error('IAE_PERSISTED_UPLOAD_STATE_INVALID'); + session = expired.value; + } else if (row.state !== 'OPEN') { + throw new Error('IAE_PERSISTED_UPLOAD_STATE_INVALID'); + } + if (session.revision !== row.revision) throw new Error('IAE_PERSISTED_UPLOAD_REVISION_INVALID'); + return session; +} + +function domainToCreate(session: ArtifactUploadSessionV1): ArtifactUploadDatabaseCreateDataV1 { + return { + ...databaseScope(session.tenantScope), + id: session.sessionId, + artifactId: session.artifactId, + expectedSha256: session.expectedSha256, + expectedByteSize: BigInt(session.expectedByteSize), + mediaType: session.mediaType, + partSize: session.partSize, + totalParts: session.totalParts, + parts: session.parts, + state: session.state, + createdAt: new Date(session.createdAt), + expiresAt: new Date(session.expiresAt), + revision: session.revision, + }; +} + +function visible(context: TenantScopeV1, row: ArtifactUploadDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaArtifactUploadTransactionAdapter implements ArtifactUploadTransactionPortV1 { + public constructor(private readonly client: ArtifactUploadDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, session: ArtifactUploadSessionV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, session.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.artifactUploadSessionRecord.findUnique({ + where: { id: session.sessionId }, + }); + if (existing === null) { + await this.client.artifactUploadSessionRecord.create({ data: domainToCreate(session) }); + return; + } + const current = rowToDomain(existing); + if (JSON.stringify(current) === JSON.stringify(session)) return; + if (session.revision !== current.revision + 1) throw new Error('IAE_UPLOAD_REVISION_CONFLICT'); + if ( + current.artifactId !== session.artifactId || + current.expectedSha256 !== session.expectedSha256 || + current.expectedByteSize !== session.expectedByteSize || + JSON.stringify(current.tenantScope) !== JSON.stringify(session.tenantScope) + ) + throw new Error('IAE_UPLOAD_IMMUTABLE_IDENTITY'); + await this.client.artifactUploadSessionRecord.update({ + where: { id: session.sessionId }, + data: { parts: session.parts, state: session.state, revision: session.revision }, + }); + } + + public async find( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + ): Promise { + const row = await this.client.artifactUploadSessionRecord.findUnique({ + where: { id: sessionId }, + }); + return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; + } +} + +export class PrismaArtifactUploadRepositoryAdapter implements ArtifactUploadRepositoryPortV1 { + public constructor(private readonly client: ArtifactUploadDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ArtifactUploadTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaArtifactUploadTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, session: ArtifactUploadSessionV1): Promise { + return new PrismaArtifactUploadTransactionAdapter(this.client).save(context, session); + } + + public find( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + ): Promise { + return new PrismaArtifactUploadTransactionAdapter(this.client).find(context, sessionId); + } +} diff --git a/services/api/test/features/iae/prisma-artifact-upload-repository.test.ts b/services/api/test/features/iae/prisma-artifact-upload-repository.test.ts new file mode 100644 index 00000000..97f5db23 --- /dev/null +++ b/services/api/test/features/iae/prisma-artifact-upload-repository.test.ts @@ -0,0 +1,85 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { + createArtifactUploadSessionV1, + recordArtifactUploadPartV1, +} from '@databreeze/domain/artifact-upload/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { + PrismaArtifactUploadRepositoryAdapter, + type ArtifactUploadDatabaseClientV1, + type ArtifactUploadDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-artifact-upload-repository.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'prisma-upload', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; +const created = createArtifactUploadSessionV1({ + sessionId: '55555555-5555-4555-8555-555555555555', + artifactId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + expectedSha256: 'a'.repeat(64), + expectedByteSize: 4, + mediaType: 'application/octet-stream', + partSize: 4, + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T01:00:00.000Z', +}); +if (!created.accepted) throw new Error('fixture upload invalid'); +const part = recordArtifactUploadPartV1(created.value, { + partNumber: 1, + contentSha256: 'b'.repeat(64), + byteSize: 4, + uploadedAt: '2026-08-02T00:10:00.000Z', + expectedRevision: 1, +}); +if (!part.accepted) throw new Error('fixture part invalid'); + +function client(rows: ArtifactUploadDatabaseRowV1[]): ArtifactUploadDatabaseClientV1 { + return { + artifactUploadSessionRecord: { + create({ data }) { + const row = { + ...data, + expectedByteSize: data.expectedByteSize, + } as ArtifactUploadDatabaseRowV1; + rows.push(row); + return Promise.resolve(row); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + update({ where, data }) { + const current = rows.find((row) => row.id === where.id); + if (!current) throw new Error('fixture upload not found'); + const next = { ...current, ...data }; + rows[rows.indexOf(current)] = next; + return Promise.resolve(next); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('IAE-014 Prisma upload adapter preserves parts, revisions, and immutable identity', async () => { + const rows: ArtifactUploadDatabaseRowV1[] = []; + const repository = new PrismaArtifactUploadRepositoryAdapter(client(rows)); + await repository.save(context, created.value); + await repository.save(context, created.value); + await repository.save(context, part.value); + assert.deepEqual(await repository.find(context, created.value.sessionId), part.value); + assert.equal(rows.length, 1); +}); From 1970f553b4a5f615ddbbcc32ac9df41dafb41c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:08:43 +0700 Subject: [PATCH 056/177] feat(iae): expose upload session control plane --- .../iae/api/artifact-upload.controller.ts | 104 ++++++++++++++++++ .../features/iae/api/artifact-upload.dto.ts | 82 ++++++++++++++ services/api/src/features/iae/iae.module.ts | 23 ++++ .../iae/artifact-upload.controller.test.ts | 51 +++++++++ 4 files changed, 260 insertions(+) create mode 100644 services/api/src/features/iae/api/artifact-upload.controller.ts create mode 100644 services/api/src/features/iae/api/artifact-upload.dto.ts create mode 100644 services/api/test/features/iae/artifact-upload.controller.test.ts diff --git a/services/api/src/features/iae/api/artifact-upload.controller.ts b/services/api/src/features/iae/api/artifact-upload.controller.ts new file mode 100644 index 00000000..6b43131c --- /dev/null +++ b/services/api/src/features/iae/api/artifact-upload.controller.ts @@ -0,0 +1,104 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + ARTIFACT_UPLOAD_REPOSITORY_PORT, + type ArtifactUploadRepositoryPortV1, +} from '../application/artifact-upload-repository.port.js'; +import { ArtifactUploadService } from '../application/artifact-upload.service.js'; +import { + AbortArtifactUploadDto, + CompleteArtifactUploadDto, + CreateArtifactUploadSessionDto, + RecordArtifactUploadPartDto, +} from './artifact-upload.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +/** IAE-014: upload control-plane metadata only; bytes travel through a separately governed transfer adapter. */ +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifact-upload-sessions') +export class ArtifactUploadController { + private readonly uploads: ArtifactUploadService; + + public constructor( + @Inject(ARTIFACT_UPLOAD_REPOSITORY_PORT) repository: ArtifactUploadRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.uploads = new ArtifactUploadService(repository); + } + + @Post() + @ApiOperation({ summary: 'Create a bounded resumable artifact upload session' }) + @ApiBody({ type: CreateArtifactUploadSessionDto }) + async create( + @Req() request: unknown, + @Body() input: CreateArtifactUploadSessionDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.uploads.create(context, { ...input, tenantScope: context.tenantScope }); + } + + @Get(':sessionId') + @ApiOperation({ summary: 'Read upload session metadata and completed part digests' }) + async find( + @Req() request: unknown, + @Param('sessionId') sessionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const sessionId = parseStableIdentifierV1(sessionIdInput); + if (!sessionId.accepted) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + const session = await this.uploads.find(context, sessionId.value); + return session + ? Object.freeze({ accepted: true, value: session }) + : Object.freeze({ accepted: false, code: 'NOT_FOUND' }); + } + + @Post(':sessionId/parts') + @ApiOperation({ summary: 'Record one verified upload part digest' }) + @ApiBody({ type: RecordArtifactUploadPartDto }) + async part( + @Req() request: unknown, + @Param('sessionId') sessionIdInput: string, + @Body() input: RecordArtifactUploadPartDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const sessionId = parseStableIdentifierV1(sessionIdInput); + if (!sessionId.accepted) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + return this.uploads.recordPart(context, sessionId.value, input); + } + + @Post(':sessionId/complete') + @ApiOperation({ + summary: 'Finalize an upload after all part digests and the assembled hash match', + }) + @ApiBody({ type: CompleteArtifactUploadDto }) + async complete( + @Req() request: unknown, + @Param('sessionId') sessionIdInput: string, + @Body() input: CompleteArtifactUploadDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const sessionId = parseStableIdentifierV1(sessionIdInput); + if (!sessionId.accepted) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + return this.uploads.complete(context, sessionId.value, input); + } + + @Post(':sessionId/abort') + @ApiOperation({ summary: 'Abort an open upload session' }) + @ApiBody({ type: AbortArtifactUploadDto }) + async abort( + @Req() request: unknown, + @Param('sessionId') sessionIdInput: string, + @Body() input: AbortArtifactUploadDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const sessionId = parseStableIdentifierV1(sessionIdInput); + if (!sessionId.accepted) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + return this.uploads.abort(context, sessionId.value, input.expectedRevision); + } +} diff --git a/services/api/src/features/iae/api/artifact-upload.dto.ts b/services/api/src/features/iae/api/artifact-upload.dto.ts new file mode 100644 index 00000000..6740e832 --- /dev/null +++ b/services/api/src/features/iae/api/artifact-upload.dto.ts @@ -0,0 +1,82 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsISO8601, IsInt, IsNumber, IsString, IsUUID, Matches, Max, Min } from 'class-validator'; + +export class CreateArtifactUploadSessionDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + sessionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + artifactId!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @Matches(/^[0-9a-f]{64}$/u) + expectedSha256!: string; + + @ApiProperty({ minimum: 0 }) + @IsNumber() + @Min(0) + expectedByteSize!: number; + + @ApiProperty() + @IsString() + mediaType!: string; + + @ApiProperty({ minimum: 1, maximum: 1073741824 }) + @IsInt() + @Min(1) + @Max(1073741824) + partSize!: number; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + expiresAt!: string; +} + +export class RecordArtifactUploadPartDto { + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + partNumber!: number; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @Matches(/^[0-9a-f]{64}$/u) + contentSha256!: string; + + @ApiProperty({ minimum: 0 }) + @IsNumber() + @Min(0) + byteSize!: number; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + uploadedAt!: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; +} + +export class CompleteArtifactUploadDto { + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @Matches(/^[0-9a-f]{64}$/u) + assembledSha256!: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; +} + +export class AbortArtifactUploadDto { + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 2d7f48cb..95c27832 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -7,6 +7,7 @@ import { ArtifactLineageController } from './api/artifact-lineage.controller.js' import { ContentPlacementController } from './api/content-placement.controller.js'; import { ArtifactRetentionController } from './api/artifact-retention.controller.js'; import { ArtifactExportController } from './api/artifact-export.controller.js'; +import { ArtifactUploadController } from './api/artifact-upload.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -28,6 +29,11 @@ import { PrismaArtifactExportRepositoryAdapter, type ArtifactExportDatabaseClientV1, } from './adapter/prisma-artifact-export-repository.adapter.js'; +import { InMemoryArtifactUploadRepositoryAdapter } from './adapter/in-memory-artifact-upload-repository.adapter.js'; +import { + PrismaArtifactUploadRepositoryAdapter, + type ArtifactUploadDatabaseClientV1, +} from './adapter/prisma-artifact-upload-repository.adapter.js'; import { PrismaArtifactRepositoryAdapter, type ArtifactDatabaseClientV1, @@ -53,6 +59,10 @@ import { ARTIFACT_EXPORT_REPOSITORY_PORT, type ArtifactExportRepositoryPortV1, } from './application/artifact-export-repository.port.js'; +import { + ARTIFACT_UPLOAD_REPOSITORY_PORT, + type ArtifactUploadRepositoryPortV1, +} from './application/artifact-upload-repository.port.js'; import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1, @@ -79,6 +89,9 @@ export interface IaeModuleOptions { readonly artifactExportRepository?: ArtifactExportRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactExportDatabase?: ArtifactExportDatabaseClientV1; + readonly artifactUploadRepository?: ArtifactUploadRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly artifactUploadDatabase?: ArtifactUploadDatabaseClientV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -96,6 +109,7 @@ export class IaeModule { ContentPlacementController, ArtifactRetentionController, ArtifactExportController, + ArtifactUploadController, ], providers: [ { @@ -138,6 +152,14 @@ export class IaeModule { ? new InMemoryArtifactExportRepositoryAdapter() : new PrismaArtifactExportRepositoryAdapter(options.artifactExportDatabase)), }, + { + provide: ARTIFACT_UPLOAD_REPOSITORY_PORT, + useValue: + options.artifactUploadRepository ?? + (options.artifactUploadDatabase === undefined + ? new InMemoryArtifactUploadRepositoryAdapter() + : new PrismaArtifactUploadRepositoryAdapter(options.artifactUploadDatabase)), + }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, useValue: options.evidenceGrantRepository ?? new InMemoryEvidenceGrantRepositoryAdapter(), @@ -153,6 +175,7 @@ export class IaeModule { ARTIFACT_LINEAGE_REPOSITORY_PORT, ARTIFACT_RETENTION_REPOSITORY_PORT, ARTIFACT_EXPORT_REPOSITORY_PORT, + ARTIFACT_UPLOAD_REPOSITORY_PORT, EVIDENCE_GRANT_REPOSITORY_PORT, ], }; diff --git a/services/api/test/features/iae/artifact-upload.controller.test.ts b/services/api/test/features/iae/artifact-upload.controller.test.ts new file mode 100644 index 00000000..b98f9dc5 --- /dev/null +++ b/services/api/test/features/iae/artifact-upload.controller.test.ts @@ -0,0 +1,51 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryArtifactUploadRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'upload-http', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const tenantContext = contextResult.value; + +void test('IAE-014 upload HTTP control plane never accepts source bytes or paths', async () => { + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + artifactUploadRepository: new InMemoryArtifactUploadRepositoryAdapter(), + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/artifact-upload-sessions', + payload: { + sessionId: '55555555-5555-4555-8555-555555555555', + artifactId: '66666666-6666-4666-8666-666666666666', + expectedSha256: 'a'.repeat(64), + expectedByteSize: 4, + mediaType: 'application/octet-stream', + partSize: 4, + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T01:00:00.000Z', + }, + }); + assert.equal(response.statusCode, 201); + assert.doesNotMatch(response.body, /sourcePath|localPath|rawBytes|excerpt/iu); + } finally { + await app.close(); + } +}); From a077152b5bb405ba9770b4bc77212336f2b7b3a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:10:09 +0700 Subject: [PATCH 057/177] feat(iae): add Prisma evidence grant adapter --- ...risma-evidence-grant-repository.adapter.ts | 213 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 12 +- .../prisma-evidence-grant-repository.test.ts | 77 +++++++ 3 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iae/adapter/prisma-evidence-grant-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-evidence-grant-repository.test.ts diff --git a/services/api/src/features/iae/adapter/prisma-evidence-grant-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-evidence-grant-repository.adapter.ts new file mode 100644 index 00000000..e75f8324 --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-evidence-grant-repository.adapter.ts @@ -0,0 +1,213 @@ +import type { + EvidenceAccessGrantV1, + EvidenceGrantActionV1, +} from '@databreeze/domain/evidence-grant/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 { + EvidenceGrantRepositoryPortV1, + EvidenceGrantTransactionPortV1, +} from '../application/evidence-grant-repository.port.js'; + +export interface EvidenceGrantDatabaseRowV1 { + readonly id: string; + readonly evidenceId: string; + readonly artifactVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly recipientDeviceId: string; + readonly action: string; + readonly issuedAt: Date; + readonly expiresAt: Date; + readonly authorizationEpoch: number; + readonly maxExcerptBytes: number; + readonly revokedAt: Date | null; +} + +export interface EvidenceGrantDatabaseClientV1 { + readonly evidenceGrantRecord: { + create(input: { + readonly data: Omit & { + readonly revokedAt: Date | null; + }; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { readonly revokedAt: Date }; + }): Promise; + }; + $transaction( + work: (transaction: EvidenceGrantDatabaseClientV1) => Promise, + ): Promise; +} + +function scope(row: EvidenceGrantDatabaseRowV1): 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('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function id(input: string, error: string) { + const parsed = parseStableIdentifierV1(input); + if (!parsed.accepted) throw new Error(error); + return parsed.value; +} + +function timestamp(input: Date, error: string) { + const parsed = parseStrictUtcTimestampV1(input.toISOString()); + if (!parsed.accepted) throw new Error(error); + return parsed.value; +} + +function rowToDomain(row: EvidenceGrantDatabaseRowV1): EvidenceAccessGrantV1 { + if (!['COORDINATE', 'EXCERPT', 'OPEN_ON_DEVICE'].includes(row.action)) + throw new Error('IAE_PERSISTED_GRANT_ACTION_INVALID'); + if ( + !Number.isSafeInteger(row.authorizationEpoch) || + row.authorizationEpoch < 1 || + !Number.isSafeInteger(row.maxExcerptBytes) || + row.maxExcerptBytes < 0 || + row.maxExcerptBytes > 4096 + ) + throw new Error('IAE_PERSISTED_GRANT_LIMIT_INVALID'); + return Object.freeze({ + schemaVersion: 1, + grantId: id(row.id, 'IAE_PERSISTED_GRANT_ID_INVALID'), + evidenceId: id(row.evidenceId, 'IAE_PERSISTED_GRANT_ID_INVALID'), + artifactVersionId: id(row.artifactVersionId, 'IAE_PERSISTED_GRANT_ID_INVALID'), + tenantScope: scope(row), + recipientDeviceId: id(row.recipientDeviceId, 'IAE_PERSISTED_GRANT_ID_INVALID'), + action: row.action as EvidenceGrantActionV1, + issuedAt: timestamp(row.issuedAt, 'IAE_PERSISTED_GRANT_TIMESTAMP_INVALID'), + expiresAt: timestamp(row.expiresAt, 'IAE_PERSISTED_GRANT_TIMESTAMP_INVALID'), + authorizationEpoch: row.authorizationEpoch, + maxExcerptBytes: row.maxExcerptBytes, + }); +} + +function databaseScope(scopeValue: TenantScopeV1) { + return { + scopeType: scopeValue.scopeType, + organizationId: scopeValue.organizationId, + workspaceId: scopeValue.scopeType === 'organization' ? null : scopeValue.workspaceId, + projectId: scopeValue.scopeType === 'project' ? scopeValue.projectId : null, + } as const; +} + +function visible(context: TenantScopeV1, row: EvidenceGrantDatabaseRowV1): boolean { + const candidate = scope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaEvidenceGrantTransactionAdapter implements EvidenceGrantTransactionPortV1 { + public constructor(private readonly client: EvidenceGrantDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, grant: EvidenceAccessGrantV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, grant.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.evidenceGrantRecord.findUnique({ + where: { id: grant.grantId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(grant)) + throw new Error('IAE_IMMUTABLE_GRANT'); + return; + } + await this.client.evidenceGrantRecord.create({ + data: { + ...databaseScope(grant.tenantScope), + id: grant.grantId, + evidenceId: grant.evidenceId, + artifactVersionId: grant.artifactVersionId, + recipientDeviceId: grant.recipientDeviceId, + action: grant.action, + issuedAt: new Date(grant.issuedAt), + expiresAt: new Date(grant.expiresAt), + authorizationEpoch: grant.authorizationEpoch, + maxExcerptBytes: grant.maxExcerptBytes, + revokedAt: null, + }, + }); + } + + public async find( + context: IamTenantContextV1, + grantId: EvidenceAccessGrantV1['grantId'], + ): Promise { + const row = await this.client.evidenceGrantRecord.findUnique({ where: { id: grantId } }); + return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; + } + + public async revoke( + context: IamTenantContextV1, + grantId: EvidenceAccessGrantV1['grantId'], + ): Promise { + const row = await this.client.evidenceGrantRecord.findUnique({ where: { id: grantId } }); + if (row === null || !visible(context.tenantScope, row)) throw new Error('IAE_GRANT_NOT_FOUND'); + if (row.revokedAt !== null) return; + await this.client.evidenceGrantRecord.update({ + where: { id: grantId }, + data: { revokedAt: new Date() }, + }); + } + + public async isRevoked( + context: IamTenantContextV1, + grantId: EvidenceAccessGrantV1['grantId'], + ): Promise { + const row = await this.client.evidenceGrantRecord.findUnique({ where: { id: grantId } }); + return row !== null && visible(context.tenantScope, row) && row.revokedAt !== null; + } +} + +export class PrismaEvidenceGrantRepositoryAdapter implements EvidenceGrantRepositoryPortV1 { + public constructor(private readonly client: EvidenceGrantDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: EvidenceGrantTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaEvidenceGrantTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, grant: EvidenceAccessGrantV1): Promise { + return new PrismaEvidenceGrantTransactionAdapter(this.client).save(context, grant); + } + public find( + context: IamTenantContextV1, + grantId: EvidenceAccessGrantV1['grantId'], + ): Promise { + return new PrismaEvidenceGrantTransactionAdapter(this.client).find(context, grantId); + } + public revoke( + context: IamTenantContextV1, + grantId: EvidenceAccessGrantV1['grantId'], + ): Promise { + return new PrismaEvidenceGrantTransactionAdapter(this.client).revoke(context, grantId); + } + public isRevoked( + context: IamTenantContextV1, + grantId: EvidenceAccessGrantV1['grantId'], + ): Promise { + return new PrismaEvidenceGrantTransactionAdapter(this.client).isRevoked(context, grantId); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 95c27832..7f27322a 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -39,6 +39,10 @@ import { type ArtifactDatabaseClientV1, } from './adapter/prisma-artifact-repository.adapter.js'; import { InMemoryEvidenceGrantRepositoryAdapter } from './adapter/in-memory-evidence-grant-repository.adapter.js'; +import { + PrismaEvidenceGrantRepositoryAdapter, + type EvidenceGrantDatabaseClientV1, +} from './adapter/prisma-evidence-grant-repository.adapter.js'; import { ARTIFACT_INTAKE_REPOSITORY_PORT, type ArtifactIntakeRepositoryPortV1, @@ -93,6 +97,8 @@ export interface IaeModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactUploadDatabase?: ArtifactUploadDatabaseClientV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly evidenceGrantDatabase?: EvidenceGrantDatabaseClientV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -162,7 +168,11 @@ export class IaeModule { }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, - useValue: options.evidenceGrantRepository ?? new InMemoryEvidenceGrantRepositoryAdapter(), + useValue: + options.evidenceGrantRepository ?? + (options.evidenceGrantDatabase === undefined + ? new InMemoryEvidenceGrantRepositoryAdapter() + : new PrismaEvidenceGrantRepositoryAdapter(options.evidenceGrantDatabase)), }, { provide: REQUEST_TENANT_CONTEXT, diff --git a/services/api/test/features/iae/prisma-evidence-grant-repository.test.ts b/services/api/test/features/iae/prisma-evidence-grant-repository.test.ts new file mode 100644 index 00000000..d4159c7b --- /dev/null +++ b/services/api/test/features/iae/prisma-evidence-grant-repository.test.ts @@ -0,0 +1,77 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createEvidenceAccessGrantV1 } from '@databreeze/domain/evidence-grant/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { + PrismaEvidenceGrantRepositoryAdapter, + type EvidenceGrantDatabaseClientV1, + type EvidenceGrantDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-evidence-grant-repository.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'prisma-grant', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; +const grantResult = createEvidenceAccessGrantV1({ + grantId: '55555555-5555-4555-8555-555555555555', + evidenceId: '66666666-6666-4666-8666-666666666666', + artifactVersionId: '77777777-7777-4777-8777-777777777777', + tenantScope: context.tenantScope, + recipientDeviceId: '88888888-8888-4888-8888-888888888888', + action: 'COORDINATE', + issuedAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T00:05:00.000Z', + authorizationEpoch: 1, + artifactDataMode: 'Hybrid', + sourceState: 'AVAILABLE', +}); +if (!grantResult.accepted) throw new Error('fixture grant invalid'); +const grant = grantResult.value; + +function client(rows: EvidenceGrantDatabaseRowV1[]): EvidenceGrantDatabaseClientV1 { + return { + evidenceGrantRecord: { + create({ data }) { + const row = { ...data } as EvidenceGrantDatabaseRowV1; + rows.push(row); + return Promise.resolve(row); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + update({ where, data }) { + const row = rows.find((candidate) => candidate.id === where.id); + if (!row) throw new Error('fixture grant not found'); + const next = { ...row, ...data }; + rows[rows.indexOf(row)] = next; + return Promise.resolve(next); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('IAE-005 Prisma grant adapter persists immutable grants and revocation', async () => { + const rows: EvidenceGrantDatabaseRowV1[] = []; + const repository = new PrismaEvidenceGrantRepositoryAdapter(client(rows)); + await repository.save(context, grant); + await repository.save(context, grant); + assert.deepEqual(await repository.find(context, grant.grantId), grant); + assert.equal(await repository.isRevoked(context, grant.grantId), false); + await repository.revoke(context, grant.grantId); + await repository.revoke(context, grant.grantId); + assert.equal(await repository.isRevoked(context, grant.grantId), true); + assert.equal(rows.length, 1); +}); From 0bb9391a0930189167f299e78ac995c4c8d61a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:11:34 +0700 Subject: [PATCH 058/177] fix(dsm): enforce dataset result input validation --- .../features/dsm/api/dataset-version.dto.ts | 13 ++++++-- .../dsm/dataset-version.controller.test.ts | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/services/api/src/features/dsm/api/dataset-version.dto.ts b/services/api/src/features/dsm/api/dataset-version.dto.ts index 4c6b80b5..06d95892 100644 --- a/services/api/src/features/dsm/api/dataset-version.dto.ts +++ b/services/api/src/features/dsm/api/dataset-version.dto.ts @@ -5,8 +5,11 @@ import { IsIn, IsInt, IsISO8601, + IsString, IsUUID, Max, + MaxLength, + Matches, Min, MinLength, } from 'class-validator'; @@ -35,15 +38,18 @@ export class RegisterDatasetVersionDto { ruleSetVersionId!: string; @ApiProperty({ minLength: 1, maxLength: 128 }) + @IsString() @MinLength(1) + @MaxLength(128) engineBuild!: string; - @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @ApiProperty({ format: 'uuid' }) @IsUUID() versionId!: string; @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) - @MinLength(64) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) contentFingerprint!: string; @ApiProperty({ minimum: 0 }) @@ -57,6 +63,7 @@ export class RegisterDatasetVersionDto { qualityState!: 'PASS' | 'PASS_WITH_WARNINGS' | 'BLOCKED' | 'INCOMPLETE'; @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) - @MinLength(64) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) lineageManifestHash!: string; } diff --git a/services/api/test/features/dsm/dataset-version.controller.test.ts b/services/api/test/features/dsm/dataset-version.controller.test.ts index fbaba935..bb054bb4 100644 --- a/services/api/test/features/dsm/dataset-version.controller.test.ts +++ b/services/api/test/features/dsm/dataset-version.controller.test.ts @@ -61,3 +61,35 @@ void test('[DSM-002, DSM-012, DSM-014] dataset result manifests are immutable an await app.close(); } }); + +void test('[DSM-002] dataset result DTO rejects malformed hashes and non-UUID version identities', async () => { + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(context()), + }; + const { app } = await createApiApplication({ + datasetVersionRepository: new InMemoryDatasetVersionRepositoryAdapter(), + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/dataset-versions', + payload: { + versionId: 'not-a-uuid', + datasetId, + inputArtifactVersionIds: [], + schemaVersionId: '00000000-0000-4000-8000-000000000808', + mappingVersionId: '00000000-0000-4000-8000-000000000809', + ruleSetVersionId: '00000000-0000-4000-8000-000000000810', + engineBuild: 'engine@1', + contentFingerprint: 'not-a-hash', + rowCount: 0, + qualityState: 'PASS', + lineageManifestHash: 'not-a-hash', + }, + }); + assert.equal(response.statusCode, 400); + } finally { + await app.close(); + } +}); From 11bb7b7dfba5e7e50b3c56eb174a5bd5f45e4a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:13:46 +0700 Subject: [PATCH 059/177] feat(dsm): list dataset result manifests --- ...mory-dataset-version-repository.adapter.ts | 20 ++++++++++++++++- ...isma-dataset-version-repository.adapter.ts | 22 +++++++++++++++++++ .../dsm/api/dataset-version.controller.ts | 14 +++++++++++- .../dataset-version-repository.port.ts | 4 ++++ .../application/dataset-version.service.ts | 9 ++++++++ .../dsm/dataset-version.controller.test.ts | 6 +++++ .../prisma-dataset-version-repository.test.ts | 12 ++++++++++ 7 files changed, 85 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts index 6517e8d1..de02d3fb 100644 --- a/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts +++ b/services/api/src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.ts @@ -44,6 +44,20 @@ export class InMemoryDatasetVersionRepositoryAdapter implements DatasetVersionRe : undefined; } + public async list( + context: IamTenantContextV1, + datasetId: DatasetVersionManifestV1['datasetId'], + ): Promise { + await Promise.resolve(); + return [...this.versions.values()] + .filter( + (version) => + version.datasetId === datasetId && visible(context.tenantScope, version.tenantScope), + ) + .sort((left, right) => left.versionId.localeCompare(right.versionId)) + .map(clone); + } + public async withTransaction( context: IamTenantContextV1, work: (transaction: DatasetVersionTransactionPortV1) => Promise, @@ -56,7 +70,11 @@ export class InMemoryDatasetVersionRepositoryAdapter implements DatasetVersionRe await previous; const before = new Map(this.versions); try { - return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + return await work({ + save: this.save.bind(this), + find: this.find.bind(this), + list: this.list.bind(this), + }); } catch (error) { this.versions = before; throw error; diff --git a/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts index 368ebbeb..55cb6c03 100644 --- a/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts +++ b/services/api/src/features/dsm/adapter/prisma-dataset-version-repository.adapter.ts @@ -47,6 +47,10 @@ export interface DatasetVersionDatabaseClientV1 { findUnique(input: { readonly where: { readonly id: string }; }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly id: 'asc' }; + }): Promise; }; $transaction( work: (transaction: DatasetVersionDatabaseClientV1) => Promise, @@ -143,6 +147,17 @@ class PrismaDatasetVersionTransactionAdapter implements DatasetVersionTransactio ? rowToDomain(row) : undefined; } + + public async list( + context: IamTenantContextV1, + datasetId: DatasetVersionManifestV1['datasetId'], + ): Promise { + const rows = await this.client.datasetVersionRecord.findMany({ + where: { datasetId, organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } } export class PrismaDatasetVersionRepositoryAdapter implements DatasetVersionRepositoryPortV1 { @@ -167,4 +182,11 @@ export class PrismaDatasetVersionRepositoryAdapter implements DatasetVersionRepo ): Promise { return new PrismaDatasetVersionTransactionAdapter(this.client).find(context, versionId); } + + public list( + context: IamTenantContextV1, + datasetId: DatasetVersionManifestV1['datasetId'], + ): Promise { + return new PrismaDatasetVersionTransactionAdapter(this.client).list(context, datasetId); + } } diff --git a/services/api/src/features/dsm/api/dataset-version.controller.ts b/services/api/src/features/dsm/api/dataset-version.controller.ts index 87ce6576..d26f2911 100644 --- a/services/api/src/features/dsm/api/dataset-version.controller.ts +++ b/services/api/src/features/dsm/api/dataset-version.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { Body, Controller, Get, Inject, Param, Post, Query, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; @@ -50,4 +50,16 @@ export class DatasetVersionController { if (!versionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; return this.versions.find(context, versionId.value); } + + @Get() + @ApiOperation({ summary: 'List exact dataset result manifests for one governed dataset' }) + async list( + @Req() request: unknown, + @Query('datasetId') datasetIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetId = parseStableIdentifierV1(datasetIdInput); + if (!datasetId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.versions.list(context, datasetId.value); + } } diff --git a/services/api/src/features/dsm/application/dataset-version-repository.port.ts b/services/api/src/features/dsm/application/dataset-version-repository.port.ts index 2acc2085..7cfbaf93 100644 --- a/services/api/src/features/dsm/application/dataset-version-repository.port.ts +++ b/services/api/src/features/dsm/application/dataset-version-repository.port.ts @@ -10,6 +10,10 @@ export interface DatasetVersionTransactionPortV1 { context: IamTenantContextV1, versionId: DatasetVersionManifestV1['versionId'], ): Promise; + list( + context: IamTenantContextV1, + datasetId: DatasetVersionManifestV1['datasetId'], + ): Promise; } export interface DatasetVersionRepositoryPortV1 extends DatasetVersionTransactionPortV1 { diff --git a/services/api/src/features/dsm/application/dataset-version.service.ts b/services/api/src/features/dsm/application/dataset-version.service.ts index e331f087..11239575 100644 --- a/services/api/src/features/dsm/application/dataset-version.service.ts +++ b/services/api/src/features/dsm/application/dataset-version.service.ts @@ -42,4 +42,13 @@ export class DatasetVersionService { ? Object.freeze({ accepted: true, value: found }) : Object.freeze({ accepted: false, code: 'VERSION_NOT_FOUND' as const }); } + + public async list( + context: IamTenantContextV1, + datasetId: DatasetVersionManifestV1['datasetId'], + ): Promise { + return this.repository.withTransaction(context, (transaction) => + transaction.list(context, datasetId), + ); + } } diff --git a/services/api/test/features/dsm/dataset-version.controller.test.ts b/services/api/test/features/dsm/dataset-version.controller.test.ts index bb054bb4..6608e52b 100644 --- a/services/api/test/features/dsm/dataset-version.controller.test.ts +++ b/services/api/test/features/dsm/dataset-version.controller.test.ts @@ -57,6 +57,12 @@ void test('[DSM-002, DSM-012, DSM-014] dataset result manifests are immutable an const read = await app.inject({ method: 'GET', url: `/v1/dataset-versions/${versionId}` }); assert.equal(read.statusCode, 200); assert.equal(read.json().value.contentFingerprint, 'a'.repeat(64)); + const listed = await app.inject({ + method: 'GET', + url: `/v1/dataset-versions?datasetId=${datasetId}`, + }); + assert.equal(listed.statusCode, 200); + assert.equal(listed.json().length, 1); } finally { await app.close(); } diff --git a/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts b/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts index ecf4cbdf..89fb7262 100644 --- a/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts +++ b/services/api/test/features/dsm/prisma-dataset-version-repository.test.ts @@ -48,6 +48,17 @@ function client(rows: DatasetVersionDatabaseRowV1[]): DatasetVersionDatabaseClie findUnique({ where }) { return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); }, + findMany({ where }) { + return Promise.resolve( + rows + .filter( + (row) => + row.datasetId === where['datasetId'] && + row.organizationId === where['organizationId'], + ) + .sort((left, right) => left.id.localeCompare(right.id)), + ); + }, }, $transaction(work) { return work(this); @@ -78,5 +89,6 @@ void test('[DSM-002, DSM-003, IAM-009] Prisma dataset version adapter is immutab await repository.save(tenantContext, created.value); await repository.save(tenantContext, created.value); assert.deepEqual(await repository.find(tenantContext, versionId), created.value); + assert.deepEqual(await repository.list(tenantContext, created.value.datasetId), [created.value]); assert.equal(rows.length, 1); }); From 7da9117e7691bdbe78dde485918ff1c9ef11a85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:15:47 +0700 Subject: [PATCH 060/177] feat(iae): persist artifact admission status --- .../in-memory-artifact-repository.adapter.ts | 20 +++++++++++ .../prisma-artifact-repository.adapter.ts | 36 +++++++++++++++++++ .../application/artifact-repository.port.ts | 5 +++ .../iae/prisma-artifact-repository.test.ts | 13 +++++++ 4 files changed, 74 insertions(+) diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts index e3cc3d84..1315c91a 100644 --- a/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts @@ -64,6 +64,25 @@ export class InMemoryArtifactRepositoryAdapter implements ArtifactRepositoryPort : undefined; } + async updateVersionStatus( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + status: ArtifactVersionV1['status'], + ): Promise { + await Promise.resolve(); + const current = this.versions.get(versionId); + if (!current || !visibleInScope(context.tenantScope, current.tenantScope)) return undefined; + if (!scopeAllowsMutation(context, current.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + if (!['QUARANTINED', 'ACTIVE', 'DELETED'].includes(status)) + throw new Error('IAE_INVALID_STATUS'); + if (current.status === 'DELETED' && status !== 'DELETED') + throw new Error('IAE_TERMINAL_STATUS'); + const next = cloneVersion({ ...current, status }); + this.versions.set(versionId, next); + return next; + } + async savePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise { await Promise.resolve(); if (!scopeAllowsMutation(context, placement.tenantScope)) @@ -152,6 +171,7 @@ export class InMemoryArtifactRepositoryAdapter implements ArtifactRepositoryPort return await work({ saveVersion: this.saveVersion.bind(this), findVersion: this.findVersion.bind(this), + updateVersionStatus: this.updateVersionStatus.bind(this), savePlacement: this.savePlacement.bind(this), updatePlacement: this.updatePlacement.bind(this), listPlacements: this.listPlacements.bind(this), diff --git a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts index e87e9cd9..b0ff651e 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts @@ -82,6 +82,10 @@ export interface ArtifactDatabaseClientV1 { findUnique(input: { readonly where: { readonly id: string }; }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { readonly status: string }; + }): Promise; }; readonly contentPlacement: { create(input: { @@ -235,6 +239,27 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { : undefined; } + public async updateVersionStatus( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + status: ArtifactVersionV1['status'], + ): Promise { + const row = await this.client.artifactVersion.findUnique({ where: { id: versionId } }); + if (row === null || !visible(context.tenantScope, row)) return undefined; + if (!tenantScopeContainsV1(context.tenantScope, rowScope(row))) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const current = rowToVersion(row); + if (!['QUARANTINED', 'ACTIVE', 'DELETED'].includes(status)) + throw new Error('IAE_INVALID_STATUS'); + if (current.status === 'DELETED' && status !== 'DELETED') + throw new Error('IAE_TERMINAL_STATUS'); + const updated = await this.client.artifactVersion.update({ + where: { id: versionId }, + data: { status }, + }); + return rowToVersion(updated); + } + public async savePlacement( context: IamTenantContextV1, placement: ContentPlacementV1, @@ -372,6 +397,17 @@ export class PrismaArtifactRepositoryAdapter implements ArtifactRepositoryPortV1 ): Promise { return new PrismaArtifactTransactionAdapter(this.client).findVersion(context, versionId); } + public updateVersionStatus( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + status: ArtifactVersionV1['status'], + ): Promise { + return new PrismaArtifactTransactionAdapter(this.client).updateVersionStatus( + context, + versionId, + status, + ); + } public savePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise { return new PrismaArtifactTransactionAdapter(this.client).savePlacement(context, placement); } diff --git a/services/api/src/features/iae/application/artifact-repository.port.ts b/services/api/src/features/iae/application/artifact-repository.port.ts index 0db52f18..4a2e768c 100644 --- a/services/api/src/features/iae/application/artifact-repository.port.ts +++ b/services/api/src/features/iae/application/artifact-repository.port.ts @@ -14,6 +14,11 @@ export interface ArtifactTransactionPortV1 { context: IamTenantContextV1, versionId: ArtifactVersionV1['versionId'], ): Promise; + updateVersionStatus( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + status: ArtifactVersionV1['status'], + ): Promise; savePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise; updatePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise; listPlacements( diff --git a/services/api/test/features/iae/prisma-artifact-repository.test.ts b/services/api/test/features/iae/prisma-artifact-repository.test.ts index 95fddcc9..0f4ec5f6 100644 --- a/services/api/test/features/iae/prisma-artifact-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-repository.test.ts @@ -63,6 +63,13 @@ function client( versions.find((candidate) => candidate.id === input.where.id) ?? null, ); }, + update(input) { + const current = versions.find((candidate) => candidate.id === input.where.id); + if (!current) throw new Error('fixture version not found'); + const next = { ...current, ...input.data }; + versions[versions.indexOf(current)] = next; + return Promise.resolve(next); + }, }, contentPlacement: { create(input) { @@ -149,6 +156,12 @@ void test('[IAE-003, IAE-004, IAE-005, IAM-009] Prisma artifact adapter keeps pl const evidence: EvidenceDatabaseRowV1[] = []; const repository = new PrismaArtifactRepositoryAdapter(client([], placements, evidence)); await repository.saveVersion(context('version'), artifact.value); + const quarantined = await repository.updateVersionStatus( + context('quarantine'), + versionId, + 'QUARANTINED', + ); + assert.equal(quarantined?.status, 'QUARANTINED'); await repository.savePlacement(context('placement'), placement.value); await repository.savePlacement(context('placement-repeat'), placement.value); await repository.saveEvidence(context('evidence'), evidenceRef.value); From ad9857bccd9c74bfaa35925c97f3d544910c6f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:16:45 +0700 Subject: [PATCH 061/177] feat(iae): coordinate artifact admission --- .../application/artifact-admission.service.ts | 49 +++++++++++++++ .../iae/artifact-admission.service.test.ts | 60 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 services/api/src/features/iae/application/artifact-admission.service.ts create mode 100644 services/api/test/features/iae/artifact-admission.service.test.ts diff --git a/services/api/src/features/iae/application/artifact-admission.service.ts b/services/api/src/features/iae/application/artifact-admission.service.ts new file mode 100644 index 00000000..34219e8a --- /dev/null +++ b/services/api/src/features/iae/application/artifact-admission.service.ts @@ -0,0 +1,49 @@ +import { + finalizeArtifactAdmissionV1, + type ArtifactIntakeResultV1, + type ArtifactScanStateV1, +} from '@databreeze/domain/artifact-intake/v1'; +import type { ArtifactVersionV1 } from '@databreeze/domain/artifact/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { ArtifactRepositoryPortV1 } from './artifact-repository.port.js'; + +export type ArtifactAdmissionServiceErrorV1 = 'ARTIFACT_NOT_FOUND' | 'ADMISSION_UPDATE_FAILED'; +export type ArtifactAdmissionServiceResultV1 = + | ArtifactIntakeResultV1 + | { readonly accepted: false; readonly code: ArtifactAdmissionServiceErrorV1 }; + +/** IAE-009/010: validates scanner output, then records only the governed status projection. */ +export class ArtifactAdmissionService { + public constructor(private readonly repository: ArtifactRepositoryPortV1) {} + + public async admit( + context: IamTenantContextV1, + versionId: ArtifactVersionV1['versionId'], + input: Omit[0], 'artifact'>, + ): Promise< + ArtifactAdmissionServiceResultV1<{ + readonly version: ArtifactVersionV1; + readonly status: 'ACTIVE' | 'QUARANTINED'; + readonly scanState: ArtifactScanStateV1; + }> + > { + return this.repository.withTransaction(context, async (transaction) => { + const artifact = await transaction.findVersion(context, versionId); + if (!artifact) return Object.freeze({ accepted: false, code: 'ARTIFACT_NOT_FOUND' as const }); + const admission = finalizeArtifactAdmissionV1({ artifact, ...input }); + if (!admission.accepted) return admission; + const updated = await transaction.updateVersionStatus( + context, + versionId, + admission.value.status, + ); + if (!updated) + return Object.freeze({ accepted: false, code: 'ADMISSION_UPDATE_FAILED' as const }); + return Object.freeze({ + accepted: true, + value: Object.freeze({ version: updated, ...admission.value }), + }); + }); + } +} diff --git a/services/api/test/features/iae/artifact-admission.service.test.ts b/services/api/test/features/iae/artifact-admission.service.test.ts new file mode 100644 index 00000000..70d96704 --- /dev/null +++ b/services/api/test/features/iae/artifact-admission.service.test.ts @@ -0,0 +1,60 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createArtifactVersionV1 } from '@databreeze/domain/artifact/v1'; +import { ArtifactAdmissionService } from '../../../src/features/iae/application/artifact-admission.service.js'; +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'admission-service', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +void test('IAE-009/010 admission updates only the status projection after scanner checks', async () => { + const repository = new InMemoryArtifactRepositoryAdapter(); + const service = new ArtifactAdmissionService(repository); + const artifact = createArtifactVersionV1({ + artifactId: '55555555-5555-4555-8555-555555555555', + versionId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'a'.repeat(64), + byteSize: 4, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: '2026-08-02T00:00:00.000Z', + status: 'QUARANTINED', + }); + assert.equal(artifact.accepted, true); + if (!artifact.accepted) return; + await repository.saveVersion(context, artifact.value); + const admitted = await service.admit(context, artifact.value.versionId, { + actualSha256: 'a'.repeat(64), + actualByteSize: 4, + detectedMediaType: 'text/csv', + scanState: 'CLEAN', + maxByteSize: 100, + }); + assert.equal(admitted.accepted, true); + if (!admitted.accepted) return; + assert.equal(admitted.value.version.status, 'ACTIVE'); + const rejected = await service.admit(context, artifact.value.versionId, { + actualSha256: 'b'.repeat(64), + actualByteSize: 4, + detectedMediaType: 'text/csv', + scanState: 'CLEAN', + maxByteSize: 100, + }); + assert.deepEqual(rejected, { accepted: false, code: 'DIGEST_MISMATCH' }); +}); From f54d096c97f938d1d6f430470cd9ea6d18a1c603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:19:38 +0700 Subject: [PATCH 062/177] feat(iae): expose artifact admission endpoint --- .../iae/api/artifact-admission.controller.ts | 45 +++++++++++++ .../iae/api/artifact-admission.dto.ts | 40 +++++++++++ services/api/src/features/iae/iae.module.ts | 2 + .../iae/artifact-admission.controller.test.ts | 67 +++++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 services/api/src/features/iae/api/artifact-admission.controller.ts create mode 100644 services/api/src/features/iae/api/artifact-admission.dto.ts create mode 100644 services/api/test/features/iae/artifact-admission.controller.test.ts diff --git a/services/api/src/features/iae/api/artifact-admission.controller.ts b/services/api/src/features/iae/api/artifact-admission.controller.ts new file mode 100644 index 00000000..c25cf45e --- /dev/null +++ b/services/api/src/features/iae/api/artifact-admission.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + ARTIFACT_REPOSITORY_PORT, + type ArtifactRepositoryPortV1, +} from '../application/artifact-repository.port.js'; +import { ArtifactAdmissionService } from '../application/artifact-admission.service.js'; +import { AdmitArtifactDto } from './artifact-admission.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +/** IAE-009/010: admission accepts verifier metadata, never source bytes or executable content. */ +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/artifact-versions') +export class ArtifactAdmissionController { + private readonly admission: ArtifactAdmissionService; + + public constructor( + @Inject(ARTIFACT_REPOSITORY_PORT) repository: ArtifactRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.admission = new ArtifactAdmissionService(repository); + } + + @Post(':versionId/admit') + @ApiOperation({ + summary: 'Admit an exact artifact version after digest, media, size, and scan checks', + }) + @ApiBody({ type: AdmitArtifactDto }) + async admit( + @Req() request: unknown, + @Param('versionId') versionIdInput: string, + @Body() input: AdmitArtifactDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + if (!versionId.accepted) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + return this.admission.admit(context, versionId.value, input); + } +} diff --git a/services/api/src/features/iae/api/artifact-admission.dto.ts b/services/api/src/features/iae/api/artifact-admission.dto.ts new file mode 100644 index 00000000..c79647e2 --- /dev/null +++ b/services/api/src/features/iae/api/artifact-admission.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsISO8601, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + Min, + Matches, +} from 'class-validator'; + +export class AdmitArtifactDto { + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @Matches(/^[0-9a-f]{64}$/u) + actualSha256!: string; + + @ApiProperty({ minimum: 0 }) + @IsNumber() + @Min(0) + actualByteSize!: number; + + @ApiProperty() + @IsString() + detectedMediaType!: string; + + @ApiProperty({ enum: ['PENDING', 'CLEAN', 'MALICIOUS', 'FAILED'] }) + @IsIn(['PENDING', 'CLEAN', 'MALICIOUS', 'FAILED']) + scanState!: 'PENDING' | 'CLEAN' | 'MALICIOUS' | 'FAILED'; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + maxByteSize!: number; + + @ApiProperty({ format: 'date-time', required: false }) + @IsOptional() + @IsISO8601() + scannedAt?: string; +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 7f27322a..54a640ac 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -8,6 +8,7 @@ import { ContentPlacementController } from './api/content-placement.controller.j import { ArtifactRetentionController } from './api/artifact-retention.controller.js'; import { ArtifactExportController } from './api/artifact-export.controller.js'; import { ArtifactUploadController } from './api/artifact-upload.controller.js'; +import { ArtifactAdmissionController } from './api/artifact-admission.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -116,6 +117,7 @@ export class IaeModule { ArtifactRetentionController, ArtifactExportController, ArtifactUploadController, + ArtifactAdmissionController, ], providers: [ { diff --git a/services/api/test/features/iae/artifact-admission.controller.test.ts b/services/api/test/features/iae/artifact-admission.controller.test.ts new file mode 100644 index 00000000..197a7954 --- /dev/null +++ b/services/api/test/features/iae/artifact-admission.controller.test.ts @@ -0,0 +1,67 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { createArtifactVersionV1 } from '@databreeze/domain/artifact/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'admit-http', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const tenantContext = contextResult.value; + +void test('IAE-009/010 admission HTTP endpoint persists clean status without source content', async () => { + const repository = new InMemoryArtifactRepositoryAdapter(); + const artifact = createArtifactVersionV1({ + artifactId: '55555555-5555-4555-8555-555555555555', + versionId: '66666666-6666-4666-8666-666666666666', + tenantScope: tenantContext.tenantScope, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'a'.repeat(64), + byteSize: 4, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: '2026-08-02T00:00:00.000Z', + status: 'QUARANTINED', + }); + assert.equal(artifact.accepted, true); + if (!artifact.accepted) return; + await repository.saveVersion(tenantContext, artifact.value); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + artifactRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: `/v1/artifact-versions/${artifact.value.versionId}/admit`, + payload: { + actualSha256: 'a'.repeat(64), + actualByteSize: 4, + detectedMediaType: 'text/csv', + scanState: 'CLEAN', + maxByteSize: 100, + }, + }); + assert.equal(response.statusCode, 201); + assert.equal(response.json().value.version.status, 'ACTIVE'); + assert.doesNotMatch(response.body, /sourcePath|rawBytes|excerpt/iu); + } finally { + await app.close(); + } +}); From a860c915d4bedecb31f3d1e816fedbd4ef929d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:20:29 +0700 Subject: [PATCH 063/177] fix(iae): normalize retention adapter formatting --- .../in-memory-artifact-retention-repository.adapter.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts index 73624c5e..ab9cee7c 100644 --- a/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.ts @@ -1,7 +1,4 @@ -import { - tenantScopeContainsV1, - type TenantScopeV1, -} from '@databreeze/domain/tenant-scope/v1'; +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; import type { ArtifactDeletionRequestV1 } from '@databreeze/domain/artifact-retention/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; @@ -55,7 +52,9 @@ export class InMemoryArtifactRetentionRepositoryAdapter ): Promise { await Promise.resolve(); const request = this.requests.get(requestId); - return request && visible(context.tenantScope, request.tenantScope) ? clone(request) : undefined; + return request && visible(context.tenantScope, request.tenantScope) + ? clone(request) + : undefined; } public async withTransaction( From f496647771135f8fc6af31c5202e8f58b00c643a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:26:59 +0700 Subject: [PATCH 064/177] fix(test): satisfy strict API lint gates --- packages/domain/src/artifact-upload/v1.ts | 5 ---- .../features/dsm/api/dataset-version.dto.ts | 1 - .../application/content-placement.service.ts | 5 +--- .../dsm/dataset-version.controller.test.ts | 13 +++++++--- .../dsm/governed-dataset.controller.test.ts | 8 ++++-- .../features/dsm/mapping.controller.test.ts | 3 ++- .../dsm/reference-entity.controller.test.ts | 3 ++- .../features/dsm/rule-set.controller.test.ts | 3 ++- .../iae/artifact-admission.controller.test.ts | 5 +++- .../iae/artifact-lineage.controller.test.ts | 8 ++++-- .../iae/artifact-read.controller.test.ts | 13 ++++++++-- .../prisma-artifact-export-repository.test.ts | 26 +++++++++++-------- 12 files changed, 59 insertions(+), 34 deletions(-) diff --git a/packages/domain/src/artifact-upload/v1.ts b/packages/domain/src/artifact-upload/v1.ts index 9f719329..b1e37cc2 100644 --- a/packages/domain/src/artifact-upload/v1.ts +++ b/packages/domain/src/artifact-upload/v1.ts @@ -89,10 +89,6 @@ function positiveInteger(input: unknown): number | undefined { return typeof input === 'number' && Number.isSafeInteger(input) && input > 0 ? input : undefined; } -function revision(input: unknown): number | undefined { - return positiveInteger(input); -} - function validPart(part: unknown, totalParts: number): part is ArtifactUploadPartV1 { if (typeof part !== 'object' || part === null || Array.isArray(part)) return false; const record = part as Record; @@ -124,7 +120,6 @@ export function createArtifactUploadSessionV1(input: { const artifactId = identifier(input.artifactId); const tenantScope = parseTenantScopeV1(input.tenantScope); const expectedSha256 = hash(input.expectedSha256); - const expectedByteSize = positiveInteger(input.expectedByteSize ?? 0); const partSize = positiveInteger(input.partSize); const mediaTypeValue = mediaType(input.mediaType); const createdAt = timestamp(input.createdAt); diff --git a/services/api/src/features/dsm/api/dataset-version.dto.ts b/services/api/src/features/dsm/api/dataset-version.dto.ts index 06d95892..ba6b0cc5 100644 --- a/services/api/src/features/dsm/api/dataset-version.dto.ts +++ b/services/api/src/features/dsm/api/dataset-version.dto.ts @@ -4,7 +4,6 @@ import { IsArray, IsIn, IsInt, - IsISO8601, IsString, IsUUID, Max, diff --git a/services/api/src/features/iae/application/content-placement.service.ts b/services/api/src/features/iae/application/content-placement.service.ts index 4ad9c0f3..1701cb85 100644 --- a/services/api/src/features/iae/application/content-placement.service.ts +++ b/services/api/src/features/iae/application/content-placement.service.ts @@ -1,7 +1,4 @@ -import { - parseStableIdentifierV1, - type StableIdentifierV1, -} from '@databreeze/domain/tenant-scope/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import { updateContentPlacementAvailabilityV1, type ArtifactResultV1, diff --git a/services/api/test/features/dsm/dataset-version.controller.test.ts b/services/api/test/features/dsm/dataset-version.controller.test.ts index 6608e52b..0cc84061 100644 --- a/services/api/test/features/dsm/dataset-version.controller.test.ts +++ b/services/api/test/features/dsm/dataset-version.controller.test.ts @@ -53,16 +53,23 @@ void test('[DSM-002, DSM-012, DSM-014] dataset result manifests are immutable an }, }); assert.equal(response.statusCode, 201); - assert.equal(response.json().value.rowCount, 42); + const createdBody = JSON.parse(response.body) as { + readonly value: { readonly rowCount: number }; + }; + assert.equal(createdBody.value.rowCount, 42); const read = await app.inject({ method: 'GET', url: `/v1/dataset-versions/${versionId}` }); assert.equal(read.statusCode, 200); - assert.equal(read.json().value.contentFingerprint, 'a'.repeat(64)); + const readBody = JSON.parse(read.body) as { + readonly value: { readonly contentFingerprint: string }; + }; + assert.equal(readBody.value.contentFingerprint, 'a'.repeat(64)); const listed = await app.inject({ method: 'GET', url: `/v1/dataset-versions?datasetId=${datasetId}`, }); assert.equal(listed.statusCode, 200); - assert.equal(listed.json().length, 1); + const listedBody = JSON.parse(listed.body) as readonly unknown[]; + assert.equal(listedBody.length, 1); } finally { await app.close(); } diff --git a/services/api/test/features/dsm/governed-dataset.controller.test.ts b/services/api/test/features/dsm/governed-dataset.controller.test.ts index f0cea603..985028ce 100644 --- a/services/api/test/features/dsm/governed-dataset.controller.test.ts +++ b/services/api/test/features/dsm/governed-dataset.controller.test.ts @@ -64,13 +64,17 @@ void test('[DSM-005, DSM-006, DSM-018, DSM-021] governed dataset HTTP surfaces p }, }); assert.equal(published.statusCode, 200); - assert.equal(published.json().value.status, 'PUBLISHED'); + const publishedBody = JSON.parse(published.body) as { + readonly value: { readonly status: string }; + }; + assert.equal(publishedBody.value.status, 'PUBLISHED'); const read = await app.inject({ method: 'GET', url: `/v1/datasets/${datasetId}/versions/${publishedVersionId}`, }); assert.equal(read.statusCode, 200); - assert.equal(read.json().value.versionId, publishedVersionId); + const readBody = JSON.parse(read.body) as { readonly value: { readonly versionId: string } }; + assert.equal(readBody.value.versionId, publishedVersionId); const comparison = await app.inject({ method: 'GET', url: `/v1/datasets/${datasetId}/compatibility?previousVersionId=${versionId}&nextVersionId=${publishedVersionId}`, diff --git a/services/api/test/features/dsm/mapping.controller.test.ts b/services/api/test/features/dsm/mapping.controller.test.ts index 5646ba24..9aee3882 100644 --- a/services/api/test/features/dsm/mapping.controller.test.ts +++ b/services/api/test/features/dsm/mapping.controller.test.ts @@ -61,7 +61,8 @@ void test('[DSM-009, DSM-010, DSM-021] mapping publication is exposed as an immu payload: { nextVersionId, publishedAt: '2026-01-01T00:01:00.000Z' }, }); assert.equal(response.statusCode, 200); - assert.equal(response.json().value.status, 'PUBLISHED'); + const body = JSON.parse(response.body) as { readonly value: { readonly status: string } }; + assert.equal(body.value.status, 'PUBLISHED'); } finally { await app.close(); } diff --git a/services/api/test/features/dsm/reference-entity.controller.test.ts b/services/api/test/features/dsm/reference-entity.controller.test.ts index 9748a4e7..f006a692 100644 --- a/services/api/test/features/dsm/reference-entity.controller.test.ts +++ b/services/api/test/features/dsm/reference-entity.controller.test.ts @@ -54,7 +54,8 @@ void test('[DSM-025, DSM-026, DSM-027] reference entity API exposes exact versio url: `/v1/reference-entities/${entityId}/versions/${versionId}`, }); assert.equal(response.statusCode, 200); - assert.equal(response.json().value.displayName, 'Công ty Ánh Dương'); + const body = JSON.parse(response.body) as { readonly value: { readonly displayName: string } }; + assert.equal(body.value.displayName, 'Công ty Ánh Dương'); const resolutions = await app.inject({ method: 'GET', url: `/v1/reference-entities/${entityId}/resolutions`, diff --git a/services/api/test/features/dsm/rule-set.controller.test.ts b/services/api/test/features/dsm/rule-set.controller.test.ts index b0918f65..dba7c87a 100644 --- a/services/api/test/features/dsm/rule-set.controller.test.ts +++ b/services/api/test/features/dsm/rule-set.controller.test.ts @@ -62,7 +62,8 @@ void test('[DSM-008, DSM-010, DSM-021] rule-set publication preserves typed dete payload: { nextVersionId, publishedAt: '2026-01-01T00:01:00.000Z' }, }); assert.equal(response.statusCode, 200); - assert.equal(response.json().value.status, 'PUBLISHED'); + const body = JSON.parse(response.body) as { readonly value: { readonly status: string } }; + assert.equal(body.value.status, 'PUBLISHED'); } finally { await app.close(); } diff --git a/services/api/test/features/iae/artifact-admission.controller.test.ts b/services/api/test/features/iae/artifact-admission.controller.test.ts index 197a7954..6dae83c3 100644 --- a/services/api/test/features/iae/artifact-admission.controller.test.ts +++ b/services/api/test/features/iae/artifact-admission.controller.test.ts @@ -59,7 +59,10 @@ void test('IAE-009/010 admission HTTP endpoint persists clean status without sou }, }); assert.equal(response.statusCode, 201); - assert.equal(response.json().value.version.status, 'ACTIVE'); + const body = JSON.parse(response.body) as { + readonly value: { readonly version: { readonly status: string } }; + }; + assert.equal(body.value.version.status, 'ACTIVE'); assert.doesNotMatch(response.body, /sourcePath|rawBytes|excerpt/iu); } finally { await app.close(); diff --git a/services/api/test/features/iae/artifact-lineage.controller.test.ts b/services/api/test/features/iae/artifact-lineage.controller.test.ts index 6219ae49..1cfbd03d 100644 --- a/services/api/test/features/iae/artifact-lineage.controller.test.ts +++ b/services/api/test/features/iae/artifact-lineage.controller.test.ts @@ -53,14 +53,18 @@ void test('[IAE-007] lineage endpoints resolve exact derived and source versions url: `/v1/artifact-versions/${derivedVersionId}/lineage`, }); assert.equal(derived.statusCode, 200); - assert.equal(derived.json().value.derivedArtifactVersionId, derivedVersionId); + const derivedBody = JSON.parse(derived.body) as { + readonly value: { readonly derivedArtifactVersionId: string }; + }; + assert.equal(derivedBody.value.derivedArtifactVersionId, derivedVersionId); const source = await app.inject({ method: 'GET', url: `/v1/artifact-versions/${sourceVersionId}/derived-lineage`, }); assert.equal(source.statusCode, 200); - assert.equal(source.json().value.length, 1); + const sourceBody = JSON.parse(source.body) as { readonly value: readonly unknown[] }; + assert.equal(sourceBody.value.length, 1); } finally { await app.close(); } diff --git a/services/api/test/features/iae/artifact-read.controller.test.ts b/services/api/test/features/iae/artifact-read.controller.test.ts index 5b0f6904..5850915b 100644 --- a/services/api/test/features/iae/artifact-read.controller.test.ts +++ b/services/api/test/features/iae/artifact-read.controller.test.ts @@ -70,7 +70,13 @@ void test('[IAE-006, IAE-008, IAE-019, IAE-020] artifact reads return exact cont try { const response = await app.inject({ method: 'GET', url: `/v1/artifact-versions/${versionId}` }); assert.equal(response.statusCode, 200); - const body = response.json(); + const body = JSON.parse(response.body) as { + readonly accepted: boolean; + readonly value: { + readonly version: { readonly versionId: string }; + readonly placements: readonly [{ readonly opaqueReference: string }]; + }; + }; assert.equal(body.accepted, true); assert.equal(body.value.version.versionId, versionId); assert.equal(body.value.placements[0].opaqueReference, 'local-placement-000001'); @@ -81,7 +87,10 @@ void test('[IAE-006, IAE-008, IAE-019, IAE-020] artifact reads return exact cont url: `/v1/artifact-versions/${versionId}/evidence`, }); assert.equal(evidenceResponse.statusCode, 200); - assert.deepEqual(evidenceResponse.json().value[0].coordinate, { + const evidenceBody = JSON.parse(evidenceResponse.body) as { + readonly value: readonly [{ readonly coordinate: Record }]; + }; + assert.deepEqual(evidenceBody.value[0].coordinate, { kind: 'ROW', row: 1, field: 'amount', diff --git a/services/api/test/features/iae/prisma-artifact-export-repository.test.ts b/services/api/test/features/iae/prisma-artifact-export-repository.test.ts index a3d71b97..10482b69 100644 --- a/services/api/test/features/iae/prisma-artifact-export-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-export-repository.test.ts @@ -5,7 +5,11 @@ import { createArtifactExportManifestV1 } from '@databreeze/domain/artifact-expo import { parseTenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; -import { PrismaArtifactExportRepositoryAdapter } from '../../../src/features/iae/adapter/prisma-artifact-export-repository.adapter.js'; +import { + PrismaArtifactExportRepositoryAdapter, + type ArtifactExportDatabaseClientV1, + type ArtifactExportDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-artifact-export-repository.adapter.js'; const organizationId = '11111111-1111-4111-8111-111111111111'; const workspaceId = '22222222-2222-4222-8222-222222222222'; @@ -43,23 +47,23 @@ const manifest = createArtifactExportManifestV1({ }); if (!manifest.accepted) throw new Error('fixture manifest invalid'); -test('IAE-018 Prisma export adapter preserves immutable manifests and scopes reads', async () => { - const rows = new Map(); - const client = { +void test('IAE-018 Prisma export adapter preserves immutable manifests and scopes reads', async () => { + const rows = new Map(); + const client: ArtifactExportDatabaseClientV1 = { artifactExportManifestRecord: { - async create({ data }: any) { + create({ data }) { const row = { ...data }; rows.set(row.id, row); - return row; + return Promise.resolve(row); }, - async findUnique({ where }: any) { - return rows.get(where.id) ?? null; + findUnique({ where }) { + return Promise.resolve(rows.get(where.id) ?? null); }, }, - async $transaction(work: any) { - return work(this); + $transaction(work) { + return work(client); }, - } as any; + }; const repository = new PrismaArtifactExportRepositoryAdapter(client); await repository.save(context, manifest.value); await repository.save(context, manifest.value); From 356a4ff9f8f3cfb25fa873d50a4b53c5bd1bf7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:35:54 +0700 Subject: [PATCH 065/177] chore(api): refresh generated OpenAPI and migration inventory --- services/api/openapi/v1.json | 2163 +++++++++++++++++- services/api/test/openapi.test.ts | 24 + services/api/test/prisma-foundation.test.mjs | 2 + 3 files changed, 2123 insertions(+), 66 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 69c9e6eb..06401995 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -1675,10 +1675,1852 @@ "tags": ["artifacts"] } }, + "/v1/artifact-versions/{versionId}": { + "get": { + "operationId": "ArtifactReadController.get", + "parameters": [ + { "name": "versionId", "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": "Read immutable artifact-version metadata and placements", + "tags": ["artifacts"] + } + }, + "/v1/artifact-versions/{versionId}/evidence": { + "get": { + "operationId": "ArtifactReadController.evidence", + "parameters": [ + { "name": "versionId", "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 typed evidence references for one immutable version", + "tags": ["artifacts"] + } + }, + "/v1/artifact-versions/{versionId}/lineage": { + "get": { + "operationId": "ArtifactLineageController.forDerived", + "parameters": [ + { "name": "versionId", "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": "Read lineage for an exact derived artifact version", + "tags": ["artifacts"] + } + }, + "/v1/artifact-versions/{versionId}/derived-lineage": { + "get": { + "operationId": "ArtifactLineageController.forSource", + "parameters": [ + { "name": "versionId", "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 derived versions that use an exact source version", + "tags": ["artifacts"] + } + }, + "/v1/artifact-versions/{versionId}/placements/{placementId}": { + "patch": { + "operationId": "ContentPlacementController.update", + "parameters": [ + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "placementId", "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/UpdateContentPlacementDto" } + } + } + }, + "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": "Update verified placement availability with a revision precondition", + "tags": ["artifacts"] + } + }, + "/v1/artifact-versions/{versionId}/deletion-requests": { + "post": { + "operationId": "ArtifactRetentionController.request", + "parameters": [ + { "name": "versionId", "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/CreateArtifactDeletionRequestDto" } + } + } + }, + "responses": { + "201": { + "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": "Request governed deletion of an exact artifact version", + "tags": ["artifacts"] + } + }, + "/v1/artifact-deletion-requests/{requestId}/authorize": { + "post": { + "operationId": "ArtifactRetentionController.authorize", + "parameters": [ + { "name": "requestId", "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/AuthorizeArtifactDeletionRequestDto" } + } + } + }, + "responses": { + "201": { + "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": "Authorize an eligible deletion request after MFA step-up", + "tags": ["artifacts"] + } + }, + "/v1/artifacts/exports": { + "post": { + "operationId": "ArtifactExportController.create", + "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/CreateArtifactExportDto" } + } + } + }, + "responses": { + "201": { + "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": "Create an immutable artifact verification manifest", + "tags": ["artifacts"] + } + }, + "/v1/artifacts/exports/{manifestId}": { + "get": { + "operationId": "ArtifactExportController.get", + "parameters": [ + { "name": "manifestId", "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": "Read an immutable artifact verification manifest", + "tags": ["artifacts"] + } + }, + "/v1/artifact-upload-sessions": { + "post": { + "operationId": "ArtifactUploadController.create", + "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/CreateArtifactUploadSessionDto" } + } + } + }, + "responses": { + "201": { + "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": "Create a bounded resumable artifact upload session", + "tags": ["artifacts"] + } + }, + "/v1/artifact-upload-sessions/{sessionId}": { + "get": { + "operationId": "ArtifactUploadController.find", + "parameters": [ + { "name": "sessionId", "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": "Read upload session metadata and completed part digests", + "tags": ["artifacts"] + } + }, + "/v1/artifact-upload-sessions/{sessionId}/parts": { + "post": { + "operationId": "ArtifactUploadController.part", + "parameters": [ + { "name": "sessionId", "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/RecordArtifactUploadPartDto" } + } + } + }, + "responses": { + "201": { + "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": "Record one verified upload part digest", + "tags": ["artifacts"] + } + }, + "/v1/artifact-upload-sessions/{sessionId}/complete": { + "post": { + "operationId": "ArtifactUploadController.complete", + "parameters": [ + { "name": "sessionId", "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/CompleteArtifactUploadDto" } + } + } + }, + "responses": { + "201": { + "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": "Finalize an upload after all part digests and the assembled hash match", + "tags": ["artifacts"] + } + }, + "/v1/artifact-upload-sessions/{sessionId}/abort": { + "post": { + "operationId": "ArtifactUploadController.abort", + "parameters": [ + { "name": "sessionId", "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/AbortArtifactUploadDto" } + } + } + }, + "responses": { + "201": { + "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": "Abort an open upload session", + "tags": ["artifacts"] + } + }, + "/v1/artifact-versions/{versionId}/admit": { + "post": { + "operationId": "ArtifactAdmissionController.admit", + "parameters": [ + { "name": "versionId", "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/AdmitArtifactDto" } } + } + }, + "responses": { + "201": { + "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": "Admit an exact artifact version after digest, media, size, and scan checks", + "tags": ["artifacts"] + } + }, "/v1/datasets": { "post": { - "operationId": "GovernedDatasetController.create", + "operationId": "GovernedDatasetController.create", + "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/CreateGovernedDatasetDto" } + } + } + }, + "responses": { + "201": { + "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": "Create an immutable governed dataset definition draft", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/versions": { + "get": { + "operationId": "GovernedDatasetController.list", + "parameters": [ + { "name": "datasetId", "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 governed dataset versions visible to the caller", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/versions/{versionId}": { + "get": { + "operationId": "GovernedDatasetController.getVersion", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "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": "Read one exact immutable governed dataset definition", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/versions/{versionId}/publish": { + "post": { + "operationId": "GovernedDatasetController.publish", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "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/PublishGovernedDatasetDto" } + } + } + }, + "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": "Publish a governed dataset definition as a new immutable version", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/compatibility": { + "get": { + "operationId": "GovernedDatasetController.compare", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "previousVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, + { + "name": "nextVersionId", + "required": true, + "in": "query", + "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": "Classify compatibility between two exact schema versions", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/mappings": { + "post": { + "operationId": "MappingController.create", + "parameters": [ + { "name": "datasetId", "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/CreateMappingDto" } } + } + }, + "responses": { + "201": { + "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": "Create an immutable mapping definition draft", + "tags": ["datasets"] + }, + "get": { + "operationId": "MappingController.list", + "parameters": [ + { "name": "datasetId", "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 immutable mapping versions", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/mappings/{versionId}/publish": { + "post": { + "operationId": "MappingController.publish", + "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "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/PublishDefinitionDto" } + } + } + }, + "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": "Publish a mapping definition as a new immutable version", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/rules": { + "post": { + "operationId": "RuleSetController.create", + "parameters": [ + { "name": "datasetId", "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/CreateRuleSetDto" } } + } + }, + "responses": { + "201": { + "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": "Create an immutable quality rule-set draft", + "tags": ["datasets"] + }, + "get": { + "operationId": "RuleSetController.list", + "parameters": [ + { "name": "datasetId", "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 immutable quality rule-set versions", + "tags": ["datasets"] + } + }, + "/v1/datasets/{datasetId}/rules/{versionId}/publish": { + "post": { + "operationId": "RuleSetController.publish", "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1691,12 +3533,12 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateGovernedDatasetDto" } + "schema": { "$ref": "#/components/schemas/PublishDefinitionDto" } } } }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -1747,15 +3589,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable governed dataset definition draft", + "summary": "Publish a quality rule set as a new immutable version", "tags": ["datasets"] } }, - "/v1/datasets/{datasetId}/versions": { - "get": { - "operationId": "GovernedDatasetController.list", + "/v1/reference-entities": { + "post": { + "operationId": "ReferenceEntityController.create", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1764,8 +3605,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateReferenceEntityDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -1816,15 +3665,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List governed dataset versions visible to the caller", - "tags": ["datasets"] + "summary": "Create an immutable business-party version", + "tags": ["reference-entities"] } }, - "/v1/datasets/{datasetId}/mappings": { + "/v1/reference-entities/merge": { "post": { - "operationId": "MappingController.create", + "operationId": "ReferenceEntityController.merge", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1836,7 +3684,9 @@ "requestBody": { "required": true, "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateMappingDto" } } + "application/json": { + "schema": { "$ref": "#/components/schemas/MergeReferenceEntityDto" } + } } }, "responses": { @@ -1891,13 +3741,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable mapping definition draft", - "tags": ["datasets"] - }, + "summary": "Record an explicit business-party merge resolution", + "tags": ["reference-entities"] + } + }, + "/v1/reference-entities/{entityId}/versions": { "get": { - "operationId": "MappingController.list", + "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", @@ -1958,15 +3810,16 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable mapping versions", - "tags": ["datasets"] + "summary": "List immutable business-party versions", + "tags": ["reference-entities"] } }, - "/v1/datasets/{datasetId}/rules": { - "post": { - "operationId": "RuleSetController.create", + "/v1/reference-entities/{entityId}/versions/{versionId}": { + "get": { + "operationId": "ReferenceEntityController.getVersion", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -1975,14 +3828,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": { @@ -2033,13 +3880,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable quality rule-set draft", - "tags": ["datasets"] - }, + "summary": "Read one exact immutable business-party version", + "tags": ["reference-entities"] + } + }, + "/v1/reference-entities/{entityId}/resolutions": { "get": { - "operationId": "RuleSetController.list", + "operationId": "ReferenceEntityController.resolutions", "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", @@ -2100,13 +3949,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable quality rule-set versions", - "tags": ["datasets"] + "summary": "List immutable merge and resolution history", + "tags": ["reference-entities"] } }, - "/v1/reference-entities": { + "/v1/dataset-versions": { "post": { - "operationId": "ReferenceEntityController.create", + "operationId": "DatasetVersionController.register", "parameters": [ { "name": "X-Correlation-Id", @@ -2120,7 +3969,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateReferenceEntityDto" } + "schema": { "$ref": "#/components/schemas/RegisterDatasetVersionDto" } } } }, @@ -2176,14 +4025,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable business-party version", - "tags": ["reference-entities"] - } - }, - "/v1/reference-entities/merge": { - "post": { - "operationId": "ReferenceEntityController.merge", + "summary": "Register an immutable dataset result manifest", + "tags": ["datasets"] + }, + "get": { + "operationId": "DatasetVersionController.list", "parameters": [ + { "name": "datasetId", "required": true, "in": "query", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2192,16 +4040,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/MergeReferenceEntityDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -2252,15 +4092,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record an explicit business-party merge resolution", - "tags": ["reference-entities"] + "summary": "List exact dataset result manifests for one governed dataset", + "tags": ["datasets"] } }, - "/v1/reference-entities/{entityId}/versions": { + "/v1/dataset-versions/{versionId}": { "get": { - "operationId": "ReferenceEntityController.list", + "operationId": "DatasetVersionController.get", "parameters": [ - { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2321,8 +4161,8 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable business-party versions", - "tags": ["reference-entities"] + "summary": "Read an exact immutable dataset result manifest", + "tags": ["datasets"] } }, "/v1/devices/sync/operations": { @@ -4056,6 +5896,146 @@ "authorizationEpoch" ] }, + "UpdateContentPlacementDto": { + "type": "object", + "properties": { + "available": { "type": "boolean" }, + "expectedRevision": { "type": "number", "minimum": 1 } + }, + "required": ["available", "expectedRevision"] + }, + "CreateArtifactDeletionRequestDto": { + "type": "object", + "properties": { + "evaluatedAt": { "type": "string", "format": "date-time" }, + "workspaceRetentionUntil": { "type": "string", "format": "date-time" }, + "resourceRetentionUntil": { "type": "string", "format": "date-time" }, + "auditRetentionUntil": { "type": "string", "format": "date-time" }, + "recoveryWindowUntil": { "type": "string", "format": "date-time" }, + "activeApproval": { "type": "boolean" }, + "legalHold": { "type": "boolean" }, + "requestId": { "type": "string", "format": "uuid" }, + "requestedBy": { "type": "string", "format": "uuid" }, + "requestedAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "evaluatedAt", + "workspaceRetentionUntil", + "resourceRetentionUntil", + "auditRetentionUntil", + "recoveryWindowUntil", + "activeApproval", + "legalHold", + "requestId", + "requestedBy", + "requestedAt" + ] + }, + "AuthorizeArtifactDeletionRequestDto": { + "type": "object", + "properties": { + "evaluatedAt": { "type": "string", "format": "date-time" }, + "workspaceRetentionUntil": { "type": "string", "format": "date-time" }, + "resourceRetentionUntil": { "type": "string", "format": "date-time" }, + "auditRetentionUntil": { "type": "string", "format": "date-time" }, + "recoveryWindowUntil": { "type": "string", "format": "date-time" }, + "activeApproval": { "type": "boolean" }, + "legalHold": { "type": "boolean" }, + "approvedAt": { "type": "string", "format": "date-time" }, + "mfaSatisfied": { "type": "boolean" }, + "expectedRevision": { "type": "number", "minimum": 1 } + }, + "required": [ + "evaluatedAt", + "workspaceRetentionUntil", + "resourceRetentionUntil", + "auditRetentionUntil", + "recoveryWindowUntil", + "activeApproval", + "legalHold", + "approvedAt", + "mfaSatisfied", + "expectedRevision" + ] + }, + "CreateArtifactExportDto": { + "type": "object", + "properties": { + "manifestId": { "type": "string", "format": "uuid" }, + "versionIds": { "type": "array", "items": { "type": "string", "format": "uuid" } }, + "approvalState": { + "type": "string", + "enum": ["NOT_REQUIRED", "PENDING", "APPROVED", "REJECTED"] + }, + "createdAt": { "type": "string", "format": "date-time" } + }, + "required": ["manifestId", "versionIds", "approvalState", "createdAt"] + }, + "CreateArtifactUploadSessionDto": { + "type": "object", + "properties": { + "sessionId": { "type": "string", "format": "uuid" }, + "artifactId": { "type": "string", "format": "uuid" }, + "expectedSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "expectedByteSize": { "type": "number", "minimum": 0 }, + "mediaType": { "type": "string" }, + "partSize": { "type": "number", "minimum": 1, "maximum": 1073741824 }, + "createdAt": { "type": "string", "format": "date-time" }, + "expiresAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "sessionId", + "artifactId", + "expectedSha256", + "expectedByteSize", + "mediaType", + "partSize", + "createdAt", + "expiresAt" + ] + }, + "RecordArtifactUploadPartDto": { + "type": "object", + "properties": { + "partNumber": { "type": "number", "minimum": 1 }, + "contentSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "byteSize": { "type": "number", "minimum": 0 }, + "uploadedAt": { "type": "string", "format": "date-time" }, + "expectedRevision": { "type": "number", "minimum": 1 } + }, + "required": ["partNumber", "contentSha256", "byteSize", "uploadedAt", "expectedRevision"] + }, + "CompleteArtifactUploadDto": { + "type": "object", + "properties": { + "assembledSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "expectedRevision": { "type": "number", "minimum": 1 } + }, + "required": ["assembledSha256", "expectedRevision"] + }, + "AbortArtifactUploadDto": { + "type": "object", + "properties": { "expectedRevision": { "type": "number", "minimum": 1 } }, + "required": ["expectedRevision"] + }, + "AdmitArtifactDto": { + "type": "object", + "properties": { + "actualSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "actualByteSize": { "type": "number", "minimum": 0 }, + "detectedMediaType": { "type": "string" }, + "scanState": { "type": "string", "enum": ["PENDING", "CLEAN", "MALICIOUS", "FAILED"] }, + "maxByteSize": { "type": "number", "minimum": 0 }, + "scannedAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "actualSha256", + "actualByteSize", + "detectedMediaType", + "scanState", + "maxByteSize" + ] + }, "GovernedDatasetFieldDto": { "type": "object", "properties": { @@ -4090,6 +6070,14 @@ }, "required": ["datasetId", "versionId", "name", "fields", "createdAt", "canonicalHash"] }, + "PublishGovernedDatasetDto": { + "type": "object", + "properties": { + "nextVersionId": { "type": "string", "format": "uuid" }, + "publishedAt": { "type": "string", "format": "date-time" } + }, + "required": ["nextVersionId", "publishedAt"] + }, "MappingStepDto": { "type": "object", "properties": { @@ -4130,6 +6118,14 @@ "canonicalHash" ] }, + "PublishDefinitionDto": { + "type": "object", + "properties": { + "nextVersionId": { "type": "string", "format": "uuid" }, + "publishedAt": { "type": "string", "format": "date-time" } + }, + "required": ["nextVersionId", "publishedAt"] + }, "CreateRuleSetDto": { "type": "object", "properties": { @@ -4177,6 +6173,41 @@ "resolvedAt" ] }, + "RegisterDatasetVersionDto": { + "type": "object", + "properties": { + "datasetId": { "type": "string", "format": "uuid" }, + "inputArtifactVersionIds": { + "type": "array", + "items": { "type": "string", "format": "uuid" } + }, + "schemaVersionId": { "type": "string", "format": "uuid" }, + "mappingVersionId": { "type": "string", "format": "uuid" }, + "ruleSetVersionId": { "type": "string", "format": "uuid" }, + "engineBuild": { "type": "string", "minLength": 1, "maxLength": 128 }, + "versionId": { "type": "string", "format": "uuid" }, + "contentFingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "rowCount": { "type": "number", "minimum": 0 }, + "qualityState": { + "type": "string", + "enum": ["PASS", "PASS_WITH_WARNINGS", "BLOCKED", "INCOMPLETE"] + }, + "lineageManifestHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "required": [ + "datasetId", + "inputArtifactVersionIds", + "schemaVersionId", + "mappingVersionId", + "ruleSetVersionId", + "engineBuild", + "versionId", + "contentFingerprint", + "rowCount", + "qualityState", + "lineageManifestHash" + ] + }, "CreateDeviceSyncOperationDto": { "type": "object", "properties": { diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 7dedbec0..3fed31e8 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -63,7 +63,22 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, assert.deepEqual(paths, [ '/health/live', '/health/ready', + '/v1/artifact-deletion-requests/{requestId}/authorize', + '/v1/artifact-upload-sessions', + '/v1/artifact-upload-sessions/{sessionId}', + '/v1/artifact-upload-sessions/{sessionId}/abort', + '/v1/artifact-upload-sessions/{sessionId}/complete', + '/v1/artifact-upload-sessions/{sessionId}/parts', + '/v1/artifact-versions/{versionId}', + '/v1/artifact-versions/{versionId}/admit', + '/v1/artifact-versions/{versionId}/deletion-requests', + '/v1/artifact-versions/{versionId}/derived-lineage', + '/v1/artifact-versions/{versionId}/evidence', + '/v1/artifact-versions/{versionId}/lineage', + '/v1/artifact-versions/{versionId}/placements/{placementId}', '/v1/artifacts/evidence-grants/{grantId}', + '/v1/artifacts/exports', + '/v1/artifacts/exports/{manifestId}', '/v1/artifacts/inbox', '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', '/v1/audit/events', @@ -77,10 +92,17 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/auth/sign-out', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', + '/v1/dataset-versions', + '/v1/dataset-versions/{versionId}', '/v1/datasets', + '/v1/datasets/{datasetId}/compatibility', '/v1/datasets/{datasetId}/mappings', + '/v1/datasets/{datasetId}/mappings/{versionId}/publish', '/v1/datasets/{datasetId}/rules', + '/v1/datasets/{datasetId}/rules/{versionId}/publish', '/v1/datasets/{datasetId}/versions', + '/v1/datasets/{datasetId}/versions/{versionId}', + '/v1/datasets/{datasetId}/versions/{versionId}/publish', '/v1/devices/enroll', '/v1/devices/enrollment-challenges', '/v1/devices/grants', @@ -103,7 +125,9 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/organizations/{organizationId}/devices', '/v1/reference-entities', '/v1/reference-entities/merge', + '/v1/reference-entities/{entityId}/resolutions', '/v1/reference-entities/{entityId}/versions', + '/v1/reference-entities/{entityId}/versions/{versionId}', '/v1/system/compatibility', '/v1/system/compatibility/check', ]); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 43e8de30..3afb41d6 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -106,6 +106,8 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802200000_dso_data_mode_policies', '20260802210000_iam_mfa_recovery', '20260802220000_iam_access_tokens', + '20260802230000_iae_retention_exports', + '20260802240000_iae_upload_sessions', 'migration_lock.toml', ]); const migration = await readFile( From 8e430badc9659502cbc00373917ec74ff8ff6b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:39:33 +0700 Subject: [PATCH 066/177] feat(dsm): add immutable dataset quality result contract --- packages/domain/package.json | 4 + packages/domain/src/dataset-quality/v1.ts | 192 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/built-public-api-smoke.mjs | 3 + .../domain/test/dataset-quality-v1.test.mjs | 91 +++++++++ packages/domain/test/public-api-v1.test.mjs | 2 + 6 files changed, 293 insertions(+) create mode 100644 packages/domain/src/dataset-quality/v1.ts create mode 100644 packages/domain/test/dataset-quality-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index ad6b3706..eff432f6 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -92,6 +92,10 @@ "types": "./src/dataset-governance/v1.ts", "import": "./dist/dataset-governance/v1.js" }, + "./dataset-quality/v1": { + "types": "./src/dataset-quality/v1.ts", + "import": "./dist/dataset-quality/v1.js" + }, "./jobs/v1": { "types": "./src/jobs/v1.ts", "import": "./dist/jobs/v1.js" diff --git a/packages/domain/src/dataset-quality/v1.ts b/packages/domain/src/dataset-quality/v1.ts new file mode 100644 index 00000000..31d7f855 --- /dev/null +++ b/packages/domain/src/dataset-quality/v1.ts @@ -0,0 +1,192 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; +import type { QualityStateV1 } from '../dataset-governance/v1.js'; + +/** DSM-011, DSM-013, DSM-015, DSM-020: immutable, value-free quality evidence. */ +export const DATASET_QUALITY_SCHEMA_VERSION_V1 = 1 as const; + +export type DatasetQualityFindingSeverityV1 = 'INFO' | 'WARNING' | 'ERROR'; + +export interface DatasetQualityFindingV1 { + readonly findingId: StableIdentifierV1; + readonly ruleId: StableIdentifierV1; + readonly severity: DatasetQualityFindingSeverityV1; + readonly messageCode: string; + readonly occurrenceCount: number; + readonly evidenceIds: readonly StableIdentifierV1[]; + readonly detailHash: string; +} + +export interface DatasetQualityResultV1 { + readonly schemaVersion: typeof DATASET_QUALITY_SCHEMA_VERSION_V1; + readonly resultId: StableIdentifierV1; + readonly datasetId: StableIdentifierV1; + readonly datasetVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly ruleSetVersionId: StableIdentifierV1; + readonly profileFingerprint: string; + readonly rowCountScanned: number; + readonly qualityState: QualityStateV1; + readonly findings: readonly DatasetQualityFindingV1[]; + readonly resultFingerprint: string; + readonly createdAt: StrictUtcTimestampV1; +} + +export type DatasetQualityErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_HASH' + | 'INVALID_COUNT' + | 'INVALID_TEXT' + | 'INVALID_FINDING' + | 'DUPLICATE_FINDING' + | 'INVALID_QUALITY_STATE'; + +export type DatasetQualityResultV1Of = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: DatasetQualityErrorCodeV1 }; + +function accepted(value: TValue): DatasetQualityResultV1Of { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: DatasetQualityErrorCodeV1): DatasetQualityResultV1Of { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function scope(input: unknown): TenantScopeV1 | undefined { + const parsed = parseTenantScopeV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; +} + +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 positiveCount(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0 ? input : undefined; +} + +function finding(input: unknown): DatasetQualityFindingV1 | undefined { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; + const record = input as Record; + const findingId = identifier(record['findingId']); + const ruleId = identifier(record['ruleId']); + const severity = record['severity']; + const messageCode = text(record['messageCode'], 96); + const occurrenceCount = positiveCount(record['occurrenceCount']); + const detailHash = hash(record['detailHash']); + const evidenceInput = record['evidenceIds'] ?? []; + if (!findingId || !ruleId || !messageCode || occurrenceCount === undefined || !detailHash) { + return undefined; + } + if (!['INFO', 'WARNING', 'ERROR'].includes(severity as string)) return undefined; + if (!Array.isArray(evidenceInput) || evidenceInput.length > 128) return undefined; + const evidenceIds = evidenceInput.map(identifier); + if (evidenceIds.some((candidate): candidate is undefined => candidate === undefined)) { + return undefined; + } + return Object.freeze({ + findingId, + ruleId, + severity: severity as DatasetQualityFindingSeverityV1, + messageCode, + occurrenceCount, + evidenceIds: Object.freeze(evidenceIds as StableIdentifierV1[]), + detailHash, + }); +} + +export function qualityStateFromFindingsV1( + findings: readonly DatasetQualityFindingV1[], + incomplete = false, +): QualityStateV1 { + if (incomplete) return 'INCOMPLETE'; + if (findings.some((candidate) => candidate.severity === 'ERROR')) return 'BLOCKED'; + if (findings.some((candidate) => candidate.severity === 'WARNING')) return 'PASS_WITH_WARNINGS'; + return 'PASS'; +} + +export function createDatasetQualityResultV1(input: { + readonly resultId: unknown; + readonly datasetId: unknown; + readonly datasetVersionId: unknown; + readonly tenantScope: unknown; + readonly ruleSetVersionId: unknown; + readonly profileFingerprint: unknown; + readonly rowCountScanned: unknown; + readonly qualityState: unknown; + readonly findings: unknown; + readonly resultFingerprint: unknown; + readonly createdAt: unknown; +}): DatasetQualityResultV1Of { + const resultId = identifier(input.resultId); + const datasetId = identifier(input.datasetId); + const datasetVersionId = identifier(input.datasetVersionId); + const tenantScope = scope(input.tenantScope); + const ruleSetVersionId = identifier(input.ruleSetVersionId); + const profileFingerprint = hash(input.profileFingerprint); + const rowCountScanned = positiveCount(input.rowCountScanned); + const resultFingerprint = hash(input.resultFingerprint); + const createdAt = timestamp(input.createdAt); + if (!resultId || !datasetId || !datasetVersionId || !ruleSetVersionId) + return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (!profileFingerprint || !resultFingerprint) return rejected('INVALID_HASH'); + if (rowCountScanned === undefined) return rejected('INVALID_COUNT'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + if ( + !['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE'].includes(input.qualityState as string) + ) + return rejected('INVALID_QUALITY_STATE'); + if (!Array.isArray(input.findings) || input.findings.length > 512) + return rejected('INVALID_FINDING'); + const findings = input.findings.map(finding); + if (findings.some((candidate): candidate is undefined => candidate === undefined)) + return rejected('INVALID_FINDING'); + const typedFindings = findings as DatasetQualityFindingV1[]; + if (new Set(typedFindings.map((candidate) => candidate.findingId)).size !== typedFindings.length) + return rejected('DUPLICATE_FINDING'); + return accepted( + Object.freeze({ + schemaVersion: DATASET_QUALITY_SCHEMA_VERSION_V1, + resultId, + datasetId, + datasetVersionId, + tenantScope, + ruleSetVersionId, + profileFingerprint, + rowCountScanned, + qualityState: input.qualityState as QualityStateV1, + findings: Object.freeze(typedFindings), + resultFingerprint, + createdAt, + }), + ); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 8826736f..d407116f 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -8,6 +8,7 @@ export * from './artifact-export/v1.js'; export * from './artifact-upload/v1.js'; export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; +export * from './dataset-quality/v1.js'; export * from './jobs/v1.js'; export * from './approval/v1.js'; export * from './execution-attempt/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index f6467ab0..ad533c6f 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -13,6 +13,7 @@ const [ artifactUpload, dataset, datasetGovernance, + datasetQuality, dataMode, jobs, approval, @@ -38,6 +39,7 @@ const [ import('@databreeze/domain/artifact-upload/v1'), import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), + import('@databreeze/domain/dataset-quality/v1'), import('@databreeze/domain/data-mode/v1'), import('@databreeze/domain/jobs/v1'), import('@databreeze/domain/approval/v1'), @@ -65,6 +67,7 @@ assert.equal(artifactExport.ARTIFACT_EXPORT_SCHEMA_VERSION_V1, 1); assert.equal(artifactUpload.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); +assert.equal(datasetQuality.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); assert.equal(jobs.JOB_SCHEMA_VERSION_V1, 1); assert.equal(approval.APPROVAL_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/dataset-quality-v1.test.mjs b/packages/domain/test/dataset-quality-v1.test.mjs new file mode 100644 index 00000000..e83b9293 --- /dev/null +++ b/packages/domain/test/dataset-quality-v1.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createDatasetQualityResultV1, + qualityStateFromFindingsV1, +} from '../dist/dataset-quality/v1.js'; + +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000001', + workspaceId: '00000000-0000-4000-8000-000000000002', +}; +const ids = { + resultId: '00000000-0000-4000-8000-000000000010', + datasetId: '00000000-0000-4000-8000-000000000011', + datasetVersionId: '00000000-0000-4000-8000-000000000012', + ruleSetVersionId: '00000000-0000-4000-8000-000000000013', + findingId: '00000000-0000-4000-8000-000000000014', + ruleId: '00000000-0000-4000-8000-000000000015', + evidenceId: '00000000-0000-4000-8000-000000000016', +}; + +function result(overrides = {}) { + return createDatasetQualityResultV1({ + resultId: ids.resultId, + datasetId: ids.datasetId, + datasetVersionId: ids.datasetVersionId, + tenantScope: scope, + ruleSetVersionId: ids.ruleSetVersionId, + profileFingerprint: 'a'.repeat(64), + rowCountScanned: 42, + qualityState: 'PASS_WITH_WARNINGS', + findings: [ + { + findingId: ids.findingId, + ruleId: ids.ruleId, + severity: 'WARNING', + messageCode: 'NULL_RATE_HIGH', + occurrenceCount: 2, + evidenceIds: [ids.evidenceId], + detailHash: 'b'.repeat(64), + }, + ], + resultFingerprint: 'c'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }); +} + +void test('[DSM-011, DSM-013, DSM-015] quality results are immutable and contain no source values', () => { + const created = result(); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(Object.isFrozen(created.value), true); + assert.equal(Object.isFrozen(created.value.findings[0]), true); + assert.equal('value' in created.value.findings[0], false); + assert.equal(created.value.qualityState, 'PASS_WITH_WARNINGS'); +}); + +void test('[DSM-020] quality state is deterministic from finding severity and completion', () => { + const accepted = result(); + assert.equal(accepted.accepted, true); + if (!accepted.accepted) return; + assert.equal(qualityStateFromFindingsV1(accepted.value.findings), 'PASS_WITH_WARNINGS'); + assert.equal( + qualityStateFromFindingsV1([{ ...accepted.value.findings[0], severity: 'ERROR' }]), + 'BLOCKED', + ); + assert.equal(qualityStateFromFindingsV1([], true), 'INCOMPLETE'); +}); + +void test('[DSM-013] quality result validation rejects malformed hashes, counts, and duplicate findings', () => { + assert.deepEqual(result({ profileFingerprint: 'not-a-hash' }), { + accepted: false, + code: 'INVALID_HASH', + }); + assert.deepEqual(result({ rowCountScanned: -1 }), { + accepted: false, + code: 'INVALID_COUNT', + }); + assert.deepEqual( + result({ + findings: [ + result().accepted ? result().value.findings[0] : undefined, + result().accepted ? result().value.findings[0] : undefined, + ], + }), + { accepted: false, code: 'DUPLICATE_FINDING' }, + ); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index aca1d84d..3ee83500 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -31,6 +31,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './artifact-upload/v1', './dataset/v1', './dataset-governance/v1', + './dataset-quality/v1', './jobs/v1', './approval/v1', './execution-attempt/v1', @@ -66,6 +67,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o 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); + assert.equal(aggregate.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.createScopedAuthorizationEvaluatorV1, 'function'); From b4e187326150cb5fb0291166c5284d50ee3268b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:42:02 +0700 Subject: [PATCH 067/177] feat(dsm): coordinate tenant-scoped quality results --- ...mory-dataset-quality-repository.adapter.ts | 88 +++++++++++++++++++ .../dataset-quality-repository.port.ts | 24 +++++ .../application/dataset-quality.service.ts | 55 ++++++++++++ .../dsm/dataset-quality.service.test.ts | 78 ++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/in-memory-dataset-quality-repository.adapter.ts create mode 100644 services/api/src/features/dsm/application/dataset-quality-repository.port.ts create mode 100644 services/api/src/features/dsm/application/dataset-quality.service.ts create mode 100644 services/api/test/features/dsm/dataset-quality.service.test.ts diff --git a/services/api/src/features/dsm/adapter/in-memory-dataset-quality-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-dataset-quality-repository.adapter.ts new file mode 100644 index 00000000..a065247d --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-dataset-quality-repository.adapter.ts @@ -0,0 +1,88 @@ +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { DatasetQualityResultV1 } from '@databreeze/domain/dataset-quality/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetQualityRepositoryPortV1, + DatasetQualityTransactionPortV1, +} from '../application/dataset-quality-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(result: DatasetQualityResultV1): DatasetQualityResultV1 { + return Object.freeze({ + ...result, + tenantScope: Object.freeze({ ...result.tenantScope }), + findings: Object.freeze( + result.findings.map((finding) => + Object.freeze({ ...finding, evidenceIds: Object.freeze([...finding.evidenceIds]) }), + ), + ), + }); +} + +export class InMemoryDatasetQualityRepositoryAdapter implements DatasetQualityRepositoryPortV1 { + private results = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, result: DatasetQualityResultV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, result.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = this.results.get(result.resultId); + if (existing && JSON.stringify(existing) !== JSON.stringify(result)) + throw new Error('DSM_IMMUTABLE_QUALITY_RESULT'); + this.results.set(result.resultId, clone(result)); + } + + public async find( + context: IamTenantContextV1, + resultId: DatasetQualityResultV1['resultId'], + ): Promise { + await Promise.resolve(); + const result = this.results.get(resultId); + return result && visible(context.tenantScope, result.tenantScope) ? clone(result) : undefined; + } + + public async list( + context: IamTenantContextV1, + datasetVersionId: DatasetQualityResultV1['datasetVersionId'], + ): Promise { + await Promise.resolve(); + return [...this.results.values()] + .filter( + (result) => + result.datasetVersionId === datasetVersionId && + visible(context.tenantScope, result.tenantScope), + ) + .sort((left, right) => left.resultId.localeCompare(right.resultId)) + .map(clone); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetQualityTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.results); + try { + return await work({ + save: this.save.bind(this), + find: this.find.bind(this), + list: this.list.bind(this), + }); + } catch (error) { + this.results = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/dsm/application/dataset-quality-repository.port.ts b/services/api/src/features/dsm/application/dataset-quality-repository.port.ts new file mode 100644 index 00000000..64aa260c --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-quality-repository.port.ts @@ -0,0 +1,24 @@ +import type { DatasetQualityResultV1 } from '@databreeze/domain/dataset-quality/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const DATASET_QUALITY_REPOSITORY_PORT = Symbol('DATASET_QUALITY_REPOSITORY_PORT'); + +export interface DatasetQualityTransactionPortV1 { + save(context: IamTenantContextV1, result: DatasetQualityResultV1): Promise; + find( + context: IamTenantContextV1, + resultId: DatasetQualityResultV1['resultId'], + ): Promise; + list( + context: IamTenantContextV1, + datasetVersionId: DatasetQualityResultV1['datasetVersionId'], + ): Promise; +} + +export interface DatasetQualityRepositoryPortV1 extends DatasetQualityTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetQualityTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/dsm/application/dataset-quality.service.ts b/services/api/src/features/dsm/application/dataset-quality.service.ts new file mode 100644 index 00000000..9041d1c9 --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-quality.service.ts @@ -0,0 +1,55 @@ +import { + createDatasetQualityResultV1, + type DatasetQualityResultV1, + type DatasetQualityResultV1Of, +} from '@databreeze/domain/dataset-quality/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { DatasetQualityRepositoryPortV1 } from './dataset-quality-repository.port.js'; + +export type DatasetQualityServiceErrorV1 = 'QUALITY_RESULT_NOT_FOUND'; +export type DatasetQualityServiceResultV1 = + | DatasetQualityResultV1Of + | { readonly accepted: false; readonly code: DatasetQualityServiceErrorV1 }; + +/** Coordinates immutable, value-free dataset profiling and validation results. */ +export class DatasetQualityService { + public constructor(private readonly repository: DatasetQualityRepositoryPortV1) {} + + public async register( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { + const created = createDatasetQualityResultV1(input); + if (!created.accepted) return created; + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.resultId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return Object.freeze({ accepted: true, value: existing }); + throw new Error('DSM_IMMUTABLE_QUALITY_RESULT'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + resultId: DatasetQualityResultV1['resultId'], + ): Promise> { + const found = await this.repository.find(context, resultId); + return found + ? Object.freeze({ accepted: true, value: found }) + : Object.freeze({ accepted: false, code: 'QUALITY_RESULT_NOT_FOUND' as const }); + } + + public async list( + context: IamTenantContextV1, + datasetVersionId: DatasetQualityResultV1['datasetVersionId'], + ): Promise { + return this.repository.withTransaction(context, (transaction) => + transaction.list(context, datasetVersionId), + ); + } +} diff --git a/services/api/test/features/dsm/dataset-quality.service.test.ts b/services/api/test/features/dsm/dataset-quality.service.test.ts new file mode 100644 index 00000000..624ce577 --- /dev/null +++ b/services/api/test/features/dsm/dataset-quality.service.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { InMemoryDatasetQualityRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-quality-repository.adapter.js'; +import { DatasetQualityService } from '../../../src/features/dsm/application/dataset-quality.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +const organizationId = '00000000-0000-4000-8000-000000000001'; +const workspaceId = '00000000-0000-4000-8000-000000000002'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000003'; +const actorId = '00000000-0000-4000-8000-000000000010'; +const correlationId = '00000000-0000-4000-8000-000000000011'; + +function context(workspace: string, idempotencyKey: string) { + 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 context'); + return result.value; +} + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid identifier'); + return result.value; +} + +const input = { + resultId: '00000000-0000-4000-8000-000000000020', + datasetId: '00000000-0000-4000-8000-000000000021', + datasetVersionId: '00000000-0000-4000-8000-000000000022', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + ruleSetVersionId: '00000000-0000-4000-8000-000000000023', + profileFingerprint: 'a'.repeat(64), + rowCountScanned: 12, + qualityState: 'PASS', + findings: [], + resultFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', +}; + +void test('[DSM-011, DSM-013, DSM-015] service registers and replays immutable quality results', async () => { + const service = new DatasetQualityService(new InMemoryDatasetQualityRepositoryAdapter()); + const created = await service.register(context(workspaceId, 'quality-1'), input); + assert.equal(created.accepted, true); + assert.deepEqual(await service.register(context(workspaceId, 'quality-1'), input), created); + const found = await service.find(context(workspaceId, 'quality-read'), stable(input.resultId)); + assert.deepEqual(found, created); + assert.equal( + (await service.list(context(workspaceId, 'quality-list'), stable(input.datasetVersionId))) + .length, + 1, + ); +}); + +void test('[IAM-009, DSM-018] sibling workspaces cannot read quality results', async () => { + const service = new DatasetQualityService(new InMemoryDatasetQualityRepositoryAdapter()); + await service.register(context(workspaceId, 'quality-2'), { + ...input, + resultId: '00000000-0000-4000-8000-000000000024', + }); + assert.equal( + ( + await service.list( + context(siblingWorkspaceId, 'quality-sibling'), + stable(input.datasetVersionId), + ) + ).length, + 0, + ); +}); From 53acdea23de681d6a4a67360f13d356681bb1263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:45:04 +0700 Subject: [PATCH 068/177] feat(dsm): persist quality results with Prisma --- .../migration.sql | 23 +++ services/api/prisma/schema/dsm.prisma | 23 +++ ...isma-dataset-quality-repository.adapter.ts | 190 ++++++++++++++++++ services/api/src/features/dsm/dsm.module.ts | 20 ++ .../prisma-dataset-quality-repository.test.ts | 95 +++++++++ services/api/test/prisma-foundation.test.mjs | 12 ++ 6 files changed, 363 insertions(+) create mode 100644 services/api/prisma/migrations/20260802250000_dsm_quality_results/migration.sql create mode 100644 services/api/src/features/dsm/adapter/prisma-dataset-quality-repository.adapter.ts create mode 100644 services/api/test/features/dsm/prisma-dataset-quality-repository.test.ts diff --git a/services/api/prisma/migrations/20260802250000_dsm_quality_results/migration.sql b/services/api/prisma/migrations/20260802250000_dsm_quality_results/migration.sql new file mode 100644 index 00000000..4694bc0a --- /dev/null +++ b/services/api/prisma/migrations/20260802250000_dsm_quality_results/migration.sql @@ -0,0 +1,23 @@ +CREATE TABLE "dsm"."dataset_quality_results" ( + "id" UUID NOT NULL, + "dataset_id" UUID NOT NULL, + "dataset_version_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "rule_set_version_id" UUID NOT NULL, + "profile_fingerprint" CHAR(64) NOT NULL, + "row_count_scanned" BIGINT NOT NULL, + "quality_state" VARCHAR(24) NOT NULL, + "findings" JSONB NOT NULL, + "result_fingerprint" CHAR(64) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dataset_quality_results_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "dataset_quality_results_dataset_version_idx" + ON "dsm"."dataset_quality_results"("dataset_version_id"); +CREATE INDEX "dataset_quality_results_scope_idx" + ON "dsm"."dataset_quality_results"("organization_id", "workspace_id", "project_id", "dataset_version_id"); diff --git a/services/api/prisma/schema/dsm.prisma b/services/api/prisma/schema/dsm.prisma index b635d0d9..749b55d6 100644 --- a/services/api/prisma/schema/dsm.prisma +++ b/services/api/prisma/schema/dsm.prisma @@ -49,6 +49,29 @@ model DatasetVersionRecord { @@schema("dsm") } +/// DSM-011, DSM-013, DSM-015, DSM-020: immutable value-free profiling and validation results. +model DatasetQualityResultRecord { + id String @id @db.Uuid + datasetId String @map("dataset_id") @db.Uuid + datasetVersionId String @map("dataset_version_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + ruleSetVersionId String @map("rule_set_version_id") @db.Uuid + profileFingerprint String @map("profile_fingerprint") @db.Char(64) + rowCountScanned BigInt @map("row_count_scanned") + qualityState String @map("quality_state") @db.VarChar(24) + findings Json + resultFingerprint String @map("result_fingerprint") @db.Char(64) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([datasetVersionId], map: "dataset_quality_results_dataset_version_idx") + @@index([organizationId, workspaceId, projectId, datasetVersionId], map: "dataset_quality_results_scope_idx") + @@map("dataset_quality_results") + @@schema("dsm") +} + /// DSM-025: canonical workspace reference identities are versioned and immutable. model ReferenceEntityVersionRecord { id String @id @db.Uuid diff --git a/services/api/src/features/dsm/adapter/prisma-dataset-quality-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-dataset-quality-repository.adapter.ts new file mode 100644 index 00000000..b3becaec --- /dev/null +++ b/services/api/src/features/dsm/adapter/prisma-dataset-quality-repository.adapter.ts @@ -0,0 +1,190 @@ +import { + createDatasetQualityResultV1, + type DatasetQualityResultV1, +} from '@databreeze/domain/dataset-quality/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetQualityRepositoryPortV1, + DatasetQualityTransactionPortV1, +} from '../application/dataset-quality-repository.port.js'; + +export interface DatasetQualityDatabaseRowV1 { + readonly id: string; + readonly datasetId: string; + readonly datasetVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly ruleSetVersionId: string; + readonly profileFingerprint: string; + readonly rowCountScanned: bigint | number; + readonly qualityState: string; + readonly findings: unknown; + readonly resultFingerprint: string; + readonly createdAt: Date; +} + +export interface DatasetQualityDatabaseCreateDataV1 + extends Omit { + readonly rowCountScanned: bigint; + readonly createdAt: Date; +} + +export interface DatasetQualityDatabaseClientV1 { + readonly datasetQualityResultRecord: { + create(input: { + readonly data: DatasetQualityDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly id: 'asc' }; + }): Promise; + }; + $transaction( + work: (transaction: DatasetQualityDatabaseClientV1) => 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 rowScope(row: DatasetQualityDatabaseRowV1): 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('DSM_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: DatasetQualityDatabaseRowV1): DatasetQualityResultV1 { + const parsed = createDatasetQualityResultV1({ + resultId: row.id, + datasetId: row.datasetId, + datasetVersionId: row.datasetVersionId, + tenantScope: rowScope(row), + ruleSetVersionId: row.ruleSetVersionId, + profileFingerprint: row.profileFingerprint, + rowCountScanned: + typeof row.rowCountScanned === 'bigint' ? Number(row.rowCountScanned) : row.rowCountScanned, + qualityState: row.qualityState, + findings: row.findings, + resultFingerprint: row.resultFingerprint, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_QUALITY_RESULT_INVALID'); + return parsed.value; +} + +function domainToCreate(result: DatasetQualityResultV1): DatasetQualityDatabaseCreateDataV1 { + return { + ...databaseScope(result.tenantScope), + id: result.resultId, + datasetId: result.datasetId, + datasetVersionId: result.datasetVersionId, + ruleSetVersionId: result.ruleSetVersionId, + profileFingerprint: result.profileFingerprint, + rowCountScanned: BigInt(result.rowCountScanned), + qualityState: result.qualityState, + findings: result.findings, + resultFingerprint: result.resultFingerprint, + createdAt: new Date(result.createdAt), + }; +} + +function visible(context: TenantScopeV1, row: DatasetQualityDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaDatasetQualityTransactionAdapter implements DatasetQualityTransactionPortV1 { + public constructor(private readonly client: DatasetQualityDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, result: DatasetQualityResultV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, result.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.datasetQualityResultRecord.findUnique({ + where: { id: result.resultId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(result)) + throw new Error('DSM_IMMUTABLE_QUALITY_RESULT'); + return; + } + await this.client.datasetQualityResultRecord.create({ data: domainToCreate(result) }); + } + + public async find( + context: IamTenantContextV1, + resultId: DatasetQualityResultV1['resultId'], + ): Promise { + const row = await this.client.datasetQualityResultRecord.findUnique({ + where: { id: resultId }, + }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } + + public async list( + context: IamTenantContextV1, + datasetVersionId: DatasetQualityResultV1['datasetVersionId'], + ): Promise { + const rows = await this.client.datasetQualityResultRecord.findMany({ + where: { datasetVersionId, organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } +} + +export class PrismaDatasetQualityRepositoryAdapter implements DatasetQualityRepositoryPortV1 { + public constructor(private readonly client: DatasetQualityDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetQualityTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaDatasetQualityTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, result: DatasetQualityResultV1): Promise { + return new PrismaDatasetQualityTransactionAdapter(this.client).save(context, result); + } + + public find( + context: IamTenantContextV1, + resultId: DatasetQualityResultV1['resultId'], + ): Promise { + return new PrismaDatasetQualityTransactionAdapter(this.client).find(context, resultId); + } + + public list( + context: IamTenantContextV1, + datasetVersionId: DatasetQualityResultV1['datasetVersionId'], + ): Promise { + return new PrismaDatasetQualityTransactionAdapter(this.client).list(context, datasetVersionId); + } +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index 903e4325..c0b0e198 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -26,6 +26,11 @@ import { PrismaDatasetVersionRepositoryAdapter, type DatasetVersionDatabaseClientV1, } from './adapter/prisma-dataset-version-repository.adapter.js'; +import { InMemoryDatasetQualityRepositoryAdapter } from './adapter/in-memory-dataset-quality-repository.adapter.js'; +import { + PrismaDatasetQualityRepositoryAdapter, + type DatasetQualityDatabaseClientV1, +} from './adapter/prisma-dataset-quality-repository.adapter.js'; import { PrismaRuleSetRepositoryAdapter, type RuleSetDatabaseClientV1, @@ -50,6 +55,10 @@ import { DATASET_VERSION_REPOSITORY_PORT, type DatasetVersionRepositoryPortV1, } from './application/dataset-version-repository.port.js'; +import { + DATASET_QUALITY_REPOSITORY_PORT, + type DatasetQualityRepositoryPortV1, +} from './application/dataset-quality-repository.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -72,6 +81,9 @@ export interface DsmModuleOptions { readonly datasetVersionRepository?: DatasetVersionRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly datasetVersionDatabase?: DatasetVersionDatabaseClientV1; + readonly datasetQualityRepository?: DatasetQualityRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly datasetQualityDatabase?: DatasetQualityDatabaseClientV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -128,6 +140,14 @@ export class DsmModule { ? new InMemoryDatasetVersionRepositoryAdapter() : new PrismaDatasetVersionRepositoryAdapter(options.datasetVersionDatabase)), }, + { + provide: DATASET_QUALITY_REPOSITORY_PORT, + useValue: + options.datasetQualityRepository ?? + (options.datasetQualityDatabase === undefined + ? new InMemoryDatasetQualityRepositoryAdapter() + : new PrismaDatasetQualityRepositoryAdapter(options.datasetQualityDatabase)), + }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), diff --git a/services/api/test/features/dsm/prisma-dataset-quality-repository.test.ts b/services/api/test/features/dsm/prisma-dataset-quality-repository.test.ts new file mode 100644 index 00000000..00c82ead --- /dev/null +++ b/services/api/test/features/dsm/prisma-dataset-quality-repository.test.ts @@ -0,0 +1,95 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { createDatasetQualityResultV1 } from '@databreeze/domain/dataset-quality/v1'; +import { + PrismaDatasetQualityRepositoryAdapter, + type DatasetQualityDatabaseClientV1, + type DatasetQualityDatabaseRowV1, +} from '../../../src/features/dsm/adapter/prisma-dataset-quality-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +function id(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('fixture identifier rejected'); + return parsed.value; +} + +const organizationId = id('00000000-0000-4000-8000-000000000901'); +const workspaceId = id('00000000-0000-4000-8000-000000000902'); +const resultId = id('00000000-0000-4000-8000-000000000903'); + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000904', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000905', + idempotencyKey: 'prisma-quality-result', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function client(rows: DatasetQualityDatabaseRowV1[]): DatasetQualityDatabaseClientV1 { + return { + datasetQualityResultRecord: { + create({ data }) { + const persisted = { ...data } as DatasetQualityDatabaseRowV1; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + findMany({ where }) { + return Promise.resolve( + rows + .filter( + (row) => + row.datasetVersionId === where['datasetVersionId'] && + row.organizationId === where['organizationId'], + ) + .sort((left, right) => left.id.localeCompare(right.id)), + ); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('[DSM-011, DSM-013, IAM-009] Prisma quality adapter persists immutable scoped results', async () => { + const tenantContext = context(); + const created = createDatasetQualityResultV1({ + resultId, + datasetId: '00000000-0000-4000-8000-000000000906', + datasetVersionId: '00000000-0000-4000-8000-000000000907', + tenantScope: tenantContext.tenantScope, + ruleSetVersionId: '00000000-0000-4000-8000-000000000908', + profileFingerprint: 'a'.repeat(64), + rowCountScanned: 5, + qualityState: 'PASS', + findings: [], + resultFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const rows: DatasetQualityDatabaseRowV1[] = []; + const repository = new PrismaDatasetQualityRepositoryAdapter(client(rows)); + await repository.save(tenantContext, created.value); + await repository.save(tenantContext, created.value); + assert.deepEqual(await repository.find(tenantContext, resultId), created.value); + assert.deepEqual(await repository.list(tenantContext, created.value.datasetVersionId), [ + created.value, + ]); + assert.equal(rows.length, 1); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 3afb41d6..b44e46a7 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -61,6 +61,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "bua"\."usage_ledger_entries"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_definitions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_versions"/); + assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_quality_results"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_resolutions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."mapping_definitions"/); @@ -108,6 +109,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802220000_iam_access_tokens', '20260802230000_iae_retention_exports', '20260802240000_iae_upload_sessions', + '20260802250000_dsm_quality_results', 'migration_lock.toml', ]); const migration = await readFile( @@ -400,4 +402,14 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const qualityMigration = await readFile( + path.join(migrationsDirectory, inventory[26], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE TABLE "dsm"."dataset_quality_results"', + 'CREATE INDEX "dataset_quality_results_dataset_version_idx"', + ]) { + assert.match(qualityMigration, new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } }); From 9c121179f101109259a7718b853c6b8b4def3d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:48:31 +0700 Subject: [PATCH 069/177] feat(dsm): expose dataset quality result API --- services/api/openapi/v1.json | 271 ++++++++++++++++++ .../dsm/api/dataset-quality.controller.ts | 63 ++++ .../features/dsm/api/dataset-quality.dto.ts | 103 +++++++ services/api/src/features/dsm/dsm.module.ts | 2 + .../dsm/dataset-quality.controller.test.ts | 108 +++++++ services/api/test/openapi.test.ts | 2 + 6 files changed, 549 insertions(+) create mode 100644 services/api/src/features/dsm/api/dataset-quality.controller.ts create mode 100644 services/api/src/features/dsm/api/dataset-quality.dto.ts create mode 100644 services/api/test/features/dsm/dataset-quality.controller.test.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 06401995..b3a25913 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -4165,6 +4165,223 @@ "tags": ["datasets"] } }, + "/v1/dataset-quality-results": { + "post": { + "operationId": "DatasetQualityController.register", + "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/RegisterDatasetQualityResultDto" } + } + } + }, + "responses": { + "201": { + "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": "Register an immutable, value-free dataset quality result", + "tags": ["datasets"] + }, + "get": { + "operationId": "DatasetQualityController.list", + "parameters": [ + { + "name": "datasetVersionId", + "required": true, + "in": "query", + "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 quality results for one exact dataset version", + "tags": ["datasets"] + } + }, + "/v1/dataset-quality-results/{resultId}": { + "get": { + "operationId": "DatasetQualityController.get", + "parameters": [ + { "name": "resultId", "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": "Read an exact immutable dataset quality result", + "tags": ["datasets"] + } + }, "/v1/devices/sync/operations": { "post": { "operationId": "DeviceSyncController.enqueue", @@ -6208,6 +6425,60 @@ "lineageManifestHash" ] }, + "DatasetQualityFindingDto": { + "type": "object", + "properties": { + "findingId": { "type": "string", "format": "uuid" }, + "ruleId": { "type": "string", "format": "uuid" }, + "severity": { "type": "string", "enum": ["INFO", "WARNING", "ERROR"] }, + "messageCode": { "type": "string", "minLength": 1, "maxLength": 96 }, + "occurrenceCount": { "type": "number", "minimum": 0 }, + "evidenceIds": { "type": "array", "items": { "type": "string", "format": "uuid" } }, + "detailHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "required": [ + "findingId", + "ruleId", + "severity", + "messageCode", + "occurrenceCount", + "evidenceIds", + "detailHash" + ] + }, + "RegisterDatasetQualityResultDto": { + "type": "object", + "properties": { + "resultId": { "type": "string", "format": "uuid" }, + "datasetId": { "type": "string", "format": "uuid" }, + "datasetVersionId": { "type": "string", "format": "uuid" }, + "ruleSetVersionId": { "type": "string", "format": "uuid" }, + "profileFingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "rowCountScanned": { "type": "number", "minimum": 0 }, + "qualityState": { + "type": "string", + "enum": ["PASS", "PASS_WITH_WARNINGS", "BLOCKED", "INCOMPLETE"] + }, + "findings": { + "type": "array", + "items": { "$ref": "#/components/schemas/DatasetQualityFindingDto" } + }, + "resultFingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "createdAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "resultId", + "datasetId", + "datasetVersionId", + "ruleSetVersionId", + "profileFingerprint", + "rowCountScanned", + "qualityState", + "findings", + "resultFingerprint", + "createdAt" + ] + }, "CreateDeviceSyncOperationDto": { "type": "object", "properties": { diff --git a/services/api/src/features/dsm/api/dataset-quality.controller.ts b/services/api/src/features/dsm/api/dataset-quality.controller.ts new file mode 100644 index 00000000..6fb2db58 --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-quality.controller.ts @@ -0,0 +1,63 @@ +import { Body, Controller, Get, Inject, Param, Post, Query, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + DATASET_QUALITY_REPOSITORY_PORT, + type DatasetQualityRepositoryPortV1, +} from '../application/dataset-quality-repository.port.js'; +import { DatasetQualityService } from '../application/dataset-quality.service.js'; +import { RegisterDatasetQualityResultDto } from './dataset-quality.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('datasets') +@ApiBearerAuth() +@Controller('v1/dataset-quality-results') +export class DatasetQualityController { + private readonly quality: DatasetQualityService; + + public constructor( + @Inject(DATASET_QUALITY_REPOSITORY_PORT) repository: DatasetQualityRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.quality = new DatasetQualityService(repository); + } + + @Post() + @ApiOperation({ summary: 'Register an immutable, value-free dataset quality result' }) + @ApiBody({ type: RegisterDatasetQualityResultDto }) + async register( + @Req() request: unknown, + @Body() input: RegisterDatasetQualityResultDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.quality.register(context, { + ...input, + tenantScope: context.tenantScope, + }); + } + + @Get(':resultId') + @ApiOperation({ summary: 'Read an exact immutable dataset quality result' }) + async get(@Req() request: unknown, @Param('resultId') resultIdInput: string): Promise { + const context = await this.requestContext.resolve(request); + const resultId = parseStableIdentifierV1(resultIdInput); + if (!resultId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.quality.find(context, resultId.value); + } + + @Get() + @ApiOperation({ summary: 'List quality results for one exact dataset version' }) + async list( + @Req() request: unknown, + @Query('datasetVersionId') datasetVersionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetVersionId = parseStableIdentifierV1(datasetVersionIdInput); + if (!datasetVersionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.quality.list(context, datasetVersionId.value); + } +} diff --git a/services/api/src/features/dsm/api/dataset-quality.dto.ts b/services/api/src/features/dsm/api/dataset-quality.dto.ts new file mode 100644 index 00000000..1863646d --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-quality.dto.ts @@ -0,0 +1,103 @@ +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsInt, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, + MinLength, + ValidateNested, +} from 'class-validator'; + +export class DatasetQualityFindingDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + findingId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + ruleId!: string; + + @ApiProperty({ enum: ['INFO', 'WARNING', 'ERROR'] }) + @IsIn(['INFO', 'WARNING', 'ERROR']) + severity!: 'INFO' | 'WARNING' | 'ERROR'; + + @ApiProperty({ minLength: 1, maxLength: 96 }) + @IsString() + @MinLength(1) + @MaxLength(96) + messageCode!: string; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + occurrenceCount!: number; + + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayMaxSize(128) + @IsUUID('4', { each: true }) + evidenceIds!: string[]; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + detailHash!: string; +} + +export class RegisterDatasetQualityResultDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + resultId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + datasetId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + datasetVersionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + ruleSetVersionId!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + profileFingerprint!: string; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + rowCountScanned!: number; + + @ApiProperty({ enum: ['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE'] }) + @IsIn(['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE']) + qualityState!: 'PASS' | 'PASS_WITH_WARNINGS' | 'BLOCKED' | 'INCOMPLETE'; + + @ApiProperty({ type: [DatasetQualityFindingDto] }) + @IsArray() + @ArrayMaxSize(512) + @ValidateNested({ each: true }) + @Type(() => DatasetQualityFindingDto) + findings!: DatasetQualityFindingDto[]; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + resultFingerprint!: string; + + @ApiProperty({ format: 'date-time' }) + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) + createdAt!: string; +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index c0b0e198..00f4d99d 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -5,6 +5,7 @@ import { MappingController } from './api/mapping.controller.js'; import { ReferenceEntityController } from './api/reference-entity.controller.js'; import { RuleSetController } from './api/rule-set.controller.js'; import { DatasetVersionController } from './api/dataset-version.controller.js'; +import { DatasetQualityController } from './api/dataset-quality.controller.js'; import { InMemoryGovernedDatasetRepositoryAdapter } from './adapter/in-memory-governed-dataset-repository.adapter.js'; import { PrismaGovernedDatasetRepositoryAdapter, @@ -98,6 +99,7 @@ export class DsmModule { RuleSetController, ReferenceEntityController, DatasetVersionController, + DatasetQualityController, ], providers: [ { diff --git a/services/api/test/features/dsm/dataset-quality.controller.test.ts b/services/api/test/features/dsm/dataset-quality.controller.test.ts new file mode 100644 index 00000000..9966b1a1 --- /dev/null +++ b/services/api/test/features/dsm/dataset-quality.controller.test.ts @@ -0,0 +1,108 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryDatasetQualityRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-quality-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000921'; +const workspaceId = '00000000-0000-4000-8000-000000000922'; +const resultId = '00000000-0000-4000-8000-000000000923'; +const datasetVersionId = '00000000-0000-4000-8000-000000000924'; + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000925', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000926', + idempotencyKey: 'quality-controller', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +void test('[DSM-011, DSM-013, DSM-015] quality HTTP surfaces never accept source values', async () => { + const tenantContext = context(); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + datasetQualityRepository: new InMemoryDatasetQualityRepositoryAdapter(), + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/dataset-quality-results', + payload: { + resultId, + datasetId: '00000000-0000-4000-8000-000000000927', + datasetVersionId, + ruleSetVersionId: '00000000-0000-4000-8000-000000000928', + profileFingerprint: 'a'.repeat(64), + rowCountScanned: 42, + qualityState: 'PASS_WITH_WARNINGS', + findings: [ + { + findingId: '00000000-0000-4000-8000-000000000929', + ruleId: '00000000-0000-4000-8000-000000000930', + severity: 'WARNING', + messageCode: 'NULL_RATE_HIGH', + occurrenceCount: 3, + evidenceIds: [], + detailHash: 'b'.repeat(64), + }, + ], + resultFingerprint: 'c'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }, + }); + assert.equal(response.statusCode, 201); + assert.equal(response.body.includes('sourceValue'), false); + const read = await app.inject({ + method: 'GET', + url: `/v1/dataset-quality-results/${resultId}`, + }); + assert.equal(read.statusCode, 200); + const listed = await app.inject({ + method: 'GET', + url: `/v1/dataset-quality-results?datasetVersionId=${datasetVersionId}`, + }); + assert.equal(listed.statusCode, 200); + assert.equal(JSON.parse(listed.body).length, 1); + } finally { + await app.close(); + } +}); + +void test('[DSM-013] quality DTO rejects unsupported source-bearing fields and malformed fingerprints', async () => { + const { app } = await createApiApplication({ + datasetQualityRepository: new InMemoryDatasetQualityRepositoryAdapter(), + requestTenantContext: { resolve: () => Promise.resolve(context()) }, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/dataset-quality-results', + payload: { + resultId, + datasetId: '00000000-0000-4000-8000-000000000927', + datasetVersionId, + ruleSetVersionId: '00000000-0000-4000-8000-000000000928', + profileFingerprint: 'not-a-hash', + rowCountScanned: 0, + qualityState: 'PASS', + findings: [], + resultFingerprint: 'c'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + sourceValue: 'must-not-be-accepted', + }, + }); + assert.equal(response.statusCode, 400); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 3fed31e8..99c7c34b 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -92,6 +92,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/auth/sign-out', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', + '/v1/dataset-quality-results', + '/v1/dataset-quality-results/{resultId}', '/v1/dataset-versions', '/v1/dataset-versions/{versionId}', '/v1/datasets', From e87d729ea993399465e887d6c028803fcb27d5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:50:13 +0700 Subject: [PATCH 070/177] feat(engine): evaluate value-free dataset quality rules --- .../processors/dataset_quality.py | 122 ++++++++++++++++++ services/engine/tests/test_dataset_quality.py | 55 ++++++++ 2 files changed, 177 insertions(+) create mode 100644 services/engine/src/databreeze_engine/processors/dataset_quality.py create mode 100644 services/engine/tests/test_dataset_quality.py diff --git a/services/engine/src/databreeze_engine/processors/dataset_quality.py b/services/engine/src/databreeze_engine/processors/dataset_quality.py new file mode 100644 index 00000000..42708e21 --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/dataset_quality.py @@ -0,0 +1,122 @@ +"""Deterministic, value-free dataset quality evaluation (DSM-013, DSM-015, DSM-020).""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr + +from .dataset_profile import DatasetProfile + +QualitySeverity = Literal["INFO", "WARNING", "ERROR"] +QualityState = Literal["PASS", "PASS_WITH_WARNINGS", "BLOCKED", "INCOMPLETE"] + + +class QualityFinding(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + ruleId: StrictStr = Field(pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + severity: QualitySeverity + messageCode: StrictStr = Field(pattern=r"^[A-Z][A-Z0-9_.-]{0,95}$") + occurrenceCount: StrictInt = Field(ge=0) + detailHash: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + + +class DatasetQualityEvaluation(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + profileFingerprint: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + rowCountScanned: StrictInt = Field(ge=0) + qualityState: QualityState + findings: tuple[QualityFinding, ...] + resultFingerprint: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + + +def _digest(value: object) -> str: + encoded = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def profile_fingerprint(profile: DatasetProfile) -> str: + """Return a stable digest of profile metadata, never source values.""" + return _digest(profile.model_dump(mode="json")) + + +def _required_count(profile: DatasetProfile, field: str) -> int | None: + for summary in profile.fields: + if summary.field == field: + return ( + summary.stateCounts["MISSING"] + + summary.stateCounts["NULL"] + + summary.stateCounts["BLANK"] + ) + return None + + +def evaluate_required_fields( + profile: DatasetProfile, + required_rules: Sequence[Mapping[str, object]], +) -> DatasetQualityEvaluation: + """Evaluate bounded REQUIRED rules from a profile without receiving row values.""" + findings: list[QualityFinding] = [] + for rule in required_rules: + rule_id = rule.get("ruleId") + field = rule.get("field") + severity = rule.get("severity", "ERROR") + if not isinstance(rule_id, str) or not isinstance(field, str): + raise ValueError("required rules need a ruleId and field") + if severity not in {"ERROR", "WARNING"}: + raise ValueError("required rule severity is invalid") + missing_count = _required_count(profile, field) + occurrence_count = profile.sourceRowCount if missing_count is None else missing_count + message_code = "FIELD_NOT_PROFILED" if missing_count is None else "REQUIRED_VALUE_MISSING" + finding_digest = _digest( + { + "ruleId": rule_id, + "field": field, + "occurrenceCount": occurrence_count, + "messageCode": message_code, + } + ) + if occurrence_count > 0 or missing_count is None: + findings.append( + QualityFinding( + ruleId=rule_id, + severity=severity, + messageCode=message_code, + occurrenceCount=occurrence_count, + detailHash=finding_digest, + ) + ) + quality_state: QualityState + if any(finding.severity == "ERROR" for finding in findings): + quality_state = "BLOCKED" + elif any(finding.severity == "WARNING" for finding in findings): + quality_state = "PASS_WITH_WARNINGS" + else: + quality_state = "PASS" + profile_digest = profile_fingerprint(profile) + result_digest = _digest( + { + "profileFingerprint": profile_digest, + "rowCountScanned": profile.rowCountScanned, + "qualityState": quality_state, + "findings": [finding.model_dump(mode="json") for finding in findings], + } + ) + return DatasetQualityEvaluation( + profileFingerprint=profile_digest, + rowCountScanned=profile.rowCountScanned, + qualityState=quality_state, + findings=tuple(findings), + resultFingerprint=result_digest, + ) diff --git a/services/engine/tests/test_dataset_quality.py b/services/engine/tests/test_dataset_quality.py new file mode 100644 index 00000000..30b454ec --- /dev/null +++ b/services/engine/tests/test_dataset_quality.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from databreeze_engine.processors.dataset_profile import profile_records +from databreeze_engine.processors.dataset_quality import ( + evaluate_required_fields, + profile_fingerprint, +) + + +def test_required_quality_is_deterministic_and_value_free() -> None: + profile = profile_records( + [{"amount": 0}, {"amount": None}, {"amount": 2}], + ["amount"], + ) + result = evaluate_required_fields( + profile, + [ + { + "ruleId": "00000000-0000-4000-8000-000000000001", + "field": "amount", + "severity": "WARNING", + } + ], + ) + assert result.qualityState == "PASS_WITH_WARNINGS" + assert result.findings[0].occurrenceCount == 1 + assert "amount" not in result.findings[0].detailHash + assert profile_fingerprint(profile) == result.profileFingerprint + + +def test_missing_profiled_field_is_disclosed_and_error_blocks() -> None: + profile = profile_records([{"code": "A"}], ["code"]) + result = evaluate_required_fields( + profile, + [ + { + "ruleId": "00000000-0000-4000-8000-000000000002", + "field": "amount", + "severity": "ERROR", + } + ], + ) + assert result.qualityState == "BLOCKED" + assert result.findings[0].messageCode == "FIELD_NOT_PROFILED" + assert result.findings[0].occurrenceCount == 1 + + +def test_invalid_rule_shape_fails_closed() -> None: + profile = profile_records([{"code": "A"}], ["code"]) + try: + evaluate_required_fields(profile, [{"field": "code"}]) + except ValueError as error: + assert str(error) == "required rules need a ruleId and field" + else: + raise AssertionError("invalid rule should fail") From 6b3c8cd99671b0a0a3c2018cd56d95aec9678153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:51:58 +0700 Subject: [PATCH 071/177] feat(iae): add revisioned inbox metadata policy --- packages/domain/src/artifact-intake/v1.ts | 75 ++++++++++++++++++- .../domain/test/artifact-intake-v1.test.mjs | 36 +++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/artifact-intake/v1.ts b/packages/domain/src/artifact-intake/v1.ts index 86318807..ef57f1e9 100644 --- a/packages/domain/src/artifact-intake/v1.ts +++ b/packages/domain/src/artifact-intake/v1.ts @@ -21,6 +21,7 @@ export type InboxItemStateV1 = | 'QUARANTINED' | 'ARCHIVED'; export type ArtifactScanStateV1 = 'PENDING' | 'CLEAN' | 'MALICIOUS' | 'FAILED'; +export type InboxPriorityV1 = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'; export interface InboxItemV1 { readonly schemaVersion: typeof ARTIFACT_INTAKE_SCHEMA_VERSION_V1; @@ -31,6 +32,10 @@ export interface InboxItemV1 { readonly state: InboxItemStateV1; readonly createdAt: StrictUtcTimestampV1; readonly revision: number; + readonly assigneeId?: StableIdentifierV1; + readonly labels?: readonly string[]; + readonly priority?: InboxPriorityV1; + readonly dueAt?: StrictUtcTimestampV1; } export type ArtifactIntakeErrorCodeV1 = @@ -47,7 +52,9 @@ export type ArtifactIntakeErrorCodeV1 = | 'SIZE_MISMATCH' | 'MEDIA_MISMATCH' | 'SIZE_POLICY_EXCEEDED' - | 'SCAN_NOT_COMPLETE'; + | 'SCAN_NOT_COMPLETE' + | 'INVALID_METADATA' + | 'REVISION_CONFLICT'; export type ArtifactIntakeResultV1 = | { readonly accepted: true; readonly value: TValue } @@ -146,6 +153,72 @@ export function transitionInboxItemV1( return accepted(Object.freeze({ ...item, state: nextState, revision: item.revision + 1 })); } +/** IAE-013: metadata updates are revisioned and never change artifact identity or state. */ +export function updateInboxMetadataV1( + item: InboxItemV1, + input: { + readonly assigneeId?: unknown; + readonly labels?: unknown; + readonly priority?: unknown; + readonly dueAt?: unknown; + readonly expectedRevision: unknown; + }, +): ArtifactIntakeResultV1 { + if ( + typeof input.expectedRevision !== 'number' || + !Number.isSafeInteger(input.expectedRevision) || + input.expectedRevision < 1 + ) + return rejected('INVALID_METADATA'); + if (input.expectedRevision !== item.revision) return rejected('REVISION_CONFLICT'); + let assigneeId = item.assigneeId; + if (input.assigneeId !== undefined) { + if (input.assigneeId === null) assigneeId = undefined; + else { + assigneeId = identifier(input.assigneeId); + if (!assigneeId) return rejected('INVALID_METADATA'); + } + } + let labels = item.labels; + if (input.labels !== undefined) { + if (!Array.isArray(input.labels) || input.labels.length > 32) + return rejected('INVALID_METADATA'); + const parsedLabels = input.labels.map((label) => text(label, 64)); + if ( + parsedLabels.some((label): label is undefined => label === undefined) || + new Set(parsedLabels).size !== parsedLabels.length + ) + return rejected('INVALID_METADATA'); + labels = Object.freeze(parsedLabels as string[]); + } + let priority = item.priority; + if (input.priority !== undefined) { + if (!['LOW', 'NORMAL', 'HIGH', 'URGENT'].includes(input.priority as string)) + return rejected('INVALID_METADATA'); + priority = input.priority as InboxPriorityV1; + } + let dueAt = item.dueAt; + if (input.dueAt !== undefined) { + if (input.dueAt === null) dueAt = undefined; + else { + dueAt = timestamp(input.dueAt); + if (!dueAt) return rejected('INVALID_METADATA'); + } + } + const next = { ...item, revision: item.revision + 1 }; + if (input.assigneeId !== undefined) { + if (assigneeId === undefined) delete next.assigneeId; + else next.assigneeId = assigneeId; + } + if (labels !== undefined) next.labels = labels; + if (priority !== undefined) next.priority = priority; + if (input.dueAt !== undefined) { + if (dueAt === undefined) delete next.dueAt; + else next.dueAt = dueAt; + } + return accepted(Object.freeze(next)); +} + export function finalizeArtifactAdmissionV1(input: { readonly artifact: ArtifactVersionV1; readonly actualSha256: unknown; diff --git a/packages/domain/test/artifact-intake-v1.test.mjs b/packages/domain/test/artifact-intake-v1.test.mjs index 0ff97b2f..d705ed72 100644 --- a/packages/domain/test/artifact-intake-v1.test.mjs +++ b/packages/domain/test/artifact-intake-v1.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test'; import { createInboxItemV1, finalizeArtifactAdmissionV1, + updateInboxMetadataV1, transitionInboxItemV1, } from '../dist/artifact-intake/v1.js'; import { createArtifactVersionV1 } from '../dist/artifact/v1.js'; @@ -84,3 +85,38 @@ void test('[IAE-009, IAE-010] admission requires digest, size, media signature, { accepted: true, value: { status: 'QUARANTINED', scanState: 'MALICIOUS' } }, ); }); + +void test('[IAE-013] inbox metadata is bounded, revisioned, and clearable', () => { + const created = createInboxItemV1({ + inboxItemId: '00000000-0000-4000-8000-000000000030', + tenantScope: scope, + idempotencyKey: 'metadata-1', + artifactVersionId: baseArtifact.versionId, + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const updated = updateInboxMetadataV1(created.value, { + assigneeId: '00000000-0000-4000-8000-000000000031', + labels: ['finance', 'urgent'], + priority: 'HIGH', + dueAt: '2026-01-02T00:00:00.000Z', + expectedRevision: 1, + }); + assert.equal(updated.accepted, true); + if (!updated.accepted) return; + assert.equal(updated.value.priority, 'HIGH'); + assert.equal(updated.value.revision, 2); + const cleared = updateInboxMetadataV1(updated.value, { + assigneeId: null, + labels: [], + dueAt: null, + expectedRevision: 2, + }); + assert.equal(cleared.accepted, true); + if (cleared.accepted) assert.equal('assigneeId' in cleared.value, false); + assert.deepEqual( + updateInboxMetadataV1(created.value, { priority: 'INVALID', expectedRevision: 1 }), + { accepted: false, code: 'INVALID_METADATA' }, + ); +}); From 76e7053f5558a0a92b0a1bae756ea40b51f94ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 01:54:36 +0700 Subject: [PATCH 072/177] feat(iae): coordinate inbox metadata updates --- ...mory-artifact-intake-repository.adapter.ts | 23 ++++++- .../application/artifact-intake.service.ts | 19 ++++++ .../artifact-intake-metadata.service.test.ts | 61 +++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 services/api/test/features/iae/artifact-intake-metadata.service.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts index 30e48555..0b0f3932 100644 --- a/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.ts @@ -1,4 +1,9 @@ -import { tenantScopeContainsV1, type InboxItemV1, type TenantScopeV1 } from '@databreeze/domain/v1'; +import { + tenantScopeContainsV1, + updateInboxMetadataV1, + type InboxItemV1, + type TenantScopeV1, +} from '@databreeze/domain/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { @@ -39,6 +44,22 @@ export class InMemoryArtifactIntakeRepositoryAdapter implements ArtifactIntakeRe item.revision !== existing.revision + 1 ) throw new Error('IAE_IMMUTABLE_INBOX_ITEM'); + if (existing.state === item.state) { + const metadataInput = { + ...(Object.hasOwn(item, 'assigneeId') || Object.hasOwn(existing, 'assigneeId') + ? { assigneeId: Object.hasOwn(item, 'assigneeId') ? item.assigneeId : null } + : {}), + ...(Object.hasOwn(item, 'labels') ? { labels: item.labels } : {}), + ...(Object.hasOwn(item, 'priority') ? { priority: item.priority } : {}), + ...(Object.hasOwn(item, 'dueAt') || Object.hasOwn(existing, 'dueAt') + ? { dueAt: Object.hasOwn(item, 'dueAt') ? item.dueAt : null } + : {}), + expectedRevision: existing.revision, + }; + const metadata = updateInboxMetadataV1(existing, metadataInput); + if (!metadata.accepted || JSON.stringify(metadata.value) !== JSON.stringify(item)) + throw new Error('IAE_INVALID_INBOX_METADATA'); + } } const sameKey = [...this.items.values()].find( (candidate) => diff --git a/services/api/src/features/iae/application/artifact-intake.service.ts b/services/api/src/features/iae/application/artifact-intake.service.ts index 69545c82..2a84c174 100644 --- a/services/api/src/features/iae/application/artifact-intake.service.ts +++ b/services/api/src/features/iae/application/artifact-intake.service.ts @@ -2,6 +2,7 @@ import { createInboxItemV1, finalizeArtifactAdmissionV1, transitionInboxItemV1, + updateInboxMetadataV1, type ArtifactIntakeResultV1, type ArtifactScanStateV1, type InboxItemV1, @@ -79,4 +80,22 @@ export class ArtifactIntakeService { public async list(context: IamTenantContextV1): Promise { return this.repository.withTransaction(context, (transaction) => transaction.list(context)); } + + public async updateMetadata( + context: IamTenantContextV1, + inboxItemId: InboxItemV1['inboxItemId'], + input: Omit[1], 'expectedRevision'>, + ): Promise> { + return this.repository.withTransaction(context, async (transaction) => { + const item = await transaction.find(context, inboxItemId); + if (!item) return Object.freeze({ accepted: false, code: 'INBOX_NOT_FOUND' as const }); + const updated = updateInboxMetadataV1(item, { + ...input, + expectedRevision: context.expectedRevision ?? item.revision, + }); + if (!updated.accepted) return updated; + await transaction.save(context, updated.value); + return updated; + }); + } } diff --git a/services/api/test/features/iae/artifact-intake-metadata.service.test.ts b/services/api/test/features/iae/artifact-intake-metadata.service.test.ts new file mode 100644 index 00000000..be5e9ede --- /dev/null +++ b/services/api/test/features/iae/artifact-intake-metadata.service.test.ts @@ -0,0 +1,61 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemoryArtifactIntakeRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-intake-repository.adapter.js'; +import { ArtifactIntakeService } from '../../../src/features/iae/application/artifact-intake.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +const organizationId = '00000000-0000-4000-8000-000000000631'; +const workspaceId = '00000000-0000-4000-8000-000000000632'; +const inboxItemId = '00000000-0000-4000-8000-000000000633'; +const artifactVersionId = '00000000-0000-4000-8000-000000000634'; + +function context(idempotencyKey: string, expectedRevision?: number) { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000635', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000636', + idempotencyKey, + ...(expectedRevision === undefined ? {} : { expectedRevision }), + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture identifier rejected'); + return result.value; +} + +void test('[IAE-013] service updates assignment, labels, priority, and due date with optimistic revisions', async () => { + const service = new ArtifactIntakeService(new InMemoryArtifactIntakeRepositoryAdapter()); + const created = await service.create(context('metadata-create'), { + inboxItemId, + tenantScope: context('metadata-scope').tenantScope, + idempotencyKey: 'metadata-item', + artifactVersionId, + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + const updated = await service.updateMetadata(context('metadata-update', 1), stable(inboxItemId), { + assigneeId: '00000000-0000-4000-8000-000000000637', + labels: ['finance'], + priority: 'HIGH', + dueAt: '2026-01-02T00:00:00.000Z', + }); + assert.equal(updated.accepted, true); + if (updated.accepted) { + assert.equal(updated.value.priority, 'HIGH'); + assert.deepEqual(updated.value.labels, ['finance']); + assert.equal(updated.value.revision, 2); + } + const stale = await service.updateMetadata(context('metadata-stale', 1), stable(inboxItemId), { + labels: ['stale'], + }); + assert.deepEqual(stale, { accepted: false, code: 'REVISION_CONFLICT' }); +}); From e6207c2bc044dc9c31d9ffe163145bca5efa099a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:02:20 +0700 Subject: [PATCH 073/177] feat(iae): persist inbox metadata with Prisma --- .../migration.sql | 5 ++ services/api/prisma/schema/iae.prisma | 4 + ...isma-artifact-intake-repository.adapter.ts | 88 +++++++++++++++++-- services/api/test/prisma-foundation.test.mjs | 17 ++++ 4 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 services/api/prisma/migrations/20260802260000_iae_inbox_metadata/migration.sql diff --git a/services/api/prisma/migrations/20260802260000_iae_inbox_metadata/migration.sql b/services/api/prisma/migrations/20260802260000_iae_inbox_metadata/migration.sql new file mode 100644 index 00000000..a246f82d --- /dev/null +++ b/services/api/prisma/migrations/20260802260000_iae_inbox_metadata/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "iae"."inbox_items" + ADD COLUMN "assignee_id" UUID, + ADD COLUMN "labels" JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN "priority" VARCHAR(16) NOT NULL DEFAULT 'NORMAL', + ADD COLUMN "due_at" TIMESTAMPTZ(6); diff --git a/services/api/prisma/schema/iae.prisma b/services/api/prisma/schema/iae.prisma index 4ed5343f..0733a70d 100644 --- a/services/api/prisma/schema/iae.prisma +++ b/services/api/prisma/schema/iae.prisma @@ -34,6 +34,10 @@ model InboxItem { idempotencyKey String @map("idempotency_key") @db.VarChar(200) artifactVersionId String @map("artifact_version_id") @db.Uuid state String @db.VarChar(24) + assigneeId String? @map("assignee_id") @db.Uuid + labels Json @default("[]") + priority String @default("NORMAL") @db.VarChar(16) + dueAt DateTime? @map("due_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) revision Int @default(1) diff --git a/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts index 4f201512..67aabd58 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-intake-repository.adapter.ts @@ -1,6 +1,7 @@ import { createInboxItemV1, transitionInboxItemV1, + updateInboxMetadataV1, type InboxItemStateV1, type InboxItemV1, } from '@databreeze/domain/artifact-intake/v1'; @@ -26,6 +27,10 @@ export interface ArtifactIntakeDatabaseRowV1 { readonly idempotencyKey: string; readonly artifactVersionId: string; readonly state: string; + readonly assigneeId?: string | null; + readonly labels?: unknown; + readonly priority?: string; + readonly dueAt?: Date | null; readonly createdAt: Date; readonly revision: number; } @@ -39,6 +44,10 @@ export interface ArtifactIntakeDatabaseCreateDataV1 { readonly idempotencyKey: string; readonly artifactVersionId: string; readonly state: InboxItemStateV1; + readonly assigneeId?: string | null; + readonly labels?: unknown; + readonly priority?: string; + readonly dueAt?: Date | null; readonly createdAt: Date; readonly revision: number; } @@ -59,7 +68,14 @@ export interface ArtifactIntakeDatabaseDelegateV1 { }): Promise; update(input: { readonly where: { readonly id: string }; - readonly data: { readonly state: InboxItemStateV1; readonly revision: number }; + readonly data: { + readonly state: InboxItemStateV1; + readonly revision: number; + readonly assigneeId: string | null; + readonly labels: unknown; + readonly priority: string; + readonly dueAt: Date | null; + }; }): Promise; } @@ -120,8 +136,16 @@ function rowToDomain(row: ArtifactIntakeDatabaseRowV1): InboxItemV1 { if (!Number.isSafeInteger(row.revision) || row.revision < 1) { throw new Error('IAE_PERSISTED_REVISION_INVALID'); } + const metadata = updateInboxMetadataV1(created.value, { + ...(row.assigneeId === undefined ? {} : { assigneeId: row.assigneeId }), + ...(row.labels === undefined ? {} : { labels: row.labels }), + ...(row.priority === undefined ? {} : { priority: row.priority }), + ...(row.dueAt === undefined || row.dueAt === null ? {} : { dueAt: row.dueAt.toISOString() }), + expectedRevision: 1, + }); + if (!metadata.accepted) throw new Error('IAE_PERSISTED_METADATA_INVALID'); return Object.freeze({ - ...created.value, + ...metadata.value, state: row.state as InboxItemStateV1, revision: row.revision, }); @@ -135,11 +159,38 @@ function domainToCreate(item: InboxItemV1): ArtifactIntakeDatabaseCreateDataV1 { idempotencyKey: item.idempotencyKey, artifactVersionId: item.artifactVersionId, state: item.state, + assigneeId: item.assigneeId ?? null, + labels: item.labels ?? [], + priority: item.priority ?? 'NORMAL', + dueAt: item.dueAt === undefined ? null : new Date(item.dueAt), createdAt: new Date(item.createdAt), revision: item.revision, }; } +/** + * Prisma applies defaults for metadata columns while older callers may omit + * those optional fields. Compare the persisted representation semantically so + * a replay of the same immutable item remains idempotent and state transitions + * do not fail merely because the database materialized defaults. + */ +function comparable(item: InboxItemV1): string { + return JSON.stringify({ + schemaVersion: item.schemaVersion, + inboxItemId: item.inboxItemId, + tenantScope: item.tenantScope, + idempotencyKey: item.idempotencyKey, + artifactVersionId: item.artifactVersionId, + state: item.state, + createdAt: item.createdAt, + revision: item.revision, + assigneeId: item.assigneeId ?? null, + labels: item.labels ?? [], + priority: item.priority ?? 'NORMAL', + dueAt: item.dueAt ?? null, + }); +} + function visible(context: TenantScopeV1, row: ArtifactIntakeDatabaseRowV1): boolean { const candidate = domainScope(row); return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); @@ -164,7 +215,7 @@ class PrismaArtifactIntakeTransactionAdapter implements ArtifactIntakeTransactio const existing = await this.client.inboxItem.findUnique({ where: { id: item.inboxItemId } }); if (existing !== null) { const current = rowToDomain(existing); - if (JSON.stringify(current) === JSON.stringify(item)) return; + if (comparable(current) === comparable(item)) return; if (context.expectedRevision !== current.revision) { throw new Error('IAE_REVISION_CONFLICT'); } @@ -176,13 +227,36 @@ class PrismaArtifactIntakeTransactionAdapter implements ArtifactIntakeTransactio ) { throw new Error('IAE_IMMUTABLE_INBOX_ITEM'); } - const transition = transitionInboxItemV1(current, item.state); - if (!transition.accepted || JSON.stringify(transition.value) !== JSON.stringify(item)) { - throw new Error('IAE_INVALID_INBOX_TRANSITION'); + if (current.state === item.state) { + const metadata = updateInboxMetadataV1(current, { + ...(Object.hasOwn(item, 'assigneeId') || Object.hasOwn(current, 'assigneeId') + ? { assigneeId: Object.hasOwn(item, 'assigneeId') ? item.assigneeId : null } + : {}), + ...(Object.hasOwn(item, 'labels') ? { labels: item.labels } : {}), + ...(Object.hasOwn(item, 'priority') ? { priority: item.priority } : {}), + ...(Object.hasOwn(item, 'dueAt') || Object.hasOwn(current, 'dueAt') + ? { dueAt: Object.hasOwn(item, 'dueAt') ? item.dueAt : null } + : {}), + expectedRevision: current.revision, + }); + if (!metadata.accepted || comparable(metadata.value) !== comparable(item)) + throw new Error('IAE_INVALID_INBOX_METADATA'); + } else { + const transition = transitionInboxItemV1(current, item.state); + if (!transition.accepted || comparable(transition.value) !== comparable(item)) { + throw new Error('IAE_INVALID_INBOX_TRANSITION'); + } } await this.client.inboxItem.update({ where: { id: item.inboxItemId }, - data: { state: item.state, revision: item.revision }, + data: { + state: item.state, + revision: item.revision, + assigneeId: item.assigneeId ?? null, + labels: item.labels ?? [], + priority: item.priority ?? 'NORMAL', + dueAt: item.dueAt === undefined ? null : new Date(item.dueAt), + }, }); return; } diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index b44e46a7..fc83978a 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -55,6 +55,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "iam"\."users"/); assert.match(diff.stdout, /CREATE TABLE "iae"\."artifact_versions"/); assert.match(diff.stdout, /CREATE TABLE "iae"\."inbox_items"/); + assert.match(diff.stdout, /"assignee_id" UUID/); assert.match(diff.stdout, /CREATE TABLE "iae"\."artifact_lineage"/); assert.match(diff.stdout, /CREATE TABLE "iae"\."evidence_grants"/); assert.match(diff.stdout, /CREATE TABLE "aud"\."audit_events"/); @@ -110,6 +111,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802230000_iae_retention_exports', '20260802240000_iae_upload_sessions', '20260802250000_dsm_quality_results', + '20260802260000_iae_inbox_metadata', 'migration_lock.toml', ]); const migration = await readFile( @@ -412,4 +414,19 @@ test('the schema diff and centrally ordered migration inventory establish platfo ]) { assert.match(qualityMigration, new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } + const inboxMetadataMigration = await readFile( + path.join(migrationsDirectory, inventory[27], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'ADD COLUMN "assignee_id" UUID', + 'ADD COLUMN "labels" JSONB', + 'ADD COLUMN "priority" VARCHAR(16)', + 'ADD COLUMN "due_at" TIMESTAMPTZ(6)', + ]) { + assert.match( + inboxMetadataMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From 0aba003cfc736ef44e9d2bd0d2d57a6b897093f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:07:59 +0700 Subject: [PATCH 074/177] feat(iae): expose revisioned inbox metadata patch --- services/api/openapi/v1.json | 94 +++++++++++++++++++ .../src/features/iae/api/inbox-item.dto.ts | 50 +++++++++- .../src/features/iae/api/inbox.controller.ts | 49 +++++++++- .../features/iae/inbox.controller.test.ts | 54 +++++++++++ services/api/test/openapi.test.ts | 1 + 5 files changed, 244 insertions(+), 4 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index b3a25913..9a832413 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -1528,6 +1528,90 @@ "tags": ["artifacts"] } }, + "/v1/artifacts/inbox/{inboxItemId}": { + "patch": { + "operationId": "InboxController.updateMetadata", + "parameters": [ + { "name": "inboxItemId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "If-Match", + "in": "header", + "description": "Expected inbox revision, for example 3 or \"3\".", + "required": false, + "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/UpdateInboxMetadataDto" } + } + } + }, + "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": "Update revisioned, content-free inbox triage metadata", + "tags": ["artifacts"] + } + }, "/v1/artifacts/{versionId}/evidence/{evidenceId}/grants": { "post": { "operationId": "EvidenceGrantController.issue", @@ -6093,6 +6177,16 @@ }, "required": ["inboxItemId", "artifactVersionId", "createdAt"] }, + "UpdateInboxMetadataDto": { + "type": "object", + "properties": { + "assigneeId": { "type": "string", "format": "uuid", "nullable": true }, + "labels": { "maxItems": 32, "type": "array", "items": { "type": "string" } }, + "priority": { "type": "string", "enum": ["LOW", "NORMAL", "HIGH", "URGENT"] }, + "dueAt": { "type": "string", "format": "date-time", "nullable": true }, + "expectedRevision": { "type": "number", "minimum": 1 } + } + }, "CreateEvidenceGrantDto": { "type": "object", "properties": { diff --git a/services/api/src/features/iae/api/inbox-item.dto.ts b/services/api/src/features/iae/api/inbox-item.dto.ts index c00246fd..bcec3d00 100644 --- a/services/api/src/features/iae/api/inbox-item.dto.ts +++ b/services/api/src/features/iae/api/inbox-item.dto.ts @@ -1,5 +1,20 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsISO8601, IsUUID, MaxLength, MinLength } from 'class-validator'; +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsISO8601, + IsIn, + IsInt, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +import type { InboxPriorityV1 } from '@databreeze/domain/artifact-intake/v1'; /** IAE-001: content-free, idempotent intake registration request. */ export class CreateInboxItemDto { @@ -20,3 +35,36 @@ export class CreateInboxItemDto { @MinLength(1) idempotencyKey?: string; } + +/** IAE-013: revisioned, content-free inbox triage metadata patch. */ +export class UpdateInboxMetadataDto { + @ApiProperty({ type: String, format: 'uuid', nullable: true, required: false }) + @IsOptional() + @IsUUID() + assigneeId?: string | null; + + @ApiProperty({ type: [String], maxItems: 32, required: false }) + @IsOptional() + @IsArray() + @ArrayMaxSize(32) + @ArrayUnique() + @IsString({ each: true }) + @MaxLength(64, { each: true }) + labels?: string[]; + + @ApiProperty({ enum: ['LOW', 'NORMAL', 'HIGH', 'URGENT'], required: false }) + @IsOptional() + @IsIn(['LOW', 'NORMAL', 'HIGH', 'URGENT']) + priority?: InboxPriorityV1; + + @ApiProperty({ type: String, format: 'date-time', nullable: true, required: false }) + @IsOptional() + @IsISO8601() + dueAt?: string | null; + + @ApiProperty({ minimum: 1, required: false }) + @IsOptional() + @IsInt() + @Min(1) + expectedRevision?: number; +} diff --git a/services/api/src/features/iae/api/inbox.controller.ts b/services/api/src/features/iae/api/inbox.controller.ts index 0eb7f45f..5f627ecb 100644 --- a/services/api/src/features/iae/api/inbox.controller.ts +++ b/services/api/src/features/iae/api/inbox.controller.ts @@ -1,5 +1,6 @@ -import { Body, Controller, Get, Headers, Inject, Post, Req } from '@nestjs/common'; -import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, Headers, Inject, Param, Patch, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import { ARTIFACT_INTAKE_REPOSITORY_PORT, @@ -9,12 +10,20 @@ import { ArtifactIntakeService, type ArtifactIntakeServiceResultV1, } from '../application/artifact-intake.service.js'; -import { CreateInboxItemDto } from './inbox-item.dto.js'; +import { CreateInboxItemDto, UpdateInboxMetadataDto } from './inbox-item.dto.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; +function parseRevisionHeader(value: string | undefined): number | 'INVALID' | undefined { + if (value === undefined) return undefined; + const match = /^(?:W\/)?"?([1-9][0-9]*)"?$/u.exec(value.trim()); + if (!match) return 'INVALID'; + const revision = Number(match[1]); + return Number.isSafeInteger(revision) ? revision : 'INVALID'; +} + @ApiTags('artifacts') @ApiBearerAuth() @Controller('v1/artifacts') @@ -54,4 +63,38 @@ export class InboxController { const context = await this.requestContext.resolve(request); return this.intake.list(context); } + + @Patch('inbox/:inboxItemId') + @ApiOperation({ summary: 'Update revisioned, content-free inbox triage metadata' }) + @ApiHeader({ + name: 'If-Match', + required: false, + description: 'Expected inbox revision, for example 3 or "3".', + }) + @ApiBody({ type: UpdateInboxMetadataDto }) + async updateMetadata( + @Req() request: unknown, + @Headers('if-match') ifMatch: string | undefined, + @Body() input: UpdateInboxMetadataDto, + @Param('inboxItemId') inboxItemId: string, + ): Promise> { + const context = await this.requestContext.resolve(request); + const headerRevision = parseRevisionHeader(ifMatch); + if (headerRevision === 'INVALID') + return Object.freeze({ accepted: false, code: 'INVALID_METADATA' as const }); + const expectedRevision = input.expectedRevision ?? headerRevision ?? context.expectedRevision; + const parsedId = parseStableIdentifierV1(inboxItemId); + if (!parsedId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + if (expectedRevision === undefined) + return Object.freeze({ accepted: false, code: 'INVALID_METADATA' as const }); + const mutationContext = + expectedRevision === undefined ? context : Object.freeze({ ...context, expectedRevision }); + return this.intake.updateMetadata(mutationContext, parsedId.value, { + ...(Object.hasOwn(input, 'assigneeId') ? { assigneeId: input.assigneeId } : {}), + ...(Object.hasOwn(input, 'labels') ? { labels: input.labels } : {}), + ...(Object.hasOwn(input, 'priority') ? { priority: input.priority } : {}), + ...(Object.hasOwn(input, 'dueAt') ? { dueAt: input.dueAt } : {}), + }); + } } diff --git a/services/api/test/features/iae/inbox.controller.test.ts b/services/api/test/features/iae/inbox.controller.test.ts index 14ead4f9..fbc4f7e8 100644 --- a/services/api/test/features/iae/inbox.controller.test.ts +++ b/services/api/test/features/iae/inbox.controller.test.ts @@ -54,3 +54,57 @@ void test('[IAE-001, IAM-009] HTTP inbox listing uses the configured tenant cont await app.close(); } }); + +void test('[IAE-013] HTTP inbox metadata patch uses a revision precondition and stays content-free', async () => { + const repository = new InMemoryArtifactIntakeRepositoryAdapter(); + const tenantContext = context(); + const intake = new ArtifactIntakeService(repository); + const created = await intake.create(tenantContext, { + inboxItemId, + tenantScope: tenantContext.tenantScope, + idempotencyKey: 'http-inbox-metadata', + artifactVersionId, + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + artifactIntakeRepository: repository, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'PATCH', + url: `/v1/artifacts/inbox/${inboxItemId}`, + headers: { 'if-match': '1' }, + payload: { + labels: ['finance', 'urgent'], + priority: 'HIGH', + dueAt: '2026-01-02T00:00:00.000Z', + path: 'must-not-be-accepted', + }, + }); + assert.equal(response.statusCode, 400); + assert.doesNotMatch(response.body, /must-not-be-accepted/u); + + const accepted = await app.inject({ + method: 'PATCH', + url: `/v1/artifacts/inbox/${inboxItemId}`, + headers: { 'if-match': '1' }, + payload: { + labels: ['finance', 'urgent'], + priority: 'HIGH', + dueAt: '2026-01-02T00:00:00.000Z', + }, + }); + assert.equal(accepted.statusCode, 200); + const body: unknown = JSON.parse(accepted.body); + assert.ok(typeof body === 'object' && body !== null && 'accepted' in body); + assert.equal((body as { readonly accepted: boolean }).accepted, true); + assert.doesNotMatch(accepted.body, /path|source|byte|excerpt/u); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 99c7c34b..c497d07f 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -80,6 +80,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifacts/exports', '/v1/artifacts/exports/{manifestId}', '/v1/artifacts/inbox', + '/v1/artifacts/inbox/{inboxItemId}', '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', '/v1/audit/events', '/v1/audit/seals', From ecd89c97dbc59fd1006b3a71fad1b527894e72ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:07:59 +0700 Subject: [PATCH 075/177] fix(test): remove unsafe quality response assertion --- .../api/test/features/dsm/dataset-quality.controller.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/api/test/features/dsm/dataset-quality.controller.test.ts b/services/api/test/features/dsm/dataset-quality.controller.test.ts index 9966b1a1..81b8f47e 100644 --- a/services/api/test/features/dsm/dataset-quality.controller.test.ts +++ b/services/api/test/features/dsm/dataset-quality.controller.test.ts @@ -72,7 +72,9 @@ void test('[DSM-011, DSM-013, DSM-015] quality HTTP surfaces never accept source url: `/v1/dataset-quality-results?datasetVersionId=${datasetVersionId}`, }); assert.equal(listed.statusCode, 200); - assert.equal(JSON.parse(listed.body).length, 1); + const listedBody: unknown = JSON.parse(listed.body); + assert.ok(Array.isArray(listedBody)); + assert.equal(listedBody.length, 1); } finally { await app.close(); } From 511c4837483b600e298f92b4abcfce280af797de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:10:57 +0700 Subject: [PATCH 076/177] feat(dsm): add reproducible profile disclosure contract --- packages/domain/package.json | 4 + packages/domain/src/dataset-profile/v1.ts | 173 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/built-public-api-smoke.mjs | 3 + .../domain/test/dataset-profile-v1.test.mjs | 59 ++++++ packages/domain/test/public-api-v1.test.mjs | 2 + 6 files changed, 242 insertions(+) create mode 100644 packages/domain/src/dataset-profile/v1.ts create mode 100644 packages/domain/test/dataset-profile-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index eff432f6..87bbba70 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -96,6 +96,10 @@ "types": "./src/dataset-quality/v1.ts", "import": "./dist/dataset-quality/v1.js" }, + "./dataset-profile/v1": { + "types": "./src/dataset-profile/v1.ts", + "import": "./dist/dataset-profile/v1.js" + }, "./jobs/v1": { "types": "./src/jobs/v1.ts", "import": "./dist/jobs/v1.js" diff --git a/packages/domain/src/dataset-profile/v1.ts b/packages/domain/src/dataset-profile/v1.ts new file mode 100644 index 00000000..2bf20713 --- /dev/null +++ b/packages/domain/src/dataset-profile/v1.ts @@ -0,0 +1,173 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** DSM-011: bounded, reproducible profiling disclosure without source values. */ +export const DATASET_PROFILE_SCHEMA_VERSION_V1 = 1 as const; + +export type DatasetProfileCompletenessV1 = 'COMPLETE' | 'DETERMINISTIC_SAMPLE'; + +export interface DatasetProfileResourceLimitsV1 { + readonly maxRows: number; + readonly maxBytes: number; + readonly maxDurationMs: number; +} + +export interface DatasetProfileV1 { + readonly schemaVersion: typeof DATASET_PROFILE_SCHEMA_VERSION_V1; + readonly profileId: StableIdentifierV1; + readonly datasetVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly completeness: DatasetProfileCompletenessV1; + readonly samplingMethod: string; + readonly samplingSeed?: string; + readonly excludedScopes: readonly string[]; + readonly rowCountScanned: number; + readonly rowCountAvailable?: number; + readonly resourceLimits: DatasetProfileResourceLimitsV1; + readonly profileFingerprint: string; + readonly createdAt: StrictUtcTimestampV1; +} + +export type DatasetProfileErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_TEXT' + | 'INVALID_HASH' + | 'INVALID_COUNT' + | 'INVALID_COMPLETENESS' + | 'INVALID_SAMPLING' + | 'INVALID_LIMITS'; + +export type DatasetProfileResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: DatasetProfileErrorCodeV1 }; + +function accepted(value: TValue): DatasetProfileResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: DatasetProfileErrorCodeV1): DatasetProfileResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function scope(input: unknown): TenantScopeV1 | undefined { + const parsed = parseTenantScopeV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +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 hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; +} + +function count(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0 ? input : undefined; +} + +function limit(input: unknown, maximum: number): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input > 0 && input <= maximum + ? input + : undefined; +} + +export function createDatasetProfileV1(input: { + readonly profileId: unknown; + readonly datasetVersionId: unknown; + readonly tenantScope: unknown; + readonly completeness: unknown; + readonly samplingMethod: unknown; + readonly samplingSeed?: unknown; + readonly excludedScopes?: unknown; + readonly rowCountScanned: unknown; + readonly rowCountAvailable?: unknown; + readonly resourceLimits: unknown; + readonly profileFingerprint: unknown; + readonly createdAt: unknown; +}): DatasetProfileResultV1 { + const profileId = identifier(input.profileId); + const datasetVersionId = identifier(input.datasetVersionId); + const tenantScope = scope(input.tenantScope); + const completeness = input.completeness; + const samplingMethod = text(input.samplingMethod, 96); + const samplingSeed = input.samplingSeed === undefined ? undefined : hash(input.samplingSeed); + const excludedInput = input.excludedScopes ?? []; + const excludedScopes = Array.isArray(excludedInput) + ? excludedInput.map((value) => text(value, 128)) + : undefined; + const rowCountScanned = count(input.rowCountScanned); + const rowCountAvailable = + input.rowCountAvailable === undefined ? undefined : count(input.rowCountAvailable); + const limits = input.resourceLimits; + const profileFingerprint = hash(input.profileFingerprint); + const createdAt = timestamp(input.createdAt); + + if (!profileId || !datasetVersionId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (!['COMPLETE', 'DETERMINISTIC_SAMPLE'].includes(completeness as string)) + return rejected('INVALID_COMPLETENESS'); + if (!samplingMethod) return rejected('INVALID_SAMPLING'); + if (completeness === 'DETERMINISTIC_SAMPLE' && !samplingSeed) return rejected('INVALID_SAMPLING'); + if (completeness === 'COMPLETE' && input.samplingSeed !== undefined) + return rejected('INVALID_SAMPLING'); + if ( + !excludedScopes || + excludedScopes.length > 64 || + excludedScopes.some((value): value is undefined => value === undefined) + ) + return rejected('INVALID_TEXT'); + if (new Set(excludedScopes).size !== excludedScopes.length) return rejected('INVALID_TEXT'); + if (rowCountScanned === undefined) return rejected('INVALID_COUNT'); + if (rowCountAvailable !== undefined && rowCountScanned > rowCountAvailable) + return rejected('INVALID_COUNT'); + if (typeof limits !== 'object' || limits === null || Array.isArray(limits)) + return rejected('INVALID_LIMITS'); + const limitRecord = limits as Record; + const maxRows = limit(limitRecord['maxRows'], 10_000_000); + const maxBytes = limit(limitRecord['maxBytes'], 1024 * 1024 * 1024 * 1024); + const maxDurationMs = limit(limitRecord['maxDurationMs'], 86_400_000); + if (!maxRows || !maxBytes || !maxDurationMs) return rejected('INVALID_LIMITS'); + if (!profileFingerprint) return rejected('INVALID_HASH'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + return accepted( + Object.freeze({ + schemaVersion: DATASET_PROFILE_SCHEMA_VERSION_V1, + profileId, + datasetVersionId, + tenantScope, + completeness: completeness as DatasetProfileCompletenessV1, + samplingMethod, + ...(samplingSeed === undefined ? {} : { samplingSeed }), + excludedScopes: Object.freeze(excludedScopes as string[]), + rowCountScanned, + ...(rowCountAvailable === undefined ? {} : { rowCountAvailable }), + resourceLimits: Object.freeze({ maxRows, maxBytes, maxDurationMs }), + profileFingerprint, + createdAt, + }), + ); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index d407116f..26dd8ab6 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -9,6 +9,7 @@ export * from './artifact-upload/v1.js'; export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; export * from './dataset-quality/v1.js'; +export * from './dataset-profile/v1.js'; export * from './jobs/v1.js'; export * from './approval/v1.js'; export * from './execution-attempt/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index ad533c6f..85c1613c 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -14,6 +14,7 @@ const [ dataset, datasetGovernance, datasetQuality, + datasetProfile, dataMode, jobs, approval, @@ -40,6 +41,7 @@ const [ import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/dataset-quality/v1'), + import('@databreeze/domain/dataset-profile/v1'), import('@databreeze/domain/data-mode/v1'), import('@databreeze/domain/jobs/v1'), import('@databreeze/domain/approval/v1'), @@ -68,6 +70,7 @@ assert.equal(artifactUpload.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(datasetQuality.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); +assert.equal(datasetProfile.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); assert.equal(jobs.JOB_SCHEMA_VERSION_V1, 1); assert.equal(approval.APPROVAL_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/dataset-profile-v1.test.mjs b/packages/domain/test/dataset-profile-v1.test.mjs new file mode 100644 index 00000000..c5729e61 --- /dev/null +++ b/packages/domain/test/dataset-profile-v1.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createDatasetProfileV1, + DATASET_PROFILE_SCHEMA_VERSION_V1, +} from '../dist/dataset-profile/v1.js'; + +const scope = { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000701', + workspaceId: '00000000-0000-4000-8000-000000000702', +}; + +const base = { + profileId: '00000000-0000-4000-8000-000000000703', + datasetVersionId: '00000000-0000-4000-8000-000000000704', + tenantScope: scope, + completeness: 'DETERMINISTIC_SAMPLE', + samplingMethod: 'HASHED_ROW_RESERVOIR_V1', + samplingSeed: 'a'.repeat(64), + excludedScopes: ['restricted:payroll'], + rowCountScanned: 500, + rowCountAvailable: 1000, + resourceLimits: { maxRows: 10000, maxBytes: 1000000, maxDurationMs: 60000 }, + profileFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', +}; + +void test('[DSM-011] profile disclosure preserves deterministic sampling, exclusions, counts, and limits', () => { + const result = createDatasetProfileV1(base); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.schemaVersion, DATASET_PROFILE_SCHEMA_VERSION_V1); + assert.equal(result.value.completeness, 'DETERMINISTIC_SAMPLE'); + assert.equal(result.value.samplingSeed, 'a'.repeat(64)); + assert.equal(result.value.rowCountScanned, 500); + assert.deepEqual(result.value.resourceLimits, { + maxRows: 10000, + maxBytes: 1000000, + maxDurationMs: 60000, + }); +}); + +void test('[DSM-011] complete profiles reject sample-only fields and impossible counts', () => { + assert.deepEqual( + createDatasetProfileV1({ + ...base, + completeness: 'COMPLETE', + samplingSeed: undefined, + rowCountScanned: 1001, + }), + { accepted: false, code: 'INVALID_COUNT' }, + ); + assert.deepEqual( + createDatasetProfileV1({ ...base, completeness: 'COMPLETE', samplingSeed: 'a'.repeat(64) }), + { accepted: false, code: 'INVALID_SAMPLING' }, + ); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 3ee83500..01dab94f 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -32,6 +32,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './dataset/v1', './dataset-governance/v1', './dataset-quality/v1', + './dataset-profile/v1', './jobs/v1', './approval/v1', './execution-attempt/v1', @@ -68,6 +69,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.createScopedAuthorizationEvaluatorV1, 'function'); From 612c1fe673b9b04ca64e35ca5c88083614b229af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:14:56 +0700 Subject: [PATCH 077/177] feat(dsm): coordinate scoped profile disclosures --- ...mory-dataset-profile-repository.adapter.ts | 87 +++++++++++++++++++ .../dataset-profile-repository.port.ts | 24 +++++ .../application/dataset-profile.service.ts | 55 ++++++++++++ .../dsm/dataset-profile.service.test.ts | 67 ++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.ts create mode 100644 services/api/src/features/dsm/application/dataset-profile-repository.port.ts create mode 100644 services/api/src/features/dsm/application/dataset-profile.service.ts create mode 100644 services/api/test/features/dsm/dataset-profile.service.test.ts diff --git a/services/api/src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.ts new file mode 100644 index 00000000..4d479968 --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.ts @@ -0,0 +1,87 @@ +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { DatasetProfileV1 } from '@databreeze/domain/dataset-profile/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetProfileRepositoryPortV1, + DatasetProfileTransactionPortV1, +} from '../application/dataset-profile-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(profile: DatasetProfileV1): DatasetProfileV1 { + return Object.freeze({ + ...profile, + tenantScope: Object.freeze({ ...profile.tenantScope }), + excludedScopes: Object.freeze([...profile.excludedScopes]), + resourceLimits: Object.freeze({ ...profile.resourceLimits }), + }); +} + +export class InMemoryDatasetProfileRepositoryAdapter implements DatasetProfileRepositoryPortV1 { + private profiles = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, profile: DatasetProfileV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, profile.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = this.profiles.get(profile.profileId); + if (existing && JSON.stringify(existing) !== JSON.stringify(profile)) + throw new Error('DSM_IMMUTABLE_DATASET_PROFILE'); + this.profiles.set(profile.profileId, clone(profile)); + } + + public async find( + context: IamTenantContextV1, + profileId: DatasetProfileV1['profileId'], + ): Promise { + await Promise.resolve(); + const profile = this.profiles.get(profileId); + return profile && visible(context.tenantScope, profile.tenantScope) + ? clone(profile) + : undefined; + } + + public async list( + context: IamTenantContextV1, + datasetVersionId: DatasetProfileV1['datasetVersionId'], + ): Promise { + await Promise.resolve(); + return [...this.profiles.values()] + .filter( + (profile) => + profile.datasetVersionId === datasetVersionId && + visible(context.tenantScope, profile.tenantScope), + ) + .sort((left, right) => left.profileId.localeCompare(right.profileId)) + .map(clone); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetProfileTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.profiles); + try { + return await work({ + save: this.save.bind(this), + find: this.find.bind(this), + list: this.list.bind(this), + }); + } catch (error) { + this.profiles = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/dsm/application/dataset-profile-repository.port.ts b/services/api/src/features/dsm/application/dataset-profile-repository.port.ts new file mode 100644 index 00000000..9b14d040 --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-profile-repository.port.ts @@ -0,0 +1,24 @@ +import type { DatasetProfileV1 } from '@databreeze/domain/dataset-profile/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const DATASET_PROFILE_REPOSITORY_PORT = Symbol('DATASET_PROFILE_REPOSITORY_PORT'); + +export interface DatasetProfileTransactionPortV1 { + save(context: IamTenantContextV1, profile: DatasetProfileV1): Promise; + find( + context: IamTenantContextV1, + profileId: DatasetProfileV1['profileId'], + ): Promise; + list( + context: IamTenantContextV1, + datasetVersionId: DatasetProfileV1['datasetVersionId'], + ): Promise; +} + +export interface DatasetProfileRepositoryPortV1 extends DatasetProfileTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetProfileTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/dsm/application/dataset-profile.service.ts b/services/api/src/features/dsm/application/dataset-profile.service.ts new file mode 100644 index 00000000..baee7f3a --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-profile.service.ts @@ -0,0 +1,55 @@ +import { + createDatasetProfileV1, + type DatasetProfileResultV1, + type DatasetProfileV1, +} from '@databreeze/domain/dataset-profile/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { DatasetProfileRepositoryPortV1 } from './dataset-profile-repository.port.js'; + +export type DatasetProfileServiceErrorV1 = 'PROFILE_NOT_FOUND'; +export type DatasetProfileServiceResultV1 = + | DatasetProfileResultV1 + | { readonly accepted: false; readonly code: DatasetProfileServiceErrorV1 }; + +/** Coordinates immutable, value-free profiling disclosure records. */ +export class DatasetProfileService { + public constructor(private readonly repository: DatasetProfileRepositoryPortV1) {} + + public async register( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { + const created = createDatasetProfileV1(input); + if (!created.accepted) return created; + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.profileId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return Object.freeze({ accepted: true, value: existing }); + throw new Error('DSM_IMMUTABLE_DATASET_PROFILE'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + profileId: DatasetProfileV1['profileId'], + ): Promise> { + const found = await this.repository.find(context, profileId); + return found + ? Object.freeze({ accepted: true, value: found }) + : Object.freeze({ accepted: false, code: 'PROFILE_NOT_FOUND' as const }); + } + + public async list( + context: IamTenantContextV1, + datasetVersionId: DatasetProfileV1['datasetVersionId'], + ): Promise { + return this.repository.withTransaction(context, (transaction) => + transaction.list(context, datasetVersionId), + ); + } +} diff --git a/services/api/test/features/dsm/dataset-profile.service.test.ts b/services/api/test/features/dsm/dataset-profile.service.test.ts new file mode 100644 index 00000000..93223760 --- /dev/null +++ b/services/api/test/features/dsm/dataset-profile.service.test.ts @@ -0,0 +1,67 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { InMemoryDatasetProfileRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.js'; +import { DatasetProfileService } from '../../../src/features/dsm/application/dataset-profile.service.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000741', + tenantScope: { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000742', + workspaceId: '00000000-0000-4000-8000-000000000743', + }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000744', + idempotencyKey: 'profile-service', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const input = { + profileId: '00000000-0000-4000-8000-000000000745', + datasetVersionId: '00000000-0000-4000-8000-000000000746', + tenantScope: context.tenantScope, + completeness: 'DETERMINISTIC_SAMPLE', + samplingMethod: 'HASHED_ROW_RESERVOIR_V1', + samplingSeed: 'a'.repeat(64), + excludedScopes: ['restricted:payroll'], + rowCountScanned: 100, + rowCountAvailable: 1000, + resourceLimits: { maxRows: 1000, maxBytes: 1000000, maxDurationMs: 60000 }, + profileFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', +}; + +void test('[DSM-011, IAM-009] profile service registers immutable scoped disclosures', async () => { + const service = new DatasetProfileService(new InMemoryDatasetProfileRepositoryAdapter()); + const first = await service.register(context, input); + assert.equal(first.accepted, true); + const replay = await service.register(context, input); + assert.equal(replay.accepted, true); + if (!first.accepted || !replay.accepted) return; + assert.deepEqual(replay.value, first.value); + const listed = await service.list(context, first.value.datasetVersionId); + assert.equal(listed.length, 1); +}); + +void test('[DSM-011, IAM-009] sibling workspace cannot read profile disclosure', async () => { + const repository = new InMemoryDatasetProfileRepositoryAdapter(); + const service = new DatasetProfileService(repository); + const first = await service.register(context, input); + assert.equal(first.accepted, true); + const siblingResult = createIamTenantContextV1({ + ...context, + tenantScope: { + scopeType: 'workspace', + organizationId: context.tenantScope.organizationId, + workspaceId: '00000000-0000-4000-8000-000000000747', + }, + idempotencyKey: 'profile-sibling', + }); + assert.equal(siblingResult.accepted, true); + if (!siblingResult.accepted || !first.accepted) return; + const found = await service.find(siblingResult.value, first.value.profileId); + assert.deepEqual(found, { accepted: false, code: 'PROFILE_NOT_FOUND' }); +}); From ef433065ef2ae6fb944e8c2d34a4d5fe285d3e36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:14:56 +0700 Subject: [PATCH 078/177] feat(dsm): expose profile disclosure API --- services/api/openapi/v1.json | 252 ++++++++++++++++++ .../dsm/api/dataset-profile.controller.ts | 60 +++++ .../features/dsm/api/dataset-profile.dto.ts | 97 +++++++ services/api/src/features/dsm/dsm.module.ts | 13 + .../dsm/dataset-profile.controller.test.ts | 85 ++++++ services/api/test/openapi.test.ts | 2 + 6 files changed, 509 insertions(+) create mode 100644 services/api/src/features/dsm/api/dataset-profile.controller.ts create mode 100644 services/api/src/features/dsm/api/dataset-profile.dto.ts create mode 100644 services/api/test/features/dsm/dataset-profile.controller.test.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 9a832413..2c0bf385 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -4466,6 +4466,223 @@ "tags": ["datasets"] } }, + "/v1/dataset-profiles": { + "post": { + "operationId": "DatasetProfileController.register", + "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/RegisterDatasetProfileDto" } + } + } + }, + "responses": { + "201": { + "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": "Register an immutable, value-free dataset profile disclosure", + "tags": ["datasets"] + }, + "get": { + "operationId": "DatasetProfileController.list", + "parameters": [ + { + "name": "datasetVersionId", + "required": true, + "in": "query", + "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 profile disclosures for one exact dataset version", + "tags": ["datasets"] + } + }, + "/v1/dataset-profiles/{profileId}": { + "get": { + "operationId": "DatasetProfileController.get", + "parameters": [ + { "name": "profileId", "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": "Read an exact immutable dataset profile disclosure", + "tags": ["datasets"] + } + }, "/v1/devices/sync/operations": { "post": { "operationId": "DeviceSyncController.enqueue", @@ -6573,6 +6790,41 @@ "createdAt" ] }, + "DatasetProfileResourceLimitsDto": { + "type": "object", + "properties": { + "maxRows": { "type": "number", "minimum": 1, "maximum": 10000000 }, + "maxBytes": { "type": "number", "minimum": 1, "maximum": 1099511627776 }, + "maxDurationMs": { "type": "number", "minimum": 1, "maximum": 86400000 } + }, + "required": ["maxRows", "maxBytes", "maxDurationMs"] + }, + "RegisterDatasetProfileDto": { + "type": "object", + "properties": { + "profileId": { "type": "string", "format": "uuid" }, + "datasetVersionId": { "type": "string", "format": "uuid" }, + "completeness": { "type": "string", "enum": ["COMPLETE", "DETERMINISTIC_SAMPLE"] }, + "samplingMethod": { "type": "string", "maxLength": 96 }, + "samplingSeed": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "excludedScopes": { "maxItems": 64, "type": "array", "items": { "type": "string" } }, + "rowCountScanned": { "type": "number", "minimum": 0 }, + "rowCountAvailable": { "type": "number", "minimum": 0 }, + "resourceLimits": { "$ref": "#/components/schemas/DatasetProfileResourceLimitsDto" }, + "profileFingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "createdAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "profileId", + "datasetVersionId", + "completeness", + "samplingMethod", + "rowCountScanned", + "resourceLimits", + "profileFingerprint", + "createdAt" + ] + }, "CreateDeviceSyncOperationDto": { "type": "object", "properties": { diff --git a/services/api/src/features/dsm/api/dataset-profile.controller.ts b/services/api/src/features/dsm/api/dataset-profile.controller.ts new file mode 100644 index 00000000..a780bb82 --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-profile.controller.ts @@ -0,0 +1,60 @@ +import { Body, Controller, Get, Inject, Param, Post, Query, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + DATASET_PROFILE_REPOSITORY_PORT, + type DatasetProfileRepositoryPortV1, +} from '../application/dataset-profile-repository.port.js'; +import { DatasetProfileService } from '../application/dataset-profile.service.js'; +import { RegisterDatasetProfileDto } from './dataset-profile.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('datasets') +@ApiBearerAuth() +@Controller('v1/dataset-profiles') +export class DatasetProfileController { + private readonly profiles: DatasetProfileService; + + public constructor( + @Inject(DATASET_PROFILE_REPOSITORY_PORT) repository: DatasetProfileRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.profiles = new DatasetProfileService(repository); + } + + @Post() + @ApiOperation({ summary: 'Register an immutable, value-free dataset profile disclosure' }) + @ApiBody({ type: RegisterDatasetProfileDto }) + async register( + @Req() request: unknown, + @Body() input: RegisterDatasetProfileDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.profiles.register(context, { ...input, tenantScope: context.tenantScope }); + } + + @Get(':profileId') + @ApiOperation({ summary: 'Read an exact immutable dataset profile disclosure' }) + async get(@Req() request: unknown, @Param('profileId') profileIdInput: string): Promise { + const context = await this.requestContext.resolve(request); + const profileId = parseStableIdentifierV1(profileIdInput); + if (!profileId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.profiles.find(context, profileId.value); + } + + @Get() + @ApiOperation({ summary: 'List profile disclosures for one exact dataset version' }) + async list( + @Req() request: unknown, + @Query('datasetVersionId') datasetVersionIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetVersionId = parseStableIdentifierV1(datasetVersionIdInput); + if (!datasetVersionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + return this.profiles.list(context, datasetVersionId.value); + } +} diff --git a/services/api/src/features/dsm/api/dataset-profile.dto.ts b/services/api/src/features/dsm/api/dataset-profile.dto.ts new file mode 100644 index 00000000..cbc7561c --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-profile.dto.ts @@ -0,0 +1,97 @@ +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; + +export class DatasetProfileResourceLimitsDto { + @ApiProperty({ minimum: 1, maximum: 10000000 }) + @IsInt() + @Min(1) + @Max(10000000) + maxRows!: number; + + @ApiProperty({ minimum: 1, maximum: 1099511627776 }) + @IsInt() + @Min(1) + @Max(1099511627776) + maxBytes!: number; + + @ApiProperty({ minimum: 1, maximum: 86400000 }) + @IsInt() + @Min(1) + @Max(86400000) + maxDurationMs!: number; +} + +export class RegisterDatasetProfileDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + profileId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + datasetVersionId!: string; + + @ApiProperty({ enum: ['COMPLETE', 'DETERMINISTIC_SAMPLE'] }) + @IsIn(['COMPLETE', 'DETERMINISTIC_SAMPLE']) + completeness!: 'COMPLETE' | 'DETERMINISTIC_SAMPLE'; + + @ApiProperty({ maxLength: 96 }) + @IsString() + @MaxLength(96) + samplingMethod!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$', required: false }) + @IsOptional() + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + samplingSeed?: string; + + @ApiProperty({ type: [String], maxItems: 64, required: false }) + @IsOptional() + @IsArray() + @ArrayMaxSize(64) + @IsString({ each: true }) + @MaxLength(128, { each: true }) + excludedScopes?: string[]; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + rowCountScanned!: number; + + @ApiProperty({ minimum: 0, required: false }) + @IsOptional() + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + rowCountAvailable?: number; + + @ApiProperty({ type: DatasetProfileResourceLimitsDto }) + @ValidateNested() + @Type(() => DatasetProfileResourceLimitsDto) + resourceLimits!: DatasetProfileResourceLimitsDto; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + profileFingerprint!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index 00f4d99d..cb120765 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -6,6 +6,8 @@ import { ReferenceEntityController } from './api/reference-entity.controller.js' import { RuleSetController } from './api/rule-set.controller.js'; import { DatasetVersionController } from './api/dataset-version.controller.js'; import { DatasetQualityController } from './api/dataset-quality.controller.js'; +import { DatasetProfileController } from './api/dataset-profile.controller.js'; +import { InMemoryDatasetProfileRepositoryAdapter } from './adapter/in-memory-dataset-profile-repository.adapter.js'; import { InMemoryGovernedDatasetRepositoryAdapter } from './adapter/in-memory-governed-dataset-repository.adapter.js'; import { PrismaGovernedDatasetRepositoryAdapter, @@ -60,6 +62,10 @@ import { DATASET_QUALITY_REPOSITORY_PORT, type DatasetQualityRepositoryPortV1, } from './application/dataset-quality-repository.port.js'; +import { + DATASET_PROFILE_REPOSITORY_PORT, + type DatasetProfileRepositoryPortV1, +} from './application/dataset-profile-repository.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -85,6 +91,7 @@ export interface DsmModuleOptions { readonly datasetQualityRepository?: DatasetQualityRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly datasetQualityDatabase?: DatasetQualityDatabaseClientV1; + readonly datasetProfileRepository?: DatasetProfileRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -100,6 +107,7 @@ export class DsmModule { ReferenceEntityController, DatasetVersionController, DatasetQualityController, + DatasetProfileController, ], providers: [ { @@ -150,6 +158,11 @@ export class DsmModule { ? new InMemoryDatasetQualityRepositoryAdapter() : new PrismaDatasetQualityRepositoryAdapter(options.datasetQualityDatabase)), }, + { + provide: DATASET_PROFILE_REPOSITORY_PORT, + useValue: + options.datasetProfileRepository ?? new InMemoryDatasetProfileRepositoryAdapter(), + }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), diff --git a/services/api/test/features/dsm/dataset-profile.controller.test.ts b/services/api/test/features/dsm/dataset-profile.controller.test.ts new file mode 100644 index 00000000..c0184520 --- /dev/null +++ b/services/api/test/features/dsm/dataset-profile.controller.test.ts @@ -0,0 +1,85 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryDatasetProfileRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000751', + tenantScope: { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000752', + workspaceId: '00000000-0000-4000-8000-000000000753', + }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000754', + idempotencyKey: 'profile-http', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const tenantContext = contextResult.value; + +void test('[DSM-011, IAM-009] profile HTTP surface discloses sampling and resource limits without values', async () => { + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + datasetProfileRepository: new InMemoryDatasetProfileRepositoryAdapter(), + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/dataset-profiles', + payload: { + profileId: '00000000-0000-4000-8000-000000000755', + datasetVersionId: '00000000-0000-4000-8000-000000000756', + completeness: 'DETERMINISTIC_SAMPLE', + samplingMethod: 'HASHED_ROW_RESERVOIR_V1', + samplingSeed: 'a'.repeat(64), + excludedScopes: ['restricted:payroll'], + rowCountScanned: 50, + rowCountAvailable: 100, + resourceLimits: { maxRows: 1000, maxBytes: 1000000, maxDurationMs: 60000 }, + profileFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + sourceValue: 'must-not-be-accepted', + }, + }); + assert.equal(response.statusCode, 400); + assert.doesNotMatch(response.body, /must-not-be-accepted/u); + + const accepted = await app.inject({ + method: 'POST', + url: '/v1/dataset-profiles', + payload: { + profileId: '00000000-0000-4000-8000-000000000755', + datasetVersionId: '00000000-0000-4000-8000-000000000756', + completeness: 'DETERMINISTIC_SAMPLE', + samplingMethod: 'HASHED_ROW_RESERVOIR_V1', + samplingSeed: 'a'.repeat(64), + excludedScopes: ['restricted:payroll'], + rowCountScanned: 50, + rowCountAvailable: 100, + resourceLimits: { maxRows: 1000, maxBytes: 1000000, maxDurationMs: 60000 }, + profileFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }, + }); + assert.equal(accepted.statusCode, 201); + assert.match(accepted.body, /DETERMINISTIC_SAMPLE/u); + assert.doesNotMatch(accepted.body, /sourceValue|rawValue|path/u); + + const listed = await app.inject({ + method: 'GET', + url: '/v1/dataset-profiles?datasetVersionId=00000000-0000-4000-8000-000000000756', + }); + assert.equal(listed.statusCode, 200); + const body: unknown = JSON.parse(listed.body); + assert.ok(Array.isArray(body)); + assert.equal(body.length, 1); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index c497d07f..6f9c2882 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -93,6 +93,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/auth/sign-out', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', + '/v1/dataset-profiles', + '/v1/dataset-profiles/{profileId}', '/v1/dataset-quality-results', '/v1/dataset-quality-results/{resultId}', '/v1/dataset-versions', From 384213be5e64817ac9f899a0f2a194eab39cd95c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:19:32 +0700 Subject: [PATCH 079/177] feat(dsm): persist profile disclosures with Prisma --- .../20260802270000_dsm_profiles/migration.sql | 28 +++ services/api/prisma/schema/dsm.prisma | 26 +++ ...isma-dataset-profile-repository.adapter.ts | 214 ++++++++++++++++++ services/api/src/features/dsm/dsm.module.ts | 11 +- .../prisma-dataset-profile-repository.test.ts | 96 ++++++++ services/api/test/prisma-foundation.test.mjs | 14 ++ 6 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 services/api/prisma/migrations/20260802270000_dsm_profiles/migration.sql create mode 100644 services/api/src/features/dsm/adapter/prisma-dataset-profile-repository.adapter.ts create mode 100644 services/api/test/features/dsm/prisma-dataset-profile-repository.test.ts diff --git a/services/api/prisma/migrations/20260802270000_dsm_profiles/migration.sql b/services/api/prisma/migrations/20260802270000_dsm_profiles/migration.sql new file mode 100644 index 00000000..920f8469 --- /dev/null +++ b/services/api/prisma/migrations/20260802270000_dsm_profiles/migration.sql @@ -0,0 +1,28 @@ +-- DSM-011: persist value-free profile disclosure metadata. +CREATE TABLE "dsm"."dataset_profiles" ( + "id" UUID NOT NULL, + "dataset_version_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "completeness" VARCHAR(32) NOT NULL, + "sampling_method" VARCHAR(96) NOT NULL, + "sampling_seed" CHAR(64), + "excluded_scopes" JSONB NOT NULL, + "row_count_scanned" BIGINT NOT NULL, + "row_count_available" BIGINT, + "max_rows" BIGINT NOT NULL, + "max_bytes" BIGINT NOT NULL, + "max_duration_ms" BIGINT NOT NULL, + "profile_fingerprint" CHAR(64) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dataset_profiles_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "dataset_profiles_dataset_version_idx" + ON "dsm"."dataset_profiles"("dataset_version_id"); + +CREATE INDEX "dataset_profiles_scope_idx" + ON "dsm"."dataset_profiles"("organization_id", "workspace_id", "project_id", "dataset_version_id"); diff --git a/services/api/prisma/schema/dsm.prisma b/services/api/prisma/schema/dsm.prisma index 749b55d6..082f0833 100644 --- a/services/api/prisma/schema/dsm.prisma +++ b/services/api/prisma/schema/dsm.prisma @@ -72,6 +72,32 @@ model DatasetQualityResultRecord { @@schema("dsm") } +/// DSM-011: immutable disclosure of profiling completeness, sampling, exclusions, and budgets. +model DatasetProfileRecord { + id String @id @db.Uuid + datasetVersionId String @map("dataset_version_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + completeness String @db.VarChar(32) + samplingMethod String @map("sampling_method") @db.VarChar(96) + samplingSeed String? @map("sampling_seed") @db.Char(64) + excludedScopes Json @map("excluded_scopes") + rowCountScanned BigInt @map("row_count_scanned") + rowCountAvailable BigInt? @map("row_count_available") + maxRows BigInt @map("max_rows") + maxBytes BigInt @map("max_bytes") + maxDurationMs BigInt @map("max_duration_ms") + profileFingerprint String @map("profile_fingerprint") @db.Char(64) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([datasetVersionId], map: "dataset_profiles_dataset_version_idx") + @@index([organizationId, workspaceId, projectId, datasetVersionId], map: "dataset_profiles_scope_idx") + @@map("dataset_profiles") + @@schema("dsm") +} + /// DSM-025: canonical workspace reference identities are versioned and immutable. model ReferenceEntityVersionRecord { id String @id @db.Uuid diff --git a/services/api/src/features/dsm/adapter/prisma-dataset-profile-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-dataset-profile-repository.adapter.ts new file mode 100644 index 00000000..f5f8c195 --- /dev/null +++ b/services/api/src/features/dsm/adapter/prisma-dataset-profile-repository.adapter.ts @@ -0,0 +1,214 @@ +import { + createDatasetProfileV1, + type DatasetProfileV1, +} from '@databreeze/domain/dataset-profile/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetProfileRepositoryPortV1, + DatasetProfileTransactionPortV1, +} from '../application/dataset-profile-repository.port.js'; + +export interface DatasetProfileDatabaseRowV1 { + readonly id: string; + readonly datasetVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly completeness: string; + readonly samplingMethod: string; + readonly samplingSeed: string | null; + readonly excludedScopes: unknown; + readonly rowCountScanned: bigint | number; + readonly rowCountAvailable: bigint | number | null; + readonly maxRows: bigint | number; + readonly maxBytes: bigint | number; + readonly maxDurationMs: bigint | number; + readonly profileFingerprint: string; + readonly createdAt: Date; +} + +export interface DatasetProfileDatabaseCreateDataV1 + extends Omit< + DatasetProfileDatabaseRowV1, + 'rowCountScanned' | 'rowCountAvailable' | 'maxRows' | 'maxBytes' | 'maxDurationMs' | 'createdAt' + > { + readonly rowCountScanned: bigint; + readonly rowCountAvailable: bigint | null; + readonly maxRows: bigint; + readonly maxBytes: bigint; + readonly maxDurationMs: bigint; + readonly createdAt: Date; +} + +export interface DatasetProfileDatabaseClientV1 { + readonly datasetProfileRecord: { + create(input: { + readonly data: DatasetProfileDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly id: 'asc' }; + }): Promise; + }; + $transaction( + work: (transaction: DatasetProfileDatabaseClientV1) => 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 rowScope(row: DatasetProfileDatabaseRowV1): 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('DSM_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function numberValue(value: bigint | number): number { + const normalized = typeof value === 'bigint' ? Number(value) : value; + if (!Number.isSafeInteger(normalized)) throw new Error('DSM_PERSISTED_PROFILE_COUNT_INVALID'); + return normalized; +} + +function rowToDomain(row: DatasetProfileDatabaseRowV1): DatasetProfileV1 { + const parsed = createDatasetProfileV1({ + profileId: row.id, + datasetVersionId: row.datasetVersionId, + tenantScope: rowScope(row), + completeness: row.completeness, + samplingMethod: row.samplingMethod, + ...(row.samplingSeed === null ? {} : { samplingSeed: row.samplingSeed }), + excludedScopes: row.excludedScopes, + rowCountScanned: numberValue(row.rowCountScanned), + ...(row.rowCountAvailable === null + ? {} + : { rowCountAvailable: numberValue(row.rowCountAvailable) }), + resourceLimits: { + maxRows: numberValue(row.maxRows), + maxBytes: numberValue(row.maxBytes), + maxDurationMs: numberValue(row.maxDurationMs), + }, + profileFingerprint: row.profileFingerprint, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_PROFILE_INVALID'); + return parsed.value; +} + +function domainToCreate(profile: DatasetProfileV1): DatasetProfileDatabaseCreateDataV1 { + return { + ...databaseScope(profile.tenantScope), + id: profile.profileId, + datasetVersionId: profile.datasetVersionId, + completeness: profile.completeness, + samplingMethod: profile.samplingMethod, + samplingSeed: profile.samplingSeed ?? null, + excludedScopes: profile.excludedScopes, + rowCountScanned: BigInt(profile.rowCountScanned), + rowCountAvailable: + profile.rowCountAvailable === undefined ? null : BigInt(profile.rowCountAvailable), + maxRows: BigInt(profile.resourceLimits.maxRows), + maxBytes: BigInt(profile.resourceLimits.maxBytes), + maxDurationMs: BigInt(profile.resourceLimits.maxDurationMs), + profileFingerprint: profile.profileFingerprint, + createdAt: new Date(profile.createdAt), + }; +} + +function visible(context: TenantScopeV1, row: DatasetProfileDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaDatasetProfileTransactionAdapter implements DatasetProfileTransactionPortV1 { + public constructor(private readonly client: DatasetProfileDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, profile: DatasetProfileV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, profile.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.datasetProfileRecord.findUnique({ + where: { id: profile.profileId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(profile)) + throw new Error('DSM_IMMUTABLE_DATASET_PROFILE'); + return; + } + await this.client.datasetProfileRecord.create({ data: domainToCreate(profile) }); + } + + public async find( + context: IamTenantContextV1, + profileId: DatasetProfileV1['profileId'], + ): Promise { + const row = await this.client.datasetProfileRecord.findUnique({ where: { id: profileId } }); + return row === null + ? undefined + : visible(context.tenantScope, row) + ? rowToDomain(row) + : undefined; + } + + public async list( + context: IamTenantContextV1, + datasetVersionId: DatasetProfileV1['datasetVersionId'], + ): Promise { + const rows = await this.client.datasetProfileRecord.findMany({ + where: { datasetVersionId, organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } +} + +export class PrismaDatasetProfileRepositoryAdapter implements DatasetProfileRepositoryPortV1 { + public constructor(private readonly client: DatasetProfileDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetProfileTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaDatasetProfileTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, profile: DatasetProfileV1): Promise { + return new PrismaDatasetProfileTransactionAdapter(this.client).save(context, profile); + } + + public find( + context: IamTenantContextV1, + profileId: DatasetProfileV1['profileId'], + ): Promise { + return new PrismaDatasetProfileTransactionAdapter(this.client).find(context, profileId); + } + + public list( + context: IamTenantContextV1, + datasetVersionId: DatasetProfileV1['datasetVersionId'], + ): Promise { + return new PrismaDatasetProfileTransactionAdapter(this.client).list(context, datasetVersionId); + } +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index cb120765..d44e3da1 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -8,6 +8,10 @@ import { DatasetVersionController } from './api/dataset-version.controller.js'; import { DatasetQualityController } from './api/dataset-quality.controller.js'; import { DatasetProfileController } from './api/dataset-profile.controller.js'; import { InMemoryDatasetProfileRepositoryAdapter } from './adapter/in-memory-dataset-profile-repository.adapter.js'; +import { + PrismaDatasetProfileRepositoryAdapter, + type DatasetProfileDatabaseClientV1, +} from './adapter/prisma-dataset-profile-repository.adapter.js'; import { InMemoryGovernedDatasetRepositoryAdapter } from './adapter/in-memory-governed-dataset-repository.adapter.js'; import { PrismaGovernedDatasetRepositoryAdapter, @@ -92,6 +96,8 @@ export interface DsmModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly datasetQualityDatabase?: DatasetQualityDatabaseClientV1; readonly datasetProfileRepository?: DatasetProfileRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly datasetProfileDatabase?: DatasetProfileDatabaseClientV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -161,7 +167,10 @@ export class DsmModule { { provide: DATASET_PROFILE_REPOSITORY_PORT, useValue: - options.datasetProfileRepository ?? new InMemoryDatasetProfileRepositoryAdapter(), + options.datasetProfileRepository ?? + (options.datasetProfileDatabase === undefined + ? new InMemoryDatasetProfileRepositoryAdapter() + : new PrismaDatasetProfileRepositoryAdapter(options.datasetProfileDatabase)), }, { provide: REQUEST_TENANT_CONTEXT, diff --git a/services/api/test/features/dsm/prisma-dataset-profile-repository.test.ts b/services/api/test/features/dsm/prisma-dataset-profile-repository.test.ts new file mode 100644 index 00000000..2488ab5d --- /dev/null +++ b/services/api/test/features/dsm/prisma-dataset-profile-repository.test.ts @@ -0,0 +1,96 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { createDatasetProfileV1 } from '@databreeze/domain/dataset-profile/v1'; +import { + PrismaDatasetProfileRepositoryAdapter, + type DatasetProfileDatabaseClientV1, + type DatasetProfileDatabaseRowV1, +} from '../../../src/features/dsm/adapter/prisma-dataset-profile-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +function id(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('fixture identifier rejected'); + return parsed.value; +} + +const organizationId = id('00000000-0000-4000-8000-000000000761'); +const workspaceId = id('00000000-0000-4000-8000-000000000762'); +const profileId = id('00000000-0000-4000-8000-000000000763'); + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000764', + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000765', + idempotencyKey: 'prisma-profile', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('fixture context rejected'); + return result.value; +} + +function client(rows: DatasetProfileDatabaseRowV1[]): DatasetProfileDatabaseClientV1 { + return { + datasetProfileRecord: { + create({ data }) { + const persisted = { ...data } as DatasetProfileDatabaseRowV1; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + findMany({ where }) { + return Promise.resolve( + rows + .filter( + (row) => + row.datasetVersionId === where['datasetVersionId'] && + row.organizationId === where['organizationId'], + ) + .sort((left, right) => left.id.localeCompare(right.id)), + ); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('[DSM-011, IAM-009] Prisma profile adapter persists immutable disclosure and hides siblings', async () => { + const tenantContext = context(); + const created = createDatasetProfileV1({ + profileId, + datasetVersionId: '00000000-0000-4000-8000-000000000766', + tenantScope: tenantContext.tenantScope, + completeness: 'DETERMINISTIC_SAMPLE', + samplingMethod: 'HASHED_ROW_RESERVOIR_V1', + samplingSeed: 'a'.repeat(64), + excludedScopes: ['restricted:payroll'], + rowCountScanned: 5, + rowCountAvailable: 10, + resourceLimits: { maxRows: 100, maxBytes: 1000, maxDurationMs: 60000 }, + profileFingerprint: 'b'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const rows: DatasetProfileDatabaseRowV1[] = []; + const repository = new PrismaDatasetProfileRepositoryAdapter(client(rows)); + await repository.save(tenantContext, created.value); + await repository.save(tenantContext, created.value); + assert.deepEqual(await repository.find(tenantContext, profileId), created.value); + assert.deepEqual(await repository.list(tenantContext, created.value.datasetVersionId), [ + created.value, + ]); + assert.equal(rows.length, 1); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index fc83978a..0acc588d 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -63,6 +63,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_definitions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_quality_results"/); + assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_profiles"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_resolutions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."mapping_definitions"/); @@ -112,6 +113,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802240000_iae_upload_sessions', '20260802250000_dsm_quality_results', '20260802260000_iae_inbox_metadata', + '20260802270000_dsm_profiles', 'migration_lock.toml', ]); const migration = await readFile( @@ -429,4 +431,16 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const profileMigration = await readFile( + path.join(migrationsDirectory, inventory[28], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE TABLE "dsm"."dataset_profiles"', + 'CREATE INDEX "dataset_profiles_dataset_version_idx"', + '"sampling_method" VARCHAR(96)', + '"max_duration_ms" BIGINT', + ]) { + assert.match(profileMigration, new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } }); From e415fa7d1b53902d74e2104fc367576e2fc270ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:20:56 +0700 Subject: [PATCH 080/177] feat(dsm): add typed safe finding subjects --- packages/domain/src/dataset-quality/v1.ts | 104 +++++++++++++++++- .../domain/test/dataset-quality-v1.test.mjs | 40 +++++++ 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/dataset-quality/v1.ts b/packages/domain/src/dataset-quality/v1.ts index 31d7f855..60d76374 100644 --- a/packages/domain/src/dataset-quality/v1.ts +++ b/packages/domain/src/dataset-quality/v1.ts @@ -13,6 +13,33 @@ export const DATASET_QUALITY_SCHEMA_VERSION_V1 = 1 as const; export type DatasetQualityFindingSeverityV1 = 'INFO' | 'WARNING' | 'ERROR'; +export type DatasetQualitySafeValueKindV1 = + | 'TEXT' + | 'INTEGER' + | 'DECIMAL' + | 'BOOLEAN' + | 'DATE' + | 'MISSING' + | 'NULL' + | 'BLANK' + | 'INVALID' + | 'ZERO' + | 'NOT_APPLICABLE' + | 'REDACTED'; + +export interface DatasetQualitySafeValueV1 { + readonly kind: DatasetQualitySafeValueKindV1; + readonly value?: string | number | boolean; +} + +export type DatasetQualitySubjectTypeV1 = 'DATASET' | 'ROW' | 'FIELD' | 'CELL'; + +export interface DatasetQualityFindingSubjectV1 { + readonly type: DatasetQualitySubjectTypeV1; + readonly keyHash: string; + readonly fieldId?: StableIdentifierV1; +} + export interface DatasetQualityFindingV1 { readonly findingId: StableIdentifierV1; readonly ruleId: StableIdentifierV1; @@ -21,6 +48,9 @@ export interface DatasetQualityFindingV1 { readonly occurrenceCount: number; readonly evidenceIds: readonly StableIdentifierV1[]; readonly detailHash: string; + readonly subject?: DatasetQualityFindingSubjectV1; + readonly actual?: DatasetQualitySafeValueV1; + readonly expected?: DatasetQualitySafeValueV1; } export interface DatasetQualityResultV1 { @@ -47,7 +77,8 @@ export type DatasetQualityErrorCodeV1 = | 'INVALID_TEXT' | 'INVALID_FINDING' | 'DUPLICATE_FINDING' - | 'INVALID_QUALITY_STATE'; + | 'INVALID_QUALITY_STATE' + | 'INVALID_TYPED_VALUE'; export type DatasetQualityResultV1Of = | { readonly accepted: true; readonly value: TValue } @@ -93,6 +124,68 @@ function positiveCount(input: unknown): number | undefined { return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0 ? input : undefined; } +const safeValueKinds: readonly DatasetQualitySafeValueKindV1[] = [ + 'TEXT', + 'INTEGER', + 'DECIMAL', + 'BOOLEAN', + 'DATE', + 'MISSING', + 'NULL', + 'BLANK', + 'INVALID', + 'ZERO', + 'NOT_APPLICABLE', + 'REDACTED', +]; + +function safeValue(input: unknown): DatasetQualitySafeValueV1 | undefined { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; + const record = input as Record; + const kind = record['kind']; + if (!safeValueKinds.includes(kind as DatasetQualitySafeValueKindV1)) return undefined; + const value = record['value']; + if (value === undefined) { + if (['TEXT', 'INTEGER', 'DECIMAL', 'BOOLEAN', 'DATE'].includes(kind as string)) + return undefined; + return Object.freeze({ kind: kind as DatasetQualitySafeValueKindV1 }); + } + if (typeof value === 'string') { + if (value.length === 0 || value.length > 256 || /\p{Cc}/u.test(value)) return undefined; + return Object.freeze({ + kind: kind as DatasetQualitySafeValueKindV1, + value: value.normalize('NFC'), + }); + } + if (typeof value === 'boolean') { + if (kind !== 'BOOLEAN') return undefined; + return Object.freeze({ kind: 'BOOLEAN', value }); + } + if (typeof value === 'number') { + if (!Number.isFinite(value) || (!Number.isSafeInteger(value) && kind === 'INTEGER')) + return undefined; + if (!['INTEGER', 'DECIMAL'].includes(kind as string)) return undefined; + return Object.freeze({ kind: kind as DatasetQualitySafeValueKindV1, value }); + } + return undefined; +} + +function subject(input: unknown): DatasetQualityFindingSubjectV1 | undefined { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; + const record = input as Record; + const type = record['type']; + const keyHash = hash(record['keyHash']); + const fieldId = record['fieldId'] === undefined ? undefined : identifier(record['fieldId']); + if (!['DATASET', 'ROW', 'FIELD', 'CELL'].includes(type as string) || !keyHash) return undefined; + if (record['fieldId'] !== undefined && !fieldId) return undefined; + if (['FIELD', 'CELL'].includes(type as string) && !fieldId) return undefined; + return Object.freeze({ + type: type as DatasetQualitySubjectTypeV1, + keyHash, + ...(fieldId === undefined ? {} : { fieldId }), + }); +} + function finding(input: unknown): DatasetQualityFindingV1 | undefined { if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; const record = input as Record; @@ -103,6 +196,9 @@ function finding(input: unknown): DatasetQualityFindingV1 | undefined { const occurrenceCount = positiveCount(record['occurrenceCount']); const detailHash = hash(record['detailHash']); const evidenceInput = record['evidenceIds'] ?? []; + const parsedSubject = record['subject'] === undefined ? undefined : subject(record['subject']); + const actual = record['actual'] === undefined ? undefined : safeValue(record['actual']); + const expected = record['expected'] === undefined ? undefined : safeValue(record['expected']); if (!findingId || !ruleId || !messageCode || occurrenceCount === undefined || !detailHash) { return undefined; } @@ -112,6 +208,9 @@ function finding(input: unknown): DatasetQualityFindingV1 | undefined { if (evidenceIds.some((candidate): candidate is undefined => candidate === undefined)) { return undefined; } + if (record['subject'] !== undefined && !parsedSubject) return undefined; + if (record['actual'] !== undefined && !actual) return undefined; + if (record['expected'] !== undefined && !expected) return undefined; return Object.freeze({ findingId, ruleId, @@ -120,6 +219,9 @@ function finding(input: unknown): DatasetQualityFindingV1 | undefined { occurrenceCount, evidenceIds: Object.freeze(evidenceIds as StableIdentifierV1[]), detailHash, + ...(parsedSubject === undefined ? {} : { subject: parsedSubject }), + ...(actual === undefined ? {} : { actual }), + ...(expected === undefined ? {} : { expected }), }); } diff --git a/packages/domain/test/dataset-quality-v1.test.mjs b/packages/domain/test/dataset-quality-v1.test.mjs index e83b9293..2d7a621e 100644 --- a/packages/domain/test/dataset-quality-v1.test.mjs +++ b/packages/domain/test/dataset-quality-v1.test.mjs @@ -89,3 +89,43 @@ void test('[DSM-013] quality result validation rejects malformed hashes, counts, { accepted: false, code: 'DUPLICATE_FINDING' }, ); }); + +void test('[DSM-013] findings may carry bounded typed values and hashed subjects only', () => { + const created = result({ + findings: [ + { + findingId: ids.findingId, + ruleId: ids.ruleId, + severity: 'WARNING', + messageCode: 'NULL_RATE_HIGH', + occurrenceCount: 2, + evidenceIds: [ids.evidenceId], + detailHash: 'b'.repeat(64), + subject: { + type: 'FIELD', + keyHash: 'd'.repeat(64), + fieldId: '00000000-0000-4000-8000-000000000017', + }, + actual: { kind: 'DECIMAL', value: 0.42 }, + expected: { kind: 'DECIMAL', value: 0.1 }, + }, + ], + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(created.value.findings[0].subject?.type, 'FIELD'); + assert.deepEqual(created.value.findings[0].actual, { kind: 'DECIMAL', value: 0.42 }); + const base = result(); + assert.equal(base.accepted, true); + if (!base.accepted) return; + const invalid = createDatasetQualityResultV1({ + ...base.value, + findings: [ + { + ...created.value.findings[0], + actual: { kind: 'TEXT', value: 'a'.repeat(257) }, + }, + ], + }); + assert.deepEqual(invalid, { accepted: false, code: 'INVALID_FINDING' }); +}); From 261c8afa0d14078d00e5198f35ed20157cd31747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:24:20 +0700 Subject: [PATCH 081/177] feat(dsm): expose typed safe finding values --- services/api/openapi/v1.json | 42 +++++++++- .../features/dsm/api/dataset-quality.dto.ts | 78 +++++++++++++++++++ .../src/features/iae/api/inbox-item.dto.ts | 10 ++- .../dsm/dataset-quality.controller.test.ts | 7 ++ 4 files changed, 132 insertions(+), 5 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 2c0bf385..8806a5c9 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -6397,10 +6397,10 @@ "UpdateInboxMetadataDto": { "type": "object", "properties": { - "assigneeId": { "type": "string", "format": "uuid", "nullable": true }, + "assigneeId": { "oneOf": [{ "type": "string", "format": "uuid" }, { "type": "null" }] }, "labels": { "maxItems": 32, "type": "array", "items": { "type": "string" } }, "priority": { "type": "string", "enum": ["LOW", "NORMAL", "HIGH", "URGENT"] }, - "dueAt": { "type": "string", "format": "date-time", "nullable": true }, + "dueAt": { "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, "expectedRevision": { "type": "number", "minimum": 1 } } }, @@ -6736,6 +6736,39 @@ "lineageManifestHash" ] }, + "DatasetQualityFindingSubjectDto": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["DATASET", "ROW", "FIELD", "CELL"] }, + "keyHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "fieldId": { "type": "string", "format": "uuid" } + }, + "required": ["type", "keyHash"] + }, + "DatasetQualitySafeValueDto": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "TEXT", + "INTEGER", + "DECIMAL", + "BOOLEAN", + "DATE", + "MISSING", + "NULL", + "BLANK", + "INVALID", + "ZERO", + "NOT_APPLICABLE", + "REDACTED" + ] + }, + "value": { "oneOf": [{ "type": "string" }, { "type": "number" }, { "type": "boolean" }] } + }, + "required": ["kind"] + }, "DatasetQualityFindingDto": { "type": "object", "properties": { @@ -6745,7 +6778,10 @@ "messageCode": { "type": "string", "minLength": 1, "maxLength": 96 }, "occurrenceCount": { "type": "number", "minimum": 0 }, "evidenceIds": { "type": "array", "items": { "type": "string", "format": "uuid" } }, - "detailHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + "detailHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "subject": { "$ref": "#/components/schemas/DatasetQualityFindingSubjectDto" }, + "actual": { "$ref": "#/components/schemas/DatasetQualitySafeValueDto" }, + "expected": { "$ref": "#/components/schemas/DatasetQualitySafeValueDto" } }, "required": [ "findingId", diff --git a/services/api/src/features/dsm/api/dataset-quality.dto.ts b/services/api/src/features/dsm/api/dataset-quality.dto.ts index 1863646d..d9293b70 100644 --- a/services/api/src/features/dsm/api/dataset-quality.dto.ts +++ b/services/api/src/features/dsm/api/dataset-quality.dto.ts @@ -2,11 +2,13 @@ import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, + Allow, IsArray, IsIn, IsInt, IsString, IsUUID, + IsOptional, Matches, Max, MaxLength, @@ -15,6 +17,64 @@ import { ValidateNested, } from 'class-validator'; +export class DatasetQualitySafeValueDto { + @ApiProperty({ + enum: [ + 'TEXT', + 'INTEGER', + 'DECIMAL', + 'BOOLEAN', + 'DATE', + 'MISSING', + 'NULL', + 'BLANK', + 'INVALID', + 'ZERO', + 'NOT_APPLICABLE', + 'REDACTED', + ], + }) + @IsIn([ + 'TEXT', + 'INTEGER', + 'DECIMAL', + 'BOOLEAN', + 'DATE', + 'MISSING', + 'NULL', + 'BLANK', + 'INVALID', + 'ZERO', + 'NOT_APPLICABLE', + 'REDACTED', + ]) + kind!: string; + + @ApiProperty({ + required: false, + oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], + }) + @IsOptional() + @Allow() + value?: string | number | boolean; +} + +export class DatasetQualityFindingSubjectDto { + @ApiProperty({ enum: ['DATASET', 'ROW', 'FIELD', 'CELL'] }) + @IsIn(['DATASET', 'ROW', 'FIELD', 'CELL']) + type!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + keyHash!: string; + + @ApiProperty({ format: 'uuid', required: false }) + @IsOptional() + @IsUUID() + fieldId?: string; +} + export class DatasetQualityFindingDto { @ApiProperty({ format: 'uuid' }) @IsUUID() @@ -50,6 +110,24 @@ export class DatasetQualityFindingDto { @IsString() @Matches(/^[0-9a-f]{64}$/u) detailHash!: string; + + @ApiProperty({ type: DatasetQualityFindingSubjectDto, required: false }) + @IsOptional() + @ValidateNested() + @Type(() => DatasetQualityFindingSubjectDto) + subject?: DatasetQualityFindingSubjectDto; + + @ApiProperty({ type: DatasetQualitySafeValueDto, required: false }) + @IsOptional() + @ValidateNested() + @Type(() => DatasetQualitySafeValueDto) + actual?: DatasetQualitySafeValueDto; + + @ApiProperty({ type: DatasetQualitySafeValueDto, required: false }) + @IsOptional() + @ValidateNested() + @Type(() => DatasetQualitySafeValueDto) + expected?: DatasetQualitySafeValueDto; } export class RegisterDatasetQualityResultDto { diff --git a/services/api/src/features/iae/api/inbox-item.dto.ts b/services/api/src/features/iae/api/inbox-item.dto.ts index bcec3d00..011d826a 100644 --- a/services/api/src/features/iae/api/inbox-item.dto.ts +++ b/services/api/src/features/iae/api/inbox-item.dto.ts @@ -38,7 +38,10 @@ export class CreateInboxItemDto { /** IAE-013: revisioned, content-free inbox triage metadata patch. */ export class UpdateInboxMetadataDto { - @ApiProperty({ type: String, format: 'uuid', nullable: true, required: false }) + @ApiProperty({ + oneOf: [{ type: 'string', format: 'uuid' }, { type: 'null' }], + required: false, + }) @IsOptional() @IsUUID() assigneeId?: string | null; @@ -57,7 +60,10 @@ export class UpdateInboxMetadataDto { @IsIn(['LOW', 'NORMAL', 'HIGH', 'URGENT']) priority?: InboxPriorityV1; - @ApiProperty({ type: String, format: 'date-time', nullable: true, required: false }) + @ApiProperty({ + oneOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }], + required: false, + }) @IsOptional() @IsISO8601() dueAt?: string | null; diff --git a/services/api/test/features/dsm/dataset-quality.controller.test.ts b/services/api/test/features/dsm/dataset-quality.controller.test.ts index 81b8f47e..8d090ccf 100644 --- a/services/api/test/features/dsm/dataset-quality.controller.test.ts +++ b/services/api/test/features/dsm/dataset-quality.controller.test.ts @@ -54,6 +54,13 @@ void test('[DSM-011, DSM-013, DSM-015] quality HTTP surfaces never accept source occurrenceCount: 3, evidenceIds: [], detailHash: 'b'.repeat(64), + subject: { + type: 'FIELD', + keyHash: 'd'.repeat(64), + fieldId: '00000000-0000-4000-8000-000000000931', + }, + actual: { kind: 'DECIMAL', value: 0.42 }, + expected: { kind: 'DECIMAL', value: 0.1 }, }, ], resultFingerprint: 'c'.repeat(64), From 083e0764aeac926bab0a9ab04a0e55d91af58580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:26:46 +0700 Subject: [PATCH 082/177] feat(dsm): add stable profile pagination --- .../application/dataset-profile.service.ts | 25 +++++++ .../dsm/dataset-profile.pagination.test.ts | 70 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 services/api/test/features/dsm/dataset-profile.pagination.test.ts diff --git a/services/api/src/features/dsm/application/dataset-profile.service.ts b/services/api/src/features/dsm/application/dataset-profile.service.ts index baee7f3a..90c6d5e4 100644 --- a/services/api/src/features/dsm/application/dataset-profile.service.ts +++ b/services/api/src/features/dsm/application/dataset-profile.service.ts @@ -12,6 +12,11 @@ export type DatasetProfileServiceResultV1 = | DatasetProfileResultV1 | { readonly accepted: false; readonly code: DatasetProfileServiceErrorV1 }; +export interface DatasetProfilePageV1 { + readonly items: readonly DatasetProfileV1[]; + readonly nextCursor?: DatasetProfileV1['profileId']; +} + /** Coordinates immutable, value-free profiling disclosure records. */ export class DatasetProfileService { public constructor(private readonly repository: DatasetProfileRepositoryPortV1) {} @@ -52,4 +57,24 @@ export class DatasetProfileService { transaction.list(context, datasetVersionId), ); } + + /** DSM-021: stable cursor pagination is scoped by the exact dataset version and tenant. */ + public async listPage( + context: IamTenantContextV1, + datasetVersionId: DatasetProfileV1['datasetVersionId'], + options: { readonly limit: number; readonly cursor?: DatasetProfileV1['profileId'] }, + ): Promise { + if (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 100) + throw new Error('DSM_INVALID_PAGE_LIMIT'); + const profiles = await this.list(context, datasetVersionId); + const cursor = options.cursor; + const after = + cursor === undefined ? profiles : profiles.filter((profile) => profile.profileId > cursor); + const items = after.slice(0, options.limit); + const last = items.at(-1); + return Object.freeze({ + items: Object.freeze(items), + ...(after.length > items.length && last !== undefined ? { nextCursor: last.profileId } : {}), + }); + } } diff --git a/services/api/test/features/dsm/dataset-profile.pagination.test.ts b/services/api/test/features/dsm/dataset-profile.pagination.test.ts new file mode 100644 index 00000000..aeecc609 --- /dev/null +++ b/services/api/test/features/dsm/dataset-profile.pagination.test.ts @@ -0,0 +1,70 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { InMemoryDatasetProfileRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-profile-repository.adapter.js'; +import { DatasetProfileService } from '../../../src/features/dsm/application/dataset-profile.service.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000771', + tenantScope: { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000772', + workspaceId: '00000000-0000-4000-8000-000000000773', + }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000774', + idempotencyKey: 'profile-pagination', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +function id(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('fixture identifier rejected'); + return parsed.value; +} + +const datasetVersionId = id('00000000-0000-4000-8000-000000000775'); + +function profile(profileId: StableIdentifierV1) { + return { + profileId, + datasetVersionId, + tenantScope: context.tenantScope, + completeness: 'COMPLETE' as const, + samplingMethod: 'FULL_SCAN_V1', + excludedScopes: [], + rowCountScanned: 10, + resourceLimits: { maxRows: 100, maxBytes: 1000, maxDurationMs: 60000 }, + profileFingerprint: 'a'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }; +} + +void test('[DSM-021] profile page cursors are stable and bound to one dataset version', async () => { + const service = new DatasetProfileService(new InMemoryDatasetProfileRepositoryAdapter()); + await service.register(context, profile(id('00000000-0000-4000-8000-000000000776'))); + await service.register(context, profile(id('00000000-0000-4000-8000-000000000777'))); + const first = await service.listPage(context, datasetVersionId, { + limit: 1, + }); + assert.equal(first.items.length, 1); + const firstItem = first.items[0]; + assert.ok(firstItem); + assert.equal(first.nextCursor, firstItem.profileId); + const second = await service.listPage(context, datasetVersionId, { + limit: 1, + cursor: first.nextCursor, + }); + assert.deepEqual( + second.items.map((item) => item.profileId), + ['00000000-0000-4000-8000-000000000777'], + ); + assert.equal(second.nextCursor, undefined); +}); From ebadd9d48d3c89bd3576e84fe60eee8f40af9ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:29:34 +0700 Subject: [PATCH 083/177] feat(dsm): expose cursor-paginated profiles --- services/api/openapi/v1.json | 76 +++++++++++++++++++ .../dsm/api/dataset-profile.controller.ts | 23 ++++++ .../dsm/dataset-profile.controller.test.ts | 42 +++++++++- services/api/test/openapi.test.ts | 1 + 4 files changed, 141 insertions(+), 1 deletion(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 8806a5c9..39bbec63 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -4614,6 +4614,82 @@ "tags": ["datasets"] } }, + "/v1/dataset-profiles/page": { + "get": { + "operationId": "DatasetProfileController.page", + "parameters": [ + { + "name": "datasetVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, + { "name": "limit", "required": true, "in": "query", "schema": { "type": "string" } }, + { "name": "cursor", "required": true, "in": "query", "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 dataset profiles with a stable scoped cursor", + "tags": ["datasets"] + } + }, "/v1/dataset-profiles/{profileId}": { "get": { "operationId": "DatasetProfileController.get", diff --git a/services/api/src/features/dsm/api/dataset-profile.controller.ts b/services/api/src/features/dsm/api/dataset-profile.controller.ts index a780bb82..b6418a4d 100644 --- a/services/api/src/features/dsm/api/dataset-profile.controller.ts +++ b/services/api/src/features/dsm/api/dataset-profile.controller.ts @@ -37,6 +37,29 @@ export class DatasetProfileController { return this.profiles.register(context, { ...input, tenantScope: context.tenantScope }); } + @Get('page') + @ApiOperation({ summary: 'List dataset profiles with a stable scoped cursor' }) + async page( + @Req() request: unknown, + @Query('datasetVersionId') datasetVersionIdInput: string, + @Query('limit') limitInput?: string, + @Query('cursor') cursorInput?: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const datasetVersionId = parseStableIdentifierV1(datasetVersionIdInput); + if (!datasetVersionId.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const limit = limitInput === undefined ? 50 : Number(limitInput); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) + return { accepted: false, code: 'INVALID_PAGE_LIMIT' as const }; + const cursor = cursorInput === undefined ? undefined : parseStableIdentifierV1(cursorInput); + if (cursorInput !== undefined && !cursor?.accepted) + return { accepted: false, code: 'INVALID_CURSOR' as const }; + return this.profiles.listPage(context, datasetVersionId.value, { + limit, + ...(cursor?.accepted ? { cursor: cursor.value } : {}), + }); + } + @Get(':profileId') @ApiOperation({ summary: 'Read an exact immutable dataset profile disclosure' }) async get(@Req() request: unknown, @Param('profileId') profileIdInput: string): Promise { diff --git a/services/api/test/features/dsm/dataset-profile.controller.test.ts b/services/api/test/features/dsm/dataset-profile.controller.test.ts index c0184520..89eb0e2b 100644 --- a/services/api/test/features/dsm/dataset-profile.controller.test.ts +++ b/services/api/test/features/dsm/dataset-profile.controller.test.ts @@ -71,6 +71,46 @@ void test('[DSM-011, IAM-009] profile HTTP surface discloses sampling and resour assert.match(accepted.body, /DETERMINISTIC_SAMPLE/u); assert.doesNotMatch(accepted.body, /sourceValue|rawValue|path/u); + const second = await app.inject({ + method: 'POST', + url: '/v1/dataset-profiles', + payload: { + profileId: '00000000-0000-4000-8000-000000000757', + datasetVersionId: '00000000-0000-4000-8000-000000000756', + completeness: 'COMPLETE', + samplingMethod: 'FULL_SCAN_V1', + rowCountScanned: 100, + resourceLimits: { maxRows: 1000, maxBytes: 1000000, maxDurationMs: 60000 }, + profileFingerprint: 'c'.repeat(64), + createdAt: '2026-01-01T00:00:00.000Z', + }, + }); + assert.equal(second.statusCode, 201); + + const firstPage = await app.inject({ + method: 'GET', + url: '/v1/dataset-profiles/page?datasetVersionId=00000000-0000-4000-8000-000000000756&limit=1', + }); + assert.equal(firstPage.statusCode, 200); + const firstPageBody = JSON.parse(firstPage.body) as { + readonly items: readonly { readonly profileId: string }[]; + readonly nextCursor?: string; + }; + assert.equal(firstPageBody.items.length, 1); + assert.equal(typeof firstPageBody.nextCursor, 'string'); + const secondPage = await app.inject({ + method: 'GET', + url: `/v1/dataset-profiles/page?datasetVersionId=00000000-0000-4000-8000-000000000756&limit=1&cursor=${firstPageBody.nextCursor}`, + }); + assert.equal(secondPage.statusCode, 200); + const secondPageBody = JSON.parse(secondPage.body) as { + readonly items: readonly { readonly profileId: string }[]; + }; + assert.deepEqual( + secondPageBody.items.map((item) => item.profileId), + ['00000000-0000-4000-8000-000000000757'], + ); + const listed = await app.inject({ method: 'GET', url: '/v1/dataset-profiles?datasetVersionId=00000000-0000-4000-8000-000000000756', @@ -78,7 +118,7 @@ void test('[DSM-011, IAM-009] profile HTTP surface discloses sampling and resour assert.equal(listed.statusCode, 200); const body: unknown = JSON.parse(listed.body); assert.ok(Array.isArray(body)); - assert.equal(body.length, 1); + assert.equal(body.length, 2); } finally { await app.close(); } diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 6f9c2882..e135b7e3 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -94,6 +94,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', '/v1/dataset-profiles', + '/v1/dataset-profiles/page', '/v1/dataset-profiles/{profileId}', '/v1/dataset-quality-results', '/v1/dataset-quality-results/{resultId}', From ddb7fd2492c0d50bfe0bfae11d8634fe9f66565a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:32:29 +0700 Subject: [PATCH 084/177] feat(iae): add opaque upload storage boundary --- ...-memory-artifact-upload-storage.adapter.ts | 121 ++++++++++++++++++ .../artifact-upload-storage.port.ts | 52 ++++++++ .../artifact-upload-storage.adapter.test.ts | 62 +++++++++ 3 files changed, 235 insertions(+) create mode 100644 services/api/src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.ts create mode 100644 services/api/src/features/iae/application/artifact-upload-storage.port.ts create mode 100644 services/api/test/features/iae/artifact-upload-storage.adapter.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.ts new file mode 100644 index 00000000..73d8f5a1 --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.ts @@ -0,0 +1,121 @@ +import { randomUUID } from 'node:crypto'; + +import type { + ArtifactUploadPartV1, + ArtifactUploadSessionV1, +} from '@databreeze/domain/artifact-upload/v1'; +import { tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ArtifactUploadPartTransferV1, + ArtifactUploadStoragePortV1, + ArtifactUploadStorageResultV1, +} from '../application/artifact-upload-storage.port.js'; + +function accepted(value: TValue): ArtifactUploadStorageResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected( + code: Exclude, { readonly accepted: true }>['code'], +): ArtifactUploadStorageResultV1 { + return Object.freeze({ accepted: false, code }); +} + +/** Deterministic test adapter that never exposes an object before finalization. */ +export class InMemoryArtifactUploadStorageAdapter implements ArtifactUploadStoragePortV1 { + private transfers = new Map< + string, + { readonly sessionId: string; readonly partNumber: number } + >(); + private parts = new Map(); + private finalized = new Set(); + + public async issuePartTransfer( + context: IamTenantContextV1, + session: ArtifactUploadSessionV1, + partNumber: number, + ): Promise> { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, session.tenantScope)) + return rejected('UPLOAD_STORAGE_SCOPE_DENIED'); + if ( + session.state !== 'OPEN' || + !Number.isSafeInteger(partNumber) || + partNumber < 1 || + partNumber > session.totalParts + ) + return rejected('UPLOAD_STORAGE_NOT_READY'); + const transferId = randomUUID(); + this.transfers.set(transferId, { sessionId: session.sessionId, partNumber }); + return accepted({ + transferId, + sessionId: session.sessionId, + partNumber, + expiresAt: session.expiresAt, + }); + } + + public async verifyPart( + context: IamTenantContextV1, + session: ArtifactUploadSessionV1, + part: ArtifactUploadPartV1, + transferId?: string, + ): Promise> { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, session.tenantScope)) + return rejected('UPLOAD_STORAGE_SCOPE_DENIED'); + if (session.state !== 'OPEN') return rejected('UPLOAD_STORAGE_NOT_READY'); + if (transferId !== undefined) { + const transfer = this.transfers.get(transferId); + if ( + transfer === undefined || + transfer.sessionId !== session.sessionId || + transfer.partNumber !== part.partNumber + ) + return rejected('UPLOAD_STORAGE_TRANSFER_INVALID'); + this.transfers.delete(transferId); + } + const key = `${session.sessionId}:${part.partNumber}`; + const existing = this.parts.get(key); + if ( + existing && + (existing.contentSha256 !== part.contentSha256 || existing.byteSize !== part.byteSize) + ) + return rejected('UPLOAD_STORAGE_PART_REJECTED'); + this.parts.set(key, part); + return accepted(undefined); + } + + public async finalize( + context: IamTenantContextV1, + session: ArtifactUploadSessionV1, + assembledSha256: string, + ): Promise> { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, session.tenantScope)) + return rejected('UPLOAD_STORAGE_SCOPE_DENIED'); + if (session.state !== 'OPEN') return rejected('UPLOAD_STORAGE_NOT_READY'); + if (assembledSha256 !== session.expectedSha256) + return rejected('UPLOAD_STORAGE_DIGEST_MISMATCH'); + if (session.parts.length !== session.totalParts) return rejected('UPLOAD_STORAGE_NOT_READY'); + for (const part of session.parts) { + if (!this.parts.has(`${session.sessionId}:${part.partNumber}`)) + return rejected('UPLOAD_STORAGE_NOT_READY'); + } + this.finalized.add(session.sessionId); + return accepted(undefined); + } + + public async abort(context: IamTenantContextV1, session: ArtifactUploadSessionV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, session.tenantScope)) return; + for (const key of this.parts.keys()) + if (key.startsWith(`${session.sessionId}:`)) this.parts.delete(key); + for (const [transferId, transfer] of this.transfers) { + if (transfer.sessionId === session.sessionId) this.transfers.delete(transferId); + } + this.finalized.delete(session.sessionId); + } +} diff --git a/services/api/src/features/iae/application/artifact-upload-storage.port.ts b/services/api/src/features/iae/application/artifact-upload-storage.port.ts new file mode 100644 index 00000000..6b8e97d9 --- /dev/null +++ b/services/api/src/features/iae/application/artifact-upload-storage.port.ts @@ -0,0 +1,52 @@ +import type { + ArtifactUploadPartV1, + ArtifactUploadSessionV1, +} from '@databreeze/domain/artifact-upload/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const ARTIFACT_UPLOAD_STORAGE_PORT = Symbol('ARTIFACT_UPLOAD_STORAGE_PORT'); + +export interface ArtifactUploadPartTransferV1 { + readonly transferId: string; + readonly sessionId: ArtifactUploadSessionV1['sessionId']; + readonly partNumber: number; + readonly expiresAt: ArtifactUploadSessionV1['expiresAt']; +} + +export type ArtifactUploadStorageErrorCodeV1 = + | 'UPLOAD_STORAGE_SCOPE_DENIED' + | 'UPLOAD_STORAGE_NOT_READY' + | 'UPLOAD_STORAGE_TRANSFER_INVALID' + | 'UPLOAD_STORAGE_PART_REJECTED' + | 'UPLOAD_STORAGE_DIGEST_MISMATCH' + | 'UPLOAD_STORAGE_FINALIZATION_FAILED'; + +export type ArtifactUploadStorageResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ArtifactUploadStorageErrorCodeV1 }; + +/** + * Provider-neutral cloud object boundary. Implementations verify parts and + * publish the object only after final digest validation; no partial locator or + * raw bytes cross this port. + */ +export interface ArtifactUploadStoragePortV1 { + issuePartTransfer( + context: IamTenantContextV1, + session: ArtifactUploadSessionV1, + partNumber: number, + ): Promise>; + verifyPart( + context: IamTenantContextV1, + session: ArtifactUploadSessionV1, + part: ArtifactUploadPartV1, + transferId?: string, + ): Promise>; + finalize( + context: IamTenantContextV1, + session: ArtifactUploadSessionV1, + assembledSha256: string, + ): Promise>; + abort(context: IamTenantContextV1, session: ArtifactUploadSessionV1): Promise; +} diff --git a/services/api/test/features/iae/artifact-upload-storage.adapter.test.ts b/services/api/test/features/iae/artifact-upload-storage.adapter.test.ts new file mode 100644 index 00000000..ccd65598 --- /dev/null +++ b/services/api/test/features/iae/artifact-upload-storage.adapter.test.ts @@ -0,0 +1,62 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createArtifactUploadSessionV1 } from '@databreeze/domain/artifact-upload/v1'; +import { parseStrictUtcTimestampV1 } from '@databreeze/domain/tenant-scope/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { InMemoryArtifactUploadStorageAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000781', + tenantScope: { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000782', + workspaceId: '00000000-0000-4000-8000-000000000783', + }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000784', + idempotencyKey: 'upload-storage', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const timestamp = parseStrictUtcTimestampV1('2026-01-01T00:10:00.000Z'); +if (!timestamp.accepted) throw new Error('fixture timestamp invalid'); + +void test('[IAE-014] storage adapter binds transfer grants to sessions and hides partial objects', async () => { + const session = createArtifactUploadSessionV1({ + sessionId: '00000000-0000-4000-8000-000000000785', + artifactId: '00000000-0000-4000-8000-000000000786', + tenantScope: context.tenantScope, + expectedSha256: 'a'.repeat(64), + expectedByteSize: 4, + mediaType: 'application/octet-stream', + partSize: 4, + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T01:00:00.000Z', + }); + assert.equal(session.accepted, true); + if (!session.accepted) return; + const storage = new InMemoryArtifactUploadStorageAdapter(); + const grant = await storage.issuePartTransfer(context, session.value, 1); + assert.equal(grant.accepted, true); + if (!grant.accepted) return; + const part = { + partNumber: 1, + contentSha256: 'b'.repeat(64), + byteSize: 4, + uploadedAt: timestamp.value, + } as const; + assert.deepEqual( + await storage.verifyPart(context, session.value, part, '00000000-0000-4000-8000-000000000787'), + { accepted: false, code: 'UPLOAD_STORAGE_TRANSFER_INVALID' }, + ); + assert.deepEqual(await storage.verifyPart(context, session.value, part, grant.value.transferId), { + accepted: true, + value: undefined, + }); + assert.deepEqual(await storage.finalize(context, session.value, 'a'.repeat(64)), { + accepted: false, + code: 'UPLOAD_STORAGE_NOT_READY', + }); +}); From 56b61b1a5836ce4a1c77f37c127d1f276c3b729d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:38:37 +0700 Subject: [PATCH 085/177] feat(iae): verify uploads through storage boundary --- .../application/artifact-upload.service.ts | 67 ++++++++++++++++--- .../iae/artifact-upload.service.test.ts | 10 ++- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/services/api/src/features/iae/application/artifact-upload.service.ts b/services/api/src/features/iae/application/artifact-upload.service.ts index 8a3a8214..f4e12e51 100644 --- a/services/api/src/features/iae/application/artifact-upload.service.ts +++ b/services/api/src/features/iae/application/artifact-upload.service.ts @@ -11,15 +11,25 @@ import { tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { ArtifactUploadRepositoryPortV1 } from './artifact-upload-repository.port.js'; +import { InMemoryArtifactUploadStorageAdapter } from '../adapter/in-memory-artifact-upload-storage.adapter.js'; +import type { + ArtifactUploadPartTransferV1, + ArtifactUploadStoragePortV1, + ArtifactUploadStorageResultV1, +} from './artifact-upload-storage.port.js'; export type ArtifactUploadServiceErrorV1 = 'UPLOAD_NOT_FOUND' | 'UPLOAD_SCOPE_NARROWING_REQUIRED'; export type ArtifactUploadServiceResultV1 = | ArtifactUploadResultV1 + | ArtifactUploadStorageResultV1 | { readonly accepted: false; readonly code: ArtifactUploadServiceErrorV1 }; /** Coordinates revisioned upload state without accepting paths, URLs, or raw bytes. */ export class ArtifactUploadService { - public constructor(private readonly repository: ArtifactUploadRepositoryPortV1) {} + public constructor( + private readonly repository: ArtifactUploadRepositoryPortV1, + private readonly storage: ArtifactUploadStoragePortV1 = new InMemoryArtifactUploadStorageAdapter(), + ) {} public async create( context: IamTenantContextV1, @@ -43,9 +53,21 @@ export class ArtifactUploadService { public async recordPart( context: IamTenantContextV1, sessionId: ArtifactUploadSessionV1['sessionId'], - input: Parameters[1], + input: Parameters[1] & { readonly transferId?: string }, ): Promise> { - return this.mutate(context, sessionId, (session) => recordArtifactUploadPartV1(session, input)); + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, sessionId); + if (!current) return Object.freeze({ accepted: false, code: 'UPLOAD_NOT_FOUND' as const }); + const next = recordArtifactUploadPartV1(current, input); + if (!next.accepted) return next; + const part = next.value.parts.find((candidate) => candidate.partNumber === input.partNumber); + if (!part) + return Object.freeze({ accepted: false, code: 'UPLOAD_STORAGE_PART_REJECTED' as const }); + const verified = await this.storage.verifyPart(context, current, part, input.transferId); + if (!verified.accepted) return verified; + await transaction.save(context, next.value); + return next; + }); } public async complete( @@ -53,9 +75,20 @@ export class ArtifactUploadService { sessionId: ArtifactUploadSessionV1['sessionId'], input: Parameters[1], ): Promise> { - return this.mutate(context, sessionId, (session) => - completeArtifactUploadSessionV1(session, input), - ); + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, sessionId); + if (!current) return Object.freeze({ accepted: false, code: 'UPLOAD_NOT_FOUND' as const }); + const next = completeArtifactUploadSessionV1(current, input); + if (!next.accepted) return next; + const finalized = await this.storage.finalize( + context, + current, + input.assembledSha256 as string, + ); + if (!finalized.accepted) return finalized; + await transaction.save(context, next.value); + return next; + }); } public async abort( @@ -63,9 +96,15 @@ export class ArtifactUploadService { sessionId: ArtifactUploadSessionV1['sessionId'], expectedRevision: unknown, ): Promise> { - return this.mutate(context, sessionId, (session) => - abortArtifactUploadSessionV1(session, expectedRevision), - ); + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, sessionId); + if (!current) return Object.freeze({ accepted: false, code: 'UPLOAD_NOT_FOUND' as const }); + const next = abortArtifactUploadSessionV1(current, expectedRevision); + if (!next.accepted) return next; + await this.storage.abort(context, current); + await transaction.save(context, next.value); + return next; + }); } public async expire( @@ -78,6 +117,16 @@ export class ArtifactUploadService { ); } + public async issuePartTransfer( + context: IamTenantContextV1, + sessionId: ArtifactUploadSessionV1['sessionId'], + partNumber: number, + ): Promise> { + const session = await this.repository.find(context, sessionId); + if (!session) return Object.freeze({ accepted: false, code: 'UPLOAD_NOT_FOUND' as const }); + return this.storage.issuePartTransfer(context, session, partNumber); + } + private async mutate( context: IamTenantContextV1, sessionId: ArtifactUploadSessionV1['sessionId'], diff --git a/services/api/test/features/iae/artifact-upload.service.test.ts b/services/api/test/features/iae/artifact-upload.service.test.ts index a76016bc..87a5c5d8 100644 --- a/services/api/test/features/iae/artifact-upload.service.test.ts +++ b/services/api/test/features/iae/artifact-upload.service.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; import { ArtifactUploadService } from '../../../src/features/iae/application/artifact-upload.service.js'; import { InMemoryArtifactUploadRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.js'; +import { InMemoryArtifactUploadStorageAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.js'; const contextResult = createIamTenantContextV1({ actorId: '11111111-1111-4111-8111-111111111111', @@ -20,7 +21,10 @@ if (!contextResult.accepted) throw new Error('fixture context invalid'); const context = contextResult.value; void test('IAE-014 service persists parts and rejects stale completion', async () => { - const service = new ArtifactUploadService(new InMemoryArtifactUploadRepositoryAdapter()); + const service = new ArtifactUploadService( + new InMemoryArtifactUploadRepositoryAdapter(), + new InMemoryArtifactUploadStorageAdapter(), + ); const created = await service.create(context, { sessionId: '55555555-5555-4555-8555-555555555555', artifactId: '66666666-6666-4666-8666-666666666666', @@ -34,7 +38,11 @@ void test('IAE-014 service persists parts and rejects stale completion', async ( }); assert.equal(created.accepted, true); if (!created.accepted) return; + const transfer = await service.issuePartTransfer(context, created.value.sessionId, 1); + assert.equal(transfer.accepted, true); + if (!transfer.accepted) return; const part = await service.recordPart(context, created.value.sessionId, { + transferId: transfer.value.transferId, partNumber: 1, contentSha256: 'b'.repeat(64), byteSize: 4, From 79ebbfde1a6cae73ffe69a46f1aead8f4ffc7c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:38:41 +0700 Subject: [PATCH 086/177] feat(iae): expose opaque upload transfer grants --- services/api/openapi/v1.json | 92 ++++++++++++++++++- .../iae/api/artifact-upload.controller.ts | 22 ++++- .../features/iae/api/artifact-upload.dto.ts | 12 +++ services/api/src/features/iae/iae.module.ts | 11 +++ .../iae/artifact-upload.controller.test.ts | 16 ++++ services/api/test/openapi.test.ts | 1 + 6 files changed, 152 insertions(+), 2 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 39bbec63..31c452dc 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -2412,6 +2412,83 @@ "tags": ["artifacts"] } }, + "/v1/artifact-upload-sessions/{sessionId}/parts/transfer": { + "post": { + "operationId": "ArtifactUploadController.issuePartTransfer", + "parameters": [ + { "name": "sessionId", "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/IssueArtifactUploadTransferDto" } + } + } + }, + "responses": { + "201": { + "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": "Issue one opaque upload-part transfer grant", + "tags": ["artifacts"] + } + }, "/v1/artifact-upload-sessions": { "post": { "operationId": "ArtifactUploadController.create", @@ -6575,6 +6652,11 @@ }, "required": ["manifestId", "versionIds", "approvalState", "createdAt"] }, + "IssueArtifactUploadTransferDto": { + "type": "object", + "properties": { "partNumber": { "type": "number", "minimum": 1, "maximum": 1000000 } }, + "required": ["partNumber"] + }, "CreateArtifactUploadSessionDto": { "type": "object", "properties": { @@ -6601,13 +6683,21 @@ "RecordArtifactUploadPartDto": { "type": "object", "properties": { + "transferId": { "type": "string", "format": "uuid" }, "partNumber": { "type": "number", "minimum": 1 }, "contentSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "byteSize": { "type": "number", "minimum": 0 }, "uploadedAt": { "type": "string", "format": "date-time" }, "expectedRevision": { "type": "number", "minimum": 1 } }, - "required": ["partNumber", "contentSha256", "byteSize", "uploadedAt", "expectedRevision"] + "required": [ + "transferId", + "partNumber", + "contentSha256", + "byteSize", + "uploadedAt", + "expectedRevision" + ] }, "CompleteArtifactUploadDto": { "type": "object", diff --git a/services/api/src/features/iae/api/artifact-upload.controller.ts b/services/api/src/features/iae/api/artifact-upload.controller.ts index 6b43131c..95df9092 100644 --- a/services/api/src/features/iae/api/artifact-upload.controller.ts +++ b/services/api/src/features/iae/api/artifact-upload.controller.ts @@ -11,8 +11,13 @@ import { AbortArtifactUploadDto, CompleteArtifactUploadDto, CreateArtifactUploadSessionDto, + IssueArtifactUploadTransferDto, RecordArtifactUploadPartDto, } from './artifact-upload.dto.js'; +import { + ARTIFACT_UPLOAD_STORAGE_PORT, + type ArtifactUploadStoragePortV1, +} from '../application/artifact-upload-storage.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -27,9 +32,24 @@ export class ArtifactUploadController { public constructor( @Inject(ARTIFACT_UPLOAD_REPOSITORY_PORT) repository: ArtifactUploadRepositoryPortV1, + @Inject(ARTIFACT_UPLOAD_STORAGE_PORT) storage: ArtifactUploadStoragePortV1, @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, ) { - this.uploads = new ArtifactUploadService(repository); + this.uploads = new ArtifactUploadService(repository, storage); + } + + @Post(':sessionId/parts/transfer') + @ApiOperation({ summary: 'Issue one opaque upload-part transfer grant' }) + @ApiBody({ type: IssueArtifactUploadTransferDto }) + async issuePartTransfer( + @Req() request: unknown, + @Param('sessionId') sessionIdInput: string, + @Body() input: IssueArtifactUploadTransferDto, + ): Promise { + const context = await this.requestContext.resolve(request); + const sessionId = parseStableIdentifierV1(sessionIdInput); + if (!sessionId.accepted) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + return this.uploads.issuePartTransfer(context, sessionId.value, input.partNumber); } @Post() diff --git a/services/api/src/features/iae/api/artifact-upload.dto.ts b/services/api/src/features/iae/api/artifact-upload.dto.ts index 6740e832..333eeba6 100644 --- a/services/api/src/features/iae/api/artifact-upload.dto.ts +++ b/services/api/src/features/iae/api/artifact-upload.dto.ts @@ -39,6 +39,10 @@ export class CreateArtifactUploadSessionDto { } export class RecordArtifactUploadPartDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + transferId!: string; + @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) @@ -63,6 +67,14 @@ export class RecordArtifactUploadPartDto { expectedRevision!: number; } +export class IssueArtifactUploadTransferDto { + @ApiProperty({ minimum: 1, maximum: 1000000 }) + @IsInt() + @Min(1) + @Max(1000000) + partNumber!: number; +} + export class CompleteArtifactUploadDto { @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) @Matches(/^[0-9a-f]{64}$/u) diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 54a640ac..b4a84901 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -31,6 +31,7 @@ import { type ArtifactExportDatabaseClientV1, } from './adapter/prisma-artifact-export-repository.adapter.js'; import { InMemoryArtifactUploadRepositoryAdapter } from './adapter/in-memory-artifact-upload-repository.adapter.js'; +import { InMemoryArtifactUploadStorageAdapter } from './adapter/in-memory-artifact-upload-storage.adapter.js'; import { PrismaArtifactUploadRepositoryAdapter, type ArtifactUploadDatabaseClientV1, @@ -68,6 +69,10 @@ import { ARTIFACT_UPLOAD_REPOSITORY_PORT, type ArtifactUploadRepositoryPortV1, } from './application/artifact-upload-repository.port.js'; +import { + ARTIFACT_UPLOAD_STORAGE_PORT, + type ArtifactUploadStoragePortV1, +} from './application/artifact-upload-storage.port.js'; import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1, @@ -97,6 +102,7 @@ export interface IaeModuleOptions { readonly artifactUploadRepository?: ArtifactUploadRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactUploadDatabase?: ArtifactUploadDatabaseClientV1; + readonly artifactUploadStorage?: ArtifactUploadStoragePortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly evidenceGrantDatabase?: EvidenceGrantDatabaseClientV1; @@ -168,6 +174,10 @@ export class IaeModule { ? new InMemoryArtifactUploadRepositoryAdapter() : new PrismaArtifactUploadRepositoryAdapter(options.artifactUploadDatabase)), }, + { + provide: ARTIFACT_UPLOAD_STORAGE_PORT, + useValue: options.artifactUploadStorage ?? new InMemoryArtifactUploadStorageAdapter(), + }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, useValue: @@ -188,6 +198,7 @@ export class IaeModule { ARTIFACT_RETENTION_REPOSITORY_PORT, ARTIFACT_EXPORT_REPOSITORY_PORT, ARTIFACT_UPLOAD_REPOSITORY_PORT, + ARTIFACT_UPLOAD_STORAGE_PORT, EVIDENCE_GRANT_REPOSITORY_PORT, ], }; diff --git a/services/api/test/features/iae/artifact-upload.controller.test.ts b/services/api/test/features/iae/artifact-upload.controller.test.ts index b98f9dc5..78ec2f59 100644 --- a/services/api/test/features/iae/artifact-upload.controller.test.ts +++ b/services/api/test/features/iae/artifact-upload.controller.test.ts @@ -45,6 +45,22 @@ void test('IAE-014 upload HTTP control plane never accepts source bytes or paths }); assert.equal(response.statusCode, 201); assert.doesNotMatch(response.body, /sourcePath|localPath|rawBytes|excerpt/iu); + + const transfer = await app.inject({ + method: 'POST', + url: '/v1/artifact-upload-sessions/55555555-5555-4555-8555-555555555555/parts/transfer', + payload: { partNumber: 1 }, + }); + assert.equal(transfer.statusCode, 201); + const transferBody = JSON.parse(transfer.body) as { + accepted: boolean; + value?: { transferId?: string; sessionId?: string; partNumber?: number }; + }; + assert.equal(transferBody.accepted, true); + assert.equal(transferBody.value?.sessionId, '55555555-5555-4555-8555-555555555555'); + assert.equal(transferBody.value?.partNumber, 1); + assert.match(transferBody.value?.transferId ?? '', /^[0-9a-f-]{36}$/u); + assert.doesNotMatch(transfer.body, /url|path|bytes|locator/iu); } finally { await app.close(); } diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index e135b7e3..3f76d4d5 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -69,6 +69,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifact-upload-sessions/{sessionId}/abort', '/v1/artifact-upload-sessions/{sessionId}/complete', '/v1/artifact-upload-sessions/{sessionId}/parts', + '/v1/artifact-upload-sessions/{sessionId}/parts/transfer', '/v1/artifact-versions/{versionId}', '/v1/artifact-versions/{versionId}/admit', '/v1/artifact-versions/{versionId}/deletion-requests', From 83982e8eecd33f19437ad65db958e11a4746b1fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:41:10 +0700 Subject: [PATCH 087/177] feat(iae): add secret-free protected document unlock contract --- packages/domain/package.json | 4 + packages/domain/src/protected-document/v1.ts | 202 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/built-public-api-smoke.mjs | 3 + .../test/protected-document-v1.test.mjs | 58 +++++ packages/domain/test/public-api-v1.test.mjs | 2 + 6 files changed, 270 insertions(+) create mode 100644 packages/domain/src/protected-document/v1.ts create mode 100644 packages/domain/test/protected-document-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index 87bbba70..c28c6afe 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -84,6 +84,10 @@ "types": "./src/artifact-upload/v1.ts", "import": "./dist/artifact-upload/v1.js" }, + "./protected-document/v1": { + "types": "./src/protected-document/v1.ts", + "import": "./dist/protected-document/v1.js" + }, "./dataset/v1": { "types": "./src/dataset/v1.ts", "import": "./dist/dataset/v1.js" diff --git a/packages/domain/src/protected-document/v1.ts b/packages/domain/src/protected-document/v1.ts new file mode 100644 index 00000000..cfec850b --- /dev/null +++ b/packages/domain/src/protected-document/v1.ts @@ -0,0 +1,202 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** IAE-015: unlock requests contain state only; secret material never enters this contract. */ +export const PROTECTED_DOCUMENT_SCHEMA_VERSION_V1 = 1 as const; + +export type ProtectedDocumentUnlockModeV1 = 'LOCAL_SECRET_INPUT' | 'DEVICE_KEYCHAIN'; +export type ProtectedDocumentUnlockStateV1 = 'REQUESTED' | 'UNLOCKED' | 'FAILED' | 'EXPIRED'; +export type ProtectedDocumentUnlockFailureCodeV1 = + | 'UNLOCK_REJECTED' + | 'LOCAL_DEVICE_UNAVAILABLE' + | 'UNSUPPORTED_DOCUMENT' + | 'MAX_ATTEMPTS'; + +export interface ProtectedDocumentUnlockRequestV1 { + readonly schemaVersion: typeof PROTECTED_DOCUMENT_SCHEMA_VERSION_V1; + readonly requestId: StableIdentifierV1; + readonly artifactVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly deviceId?: StableIdentifierV1; + readonly mode: ProtectedDocumentUnlockModeV1; + readonly state: ProtectedDocumentUnlockStateV1; + readonly attemptCount: number; + readonly maxAttempts: number; + readonly lastFailureCode?: ProtectedDocumentUnlockFailureCodeV1; + readonly createdAt: StrictUtcTimestampV1; + readonly expiresAt: StrictUtcTimestampV1; + readonly revision: number; +} + +export type ProtectedDocumentUnlockResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ProtectedDocumentUnlockErrorCodeV1 }; + +export type ProtectedDocumentUnlockErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TIMESTAMP' + | 'INVALID_MODE' + | 'INVALID_ATTEMPTS' + | 'INVALID_OUTCOME' + | 'INVALID_FAILURE_CODE' + | 'INVALID_STATE' + | 'REVISION_CONFLICT' + | 'EXPIRED' + | 'MAX_ATTEMPTS'; + +const modes = new Set(['LOCAL_SECRET_INPUT', 'DEVICE_KEYCHAIN']); +const failureCodes = new Set([ + 'UNLOCK_REJECTED', + 'LOCAL_DEVICE_UNAVAILABLE', + 'UNSUPPORTED_DOCUMENT', + 'MAX_ATTEMPTS', +]); + +function accepted(value: TValue): ProtectedDocumentUnlockResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected( + code: ProtectedDocumentUnlockErrorCodeV1, +): ProtectedDocumentUnlockResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function mode(input: unknown): ProtectedDocumentUnlockModeV1 | undefined { + return typeof input === 'string' && modes.has(input as ProtectedDocumentUnlockModeV1) + ? (input as ProtectedDocumentUnlockModeV1) + : undefined; +} + +function failureCode(input: unknown): ProtectedDocumentUnlockFailureCodeV1 | undefined { + return typeof input === 'string' && + failureCodes.has(input as ProtectedDocumentUnlockFailureCodeV1) + ? (input as ProtectedDocumentUnlockFailureCodeV1) + : undefined; +} + +function revision(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input > 0 ? input : undefined; +} + +function attempts(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 && input <= 10 + ? input + : undefined; +} + +export function createProtectedDocumentUnlockRequestV1(input: { + readonly requestId: unknown; + readonly artifactVersionId: unknown; + readonly tenantScope: unknown; + readonly deviceId?: unknown; + readonly mode: unknown; + readonly maxAttempts?: unknown; + readonly createdAt: unknown; + readonly expiresAt: unknown; +}): ProtectedDocumentUnlockResultV1 { + const requestId = identifier(input.requestId); + const artifactVersionId = identifier(input.artifactVersionId); + const tenantScope = parseTenantScopeV1(input.tenantScope); + const deviceId = input.deviceId === undefined ? undefined : identifier(input.deviceId); + const modeValue = mode(input.mode); + const maxAttempts = input.maxAttempts === undefined ? 3 : attempts(input.maxAttempts); + const createdAt = timestamp(input.createdAt); + const expiresAt = timestamp(input.expiresAt); + if (!requestId || !artifactVersionId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (input.deviceId !== undefined && !deviceId) return rejected('INVALID_IDENTIFIER'); + if (!modeValue) return rejected('INVALID_MODE'); + if (!maxAttempts) return rejected('INVALID_ATTEMPTS'); + if (!createdAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(createdAt)) + return rejected('INVALID_TIMESTAMP'); + if (Date.parse(expiresAt) - Date.parse(createdAt) > 24 * 60 * 60 * 1000) + return rejected('INVALID_TIMESTAMP'); + if (modeValue === 'DEVICE_KEYCHAIN' && !deviceId) return rejected('INVALID_IDENTIFIER'); + return accepted( + Object.freeze({ + schemaVersion: PROTECTED_DOCUMENT_SCHEMA_VERSION_V1, + requestId, + artifactVersionId, + tenantScope: tenantScope.value, + ...(deviceId === undefined ? {} : { deviceId }), + mode: modeValue, + state: 'REQUESTED' as const, + attemptCount: 0, + maxAttempts, + createdAt, + expiresAt, + revision: 1, + }), + ); +} + +export function recordProtectedDocumentUnlockResultV1( + request: ProtectedDocumentUnlockRequestV1, + input: { + readonly expectedRevision: unknown; + readonly outcome: unknown; + readonly failureCode?: unknown; + readonly occurredAt: unknown; + }, +): ProtectedDocumentUnlockResultV1 { + if (request.state !== 'REQUESTED') return rejected('INVALID_STATE'); + if (input.expectedRevision !== request.revision) return rejected('REVISION_CONFLICT'); + const occurredAt = timestamp(input.occurredAt); + if (!occurredAt) return rejected('INVALID_TIMESTAMP'); + if (Date.parse(occurredAt) >= Date.parse(request.expiresAt)) return rejected('EXPIRED'); + if (input.outcome !== 'UNLOCKED' && input.outcome !== 'FAILED') + return rejected('INVALID_OUTCOME'); + const nextAttemptCount = request.attemptCount + 1; + if (nextAttemptCount > request.maxAttempts) return rejected('MAX_ATTEMPTS'); + if (input.outcome === 'UNLOCKED') + return accepted( + Object.freeze({ + ...request, + state: 'UNLOCKED' as const, + attemptCount: nextAttemptCount, + revision: request.revision + 1, + }), + ); + const code = input.failureCode === undefined ? 'UNLOCK_REJECTED' : failureCode(input.failureCode); + if (!code) return rejected('INVALID_FAILURE_CODE'); + return accepted( + Object.freeze({ + ...request, + state: nextAttemptCount >= request.maxAttempts ? ('FAILED' as const) : ('REQUESTED' as const), + attemptCount: nextAttemptCount, + lastFailureCode: code, + revision: request.revision + 1, + }), + ); +} + +export function expireProtectedDocumentUnlockRequestV1( + request: ProtectedDocumentUnlockRequestV1, + now: unknown, +): ProtectedDocumentUnlockResultV1 { + const timestampValue = timestamp(now); + if (!timestampValue) return rejected('INVALID_TIMESTAMP'); + if (request.state !== 'REQUESTED') return rejected('INVALID_STATE'); + if (Date.parse(timestampValue) < Date.parse(request.expiresAt)) return rejected('EXPIRED'); + return accepted( + Object.freeze({ ...request, state: 'EXPIRED' as const, revision: request.revision + 1 }), + ); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 26dd8ab6..865b9864 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -6,6 +6,7 @@ export * from './artifact-governance/v1.js'; export * from './artifact-retention/v1.js'; export * from './artifact-export/v1.js'; export * from './artifact-upload/v1.js'; +export * from './protected-document/v1.js'; export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; export * from './dataset-quality/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 85c1613c..d9c679ed 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -11,6 +11,7 @@ const [ artifactRetention, artifactExport, artifactUpload, + protectedDocument, dataset, datasetGovernance, datasetQuality, @@ -38,6 +39,7 @@ const [ import('@databreeze/domain/artifact-retention/v1'), import('@databreeze/domain/artifact-export/v1'), import('@databreeze/domain/artifact-upload/v1'), + import('@databreeze/domain/protected-document/v1'), import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/dataset-quality/v1'), @@ -67,6 +69,7 @@ assert.equal(artifactGovernance.ARTIFACT_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(artifactRetention.ARTIFACT_RETENTION_SCHEMA_VERSION_V1, 1); assert.equal(artifactExport.ARTIFACT_EXPORT_SCHEMA_VERSION_V1, 1); assert.equal(artifactUpload.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); +assert.equal(protectedDocument.PROTECTED_DOCUMENT_SCHEMA_VERSION_V1, 1); assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(datasetQuality.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/protected-document-v1.test.mjs b/packages/domain/test/protected-document-v1.test.mjs new file mode 100644 index 00000000..6cde0b01 --- /dev/null +++ b/packages/domain/test/protected-document-v1.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createProtectedDocumentUnlockRequestV1, + expireProtectedDocumentUnlockRequestV1, + recordProtectedDocumentUnlockResultV1, +} from '../dist/protected-document/v1.js'; + +const base = { + requestId: '11111111-1111-4111-8111-111111111111', + artifactVersionId: '22222222-2222-4222-8222-222222222222', + tenantScope: { + scopeType: 'workspace', + organizationId: '33333333-3333-4333-8333-333333333333', + workspaceId: '44444444-4444-4444-8444-444444444444', + }, + deviceId: '55555555-5555-4555-8555-555555555555', + mode: 'DEVICE_KEYCHAIN', + maxAttempts: 2, + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T00:30:00.000Z', +}; + +void test('[IAE-015] unlock state never carries credential material and supports bounded retries', () => { + const created = createProtectedDocumentUnlockRequestV1(base); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(Object.hasOwn(created.value, 'password'), false); + assert.equal(Object.hasOwn(created.value, 'secret'), false); + const failed = recordProtectedDocumentUnlockResultV1(created.value, { + expectedRevision: 1, + outcome: 'FAILED', + failureCode: 'UNLOCK_REJECTED', + occurredAt: '2026-08-02T00:05:00.000Z', + }); + assert.equal(failed.accepted, true); + if (!failed.accepted) return; + assert.equal(failed.value.state, 'REQUESTED'); + const unlocked = recordProtectedDocumentUnlockResultV1(failed.value, { + expectedRevision: 2, + outcome: 'UNLOCKED', + occurredAt: '2026-08-02T00:06:00.000Z', + }); + assert.equal(unlocked.accepted, true); + if (unlocked.accepted) assert.equal(unlocked.value.state, 'UNLOCKED'); +}); + +void test('[IAE-015] device-keychain requests require a device and expire without a secret', () => { + const missingDevice = createProtectedDocumentUnlockRequestV1({ ...base, deviceId: undefined }); + assert.deepEqual(missingDevice, { accepted: false, code: 'INVALID_IDENTIFIER' }); + const created = createProtectedDocumentUnlockRequestV1(base); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const expired = expireProtectedDocumentUnlockRequestV1(created.value, '2026-08-02T00:30:00.000Z'); + assert.equal(expired.accepted, true); + if (expired.accepted) assert.equal(expired.value.state, 'EXPIRED'); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 01dab94f..618e8d28 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -29,6 +29,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './artifact-retention/v1', './artifact-export/v1', './artifact-upload/v1', + './protected-document/v1', './dataset/v1', './dataset-governance/v1', './dataset-quality/v1', @@ -72,6 +73,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.PROTECTED_DOCUMENT_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.createScopedAuthorizationEvaluatorV1, 'function'); assert.equal(aggregate.MAPPING_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.RULE_SET_SCHEMA_VERSION_V1, 1); From b2beaafaa3ea32f78d014819a4a00eac680240c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:44:18 +0700 Subject: [PATCH 088/177] feat(iae): coordinate protected document unlock attempts --- ...protected-document-secret-input.adapter.ts | 75 ++++++++++ ...cted-document-unlock-repository.adapter.ts | 79 +++++++++++ .../protected-document-secret-input.port.ts | 38 +++++ ...otected-document-unlock-repository.port.ts | 23 +++ .../protected-document-unlock.service.ts | 132 ++++++++++++++++++ .../protected-document-unlock.service.test.ts | 58 ++++++++ 6 files changed, 405 insertions(+) create mode 100644 services/api/src/features/iae/adapter/in-memory-protected-document-secret-input.adapter.ts create mode 100644 services/api/src/features/iae/adapter/in-memory-protected-document-unlock-repository.adapter.ts create mode 100644 services/api/src/features/iae/application/protected-document-secret-input.port.ts create mode 100644 services/api/src/features/iae/application/protected-document-unlock-repository.port.ts create mode 100644 services/api/src/features/iae/application/protected-document-unlock.service.ts create mode 100644 services/api/test/features/iae/protected-document-unlock.service.test.ts diff --git a/services/api/src/features/iae/adapter/in-memory-protected-document-secret-input.adapter.ts b/services/api/src/features/iae/adapter/in-memory-protected-document-secret-input.adapter.ts new file mode 100644 index 00000000..16b92906 --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-protected-document-secret-input.adapter.ts @@ -0,0 +1,75 @@ +import { randomUUID } from 'node:crypto'; + +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { ProtectedDocumentUnlockRequestV1 } from '@databreeze/domain/protected-document/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ProtectedDocumentSecretInputPortV1, + ProtectedDocumentSecretInputResultV1, + ProtectedDocumentUnlockHandleV1, +} from '../application/protected-document-secret-input.port.js'; + +function accepted(value: TValue): ProtectedDocumentSecretInputResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected( + code: Exclude, { readonly accepted: true }>['code'], +): ProtectedDocumentSecretInputResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate); +} + +/** Test/local adapter that models a one-shot OS-secret prompt without storing its value. */ +export class InMemoryProtectedDocumentSecretInputAdapter + implements ProtectedDocumentSecretInputPortV1 +{ + private handles = new Map(); + + public constructor(private readonly now: () => string = () => new Date().toISOString()) {} + + public async issue( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + ): Promise> { + await Promise.resolve(); + if (!visible(context.tenantScope, request.tenantScope)) return rejected('UNLOCK_SCOPE_DENIED'); + if (request.state !== 'REQUESTED') return rejected('UNLOCK_HANDLE_INVALID'); + if (Date.parse(request.expiresAt) <= Date.parse(this.now())) + return rejected('UNLOCK_HANDLE_EXPIRED'); + const handleId = randomUUID(); + this.handles.set(handleId, { requestId: request.requestId, expiresAt: request.expiresAt }); + return accepted({ handleId, requestId: request.requestId, expiresAt: request.expiresAt }); + } + + public async consume( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + handleId: string, + outcome: 'UNLOCKED' | 'FAILED', + ): Promise> { + await Promise.resolve(); + if (!visible(context.tenantScope, request.tenantScope)) return rejected('UNLOCK_SCOPE_DENIED'); + const handle = this.handles.get(handleId); + if (!handle || handle.requestId !== request.requestId) return rejected('UNLOCK_HANDLE_INVALID'); + this.handles.delete(handleId); + if (Date.parse(handle.expiresAt) <= Date.parse(this.now())) + return rejected('UNLOCK_HANDLE_EXPIRED'); + void outcome; + return accepted(undefined); + } + + public async release( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + ): Promise { + await Promise.resolve(); + if (!visible(context.tenantScope, request.tenantScope)) return; + for (const [handleId, handle] of this.handles) + if (handle.requestId === request.requestId) this.handles.delete(handleId); + } +} diff --git a/services/api/src/features/iae/adapter/in-memory-protected-document-unlock-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-protected-document-unlock-repository.adapter.ts new file mode 100644 index 00000000..123346fe --- /dev/null +++ b/services/api/src/features/iae/adapter/in-memory-protected-document-unlock-repository.adapter.ts @@ -0,0 +1,79 @@ +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { ProtectedDocumentUnlockRequestV1 } from '@databreeze/domain/protected-document/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ProtectedDocumentUnlockRepositoryPortV1, + ProtectedDocumentUnlockTransactionPortV1, +} from '../application/protected-document-unlock-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(request: ProtectedDocumentUnlockRequestV1): ProtectedDocumentUnlockRequestV1 { + return Object.freeze({ + ...request, + tenantScope: Object.freeze({ ...request.tenantScope }), + }); +} + +export class InMemoryProtectedDocumentUnlockRepositoryAdapter + implements ProtectedDocumentUnlockRepositoryPortV1 +{ + private requests = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, request.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = this.requests.get(request.requestId); + if (existing && JSON.stringify(existing) === JSON.stringify(request)) return; + if (existing) { + if (request.revision !== existing.revision + 1) throw new Error('IAE_REVISION_CONFLICT'); + if ( + existing.artifactVersionId !== request.artifactVersionId || + existing.createdAt !== request.createdAt || + JSON.stringify(existing.tenantScope) !== JSON.stringify(request.tenantScope) + ) + throw new Error('IAE_IMMUTABLE_UNLOCK_REQUEST'); + } + this.requests.set(request.requestId, clone(request)); + } + + public async find( + context: IamTenantContextV1, + requestId: ProtectedDocumentUnlockRequestV1['requestId'], + ): Promise { + await Promise.resolve(); + const request = this.requests.get(requestId); + return request && visible(context.tenantScope, request.tenantScope) + ? clone(request) + : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ProtectedDocumentUnlockTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.requests); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + } catch (error) { + this.requests = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iae/application/protected-document-secret-input.port.ts b/services/api/src/features/iae/application/protected-document-secret-input.port.ts new file mode 100644 index 00000000..b7f8579a --- /dev/null +++ b/services/api/src/features/iae/application/protected-document-secret-input.port.ts @@ -0,0 +1,38 @@ +import type { ProtectedDocumentUnlockRequestV1 } from '@databreeze/domain/protected-document/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const PROTECTED_DOCUMENT_SECRET_INPUT_PORT = Symbol('PROTECTED_DOCUMENT_SECRET_INPUT_PORT'); + +export interface ProtectedDocumentUnlockHandleV1 { + readonly handleId: string; + readonly requestId: ProtectedDocumentUnlockRequestV1['requestId']; + readonly expiresAt: ProtectedDocumentUnlockRequestV1['expiresAt']; +} + +export type ProtectedDocumentSecretInputErrorCodeV1 = + | 'UNLOCK_HANDLE_INVALID' + | 'UNLOCK_HANDLE_EXPIRED' + | 'UNLOCK_SCOPE_DENIED'; + +export type ProtectedDocumentSecretInputResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ProtectedDocumentSecretInputErrorCodeV1 }; + +/** + * Local/sidecar boundary for secret entry. The port receives only an opaque + * one-shot handle and an outcome; plaintext credentials never cross the API. + */ +export interface ProtectedDocumentSecretInputPortV1 { + issue( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + ): Promise>; + consume( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + handleId: string, + outcome: 'UNLOCKED' | 'FAILED', + ): Promise>; + release(context: IamTenantContextV1, request: ProtectedDocumentUnlockRequestV1): Promise; +} diff --git a/services/api/src/features/iae/application/protected-document-unlock-repository.port.ts b/services/api/src/features/iae/application/protected-document-unlock-repository.port.ts new file mode 100644 index 00000000..93f3586a --- /dev/null +++ b/services/api/src/features/iae/application/protected-document-unlock-repository.port.ts @@ -0,0 +1,23 @@ +import type { ProtectedDocumentUnlockRequestV1 } from '@databreeze/domain/protected-document/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT = Symbol( + 'PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT', +); + +export interface ProtectedDocumentUnlockTransactionPortV1 { + save(context: IamTenantContextV1, request: ProtectedDocumentUnlockRequestV1): Promise; + find( + context: IamTenantContextV1, + requestId: ProtectedDocumentUnlockRequestV1['requestId'], + ): Promise; +} + +export interface ProtectedDocumentUnlockRepositoryPortV1 + extends ProtectedDocumentUnlockTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: ProtectedDocumentUnlockTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/iae/application/protected-document-unlock.service.ts b/services/api/src/features/iae/application/protected-document-unlock.service.ts new file mode 100644 index 00000000..6c0c9454 --- /dev/null +++ b/services/api/src/features/iae/application/protected-document-unlock.service.ts @@ -0,0 +1,132 @@ +import { + createProtectedDocumentUnlockRequestV1, + expireProtectedDocumentUnlockRequestV1, + recordProtectedDocumentUnlockResultV1, + type ProtectedDocumentUnlockRequestV1, + type ProtectedDocumentUnlockResultV1, +} from '@databreeze/domain/protected-document/v1'; +import { parseStableIdentifierV1, tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ProtectedDocumentSecretInputPortV1, + ProtectedDocumentSecretInputResultV1, + ProtectedDocumentUnlockHandleV1, +} from './protected-document-secret-input.port.js'; +import { InMemoryProtectedDocumentSecretInputAdapter } from '../adapter/in-memory-protected-document-secret-input.adapter.js'; +import type { ProtectedDocumentUnlockRepositoryPortV1 } from './protected-document-unlock-repository.port.js'; + +export type ProtectedDocumentUnlockServiceErrorV1 = + | 'UNLOCK_NOT_FOUND' + | 'UNLOCK_SCOPE_NARROWING_REQUIRED'; +export type ProtectedDocumentUnlockServiceResultV1 = + | ProtectedDocumentUnlockResultV1 + | ProtectedDocumentSecretInputResultV1 + | { readonly accepted: false; readonly code: ProtectedDocumentUnlockServiceErrorV1 }; + +/** Coordinates unlock state while delegating secret entry to a local/sidecar port. */ +export class ProtectedDocumentUnlockService { + public constructor( + private readonly requests: ProtectedDocumentUnlockRepositoryPortV1, + private readonly secretInput: ProtectedDocumentSecretInputPortV1 = new InMemoryProtectedDocumentSecretInputAdapter(), + ) {} + + public async create( + context: IamTenantContextV1, + input: Omit[0], 'tenantScope'> & { + readonly tenantScope?: unknown; + }, + ): Promise> { + const created = createProtectedDocumentUnlockRequestV1({ + ...input, + tenantScope: input.tenantScope ?? context.tenantScope, + }); + if (!created.accepted) return created; + if (!tenantScopeContainsV1(context.tenantScope, created.value.tenantScope)) + return Object.freeze({ accepted: false, code: 'UNLOCK_SCOPE_NARROWING_REQUIRED' as const }); + return this.requests.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.requestId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return { accepted: true, value: existing }; + throw new Error('IAE_IMMUTABLE_UNLOCK_REQUEST'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + requestIdInput: unknown, + ): Promise> { + const requestId = parseStableIdentifierV1(requestIdInput); + if (!requestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const request = await this.requests.find(context, requestId.value); + return request + ? Object.freeze({ accepted: true, value: request }) + : Object.freeze({ accepted: false, code: 'UNLOCK_NOT_FOUND' as const }); + } + + public async issueHandle( + context: IamTenantContextV1, + requestIdInput: unknown, + ): Promise> { + const request = await this.find(context, requestIdInput); + if (!request.accepted) return request; + return this.secretInput.issue(context, request.value); + } + + public async recordOutcome( + context: IamTenantContextV1, + requestIdInput: unknown, + input: { + readonly handleId: string; + readonly expectedRevision: unknown; + readonly outcome: unknown; + readonly failureCode?: unknown; + readonly occurredAt: unknown; + }, + ): Promise> { + const requestId = parseStableIdentifierV1(requestIdInput); + if (!requestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + return this.requests.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, requestId.value); + if (!current) return Object.freeze({ accepted: false, code: 'UNLOCK_NOT_FOUND' as const }); + if (input.outcome !== 'UNLOCKED' && input.outcome !== 'FAILED') + return Object.freeze({ accepted: false, code: 'INVALID_OUTCOME' as const }); + const verified = await this.secretInput.consume( + context, + current, + input.handleId, + input.outcome, + ); + if (!verified.accepted) return verified; + const next = recordProtectedDocumentUnlockResultV1(current, input); + if (!next.accepted) return next; + await transaction.save(context, next.value); + return next; + }); + } + + public async expire( + context: IamTenantContextV1, + requestIdInput: unknown, + now: unknown, + ): Promise> { + const requestId = parseStableIdentifierV1(requestIdInput); + if (!requestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + return this.requests.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, requestId.value); + if (!current) return Object.freeze({ accepted: false, code: 'UNLOCK_NOT_FOUND' as const }); + const next = expireProtectedDocumentUnlockRequestV1(current, now); + if (!next.accepted) return next; + await this.secretInput.release(context, current); + await transaction.save(context, next.value); + return next; + }); + } +} diff --git a/services/api/test/features/iae/protected-document-unlock.service.test.ts b/services/api/test/features/iae/protected-document-unlock.service.test.ts new file mode 100644 index 00000000..29b825cc --- /dev/null +++ b/services/api/test/features/iae/protected-document-unlock.service.test.ts @@ -0,0 +1,58 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemoryProtectedDocumentSecretInputAdapter } from '../../../src/features/iae/adapter/in-memory-protected-document-secret-input.adapter.js'; +import { InMemoryProtectedDocumentUnlockRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-protected-document-unlock-repository.adapter.js'; +import { ProtectedDocumentUnlockService } from '../../../src/features/iae/application/protected-document-unlock.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'protected-document', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const input = { + requestId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + mode: 'LOCAL_SECRET_INPUT', + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T00:20:00.000Z', +}; + +void test('IAE-015 service issues and consumes opaque one-shot unlock handles', async () => { + const service = new ProtectedDocumentUnlockService( + new InMemoryProtectedDocumentUnlockRepositoryAdapter(), + new InMemoryProtectedDocumentSecretInputAdapter(() => '2026-08-02T00:05:00.000Z'), + ); + const created = await service.create(context, input); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const handle = await service.issueHandle(context, created.value.requestId); + assert.equal(handle.accepted, true); + if (!handle.accepted) return; + assert.equal(Object.hasOwn(handle.value, 'secret'), false); + const outcome = await service.recordOutcome(context, created.value.requestId, { + handleId: handle.value.handleId, + expectedRevision: 1, + outcome: 'UNLOCKED', + occurredAt: '2026-08-02T00:01:00.000Z', + }); + assert.equal(outcome.accepted, true); + if (outcome.accepted) assert.equal(outcome.value.state, 'UNLOCKED'); + const replay = await service.recordOutcome(context, created.value.requestId, { + handleId: handle.value.handleId, + expectedRevision: 2, + outcome: 'UNLOCKED', + occurredAt: '2026-08-02T00:02:00.000Z', + }); + assert.deepEqual(replay, { accepted: false, code: 'UNLOCK_HANDLE_INVALID' }); +}); From 25863a007c5f3130f2ec72ace5924fd7e2ac664b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:47:42 +0700 Subject: [PATCH 089/177] feat(iae): expose protected document unlock workflow --- services/api/openapi/v1.json | 405 ++++++++++++++++++ .../protected-document-unlock.controller.ts | 90 ++++ .../iae/api/protected-document-unlock.dto.ts | 75 ++++ services/api/src/features/iae/iae.module.ts | 28 ++ ...otected-document-unlock.controller.test.ts | 74 ++++ services/api/test/openapi.test.ts | 5 + 6 files changed, 677 insertions(+) create mode 100644 services/api/src/features/iae/api/protected-document-unlock.controller.ts create mode 100644 services/api/src/features/iae/api/protected-document-unlock.dto.ts create mode 100644 services/api/test/features/iae/protected-document-unlock.controller.test.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 31c452dc..3900ca28 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -2940,6 +2940,374 @@ "tags": ["artifacts"] } }, + "/v1/protected-document-unlocks": { + "post": { + "operationId": "ProtectedDocumentUnlockController.create", + "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/CreateProtectedDocumentUnlockDto" } + } + } + }, + "responses": { + "201": { + "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": "Create a secret-free protected-document unlock request", + "tags": ["artifacts"] + } + }, + "/v1/protected-document-unlocks/{requestId}": { + "get": { + "operationId": "ProtectedDocumentUnlockController.find", + "parameters": [ + { "name": "requestId", "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": "Read protected-document unlock state without credentials", + "tags": ["artifacts"] + } + }, + "/v1/protected-document-unlocks/{requestId}/handle": { + "post": { + "operationId": "ProtectedDocumentUnlockController.issueHandle", + "parameters": [ + { "name": "requestId", "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": { + "201": { + "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": "Issue a one-shot local secret-input handle", + "tags": ["artifacts"] + } + }, + "/v1/protected-document-unlocks/{requestId}/outcome": { + "post": { + "operationId": "ProtectedDocumentUnlockController.recordOutcome", + "parameters": [ + { "name": "requestId", "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/RecordProtectedDocumentUnlockOutcomeDto" } + } + } + }, + "responses": { + "201": { + "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": "Record a local unlock outcome using an opaque handle", + "tags": ["artifacts"] + } + }, + "/v1/protected-document-unlocks/{requestId}/expire": { + "post": { + "operationId": "ProtectedDocumentUnlockController.expire", + "parameters": [ + { "name": "requestId", "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/ExpireProtectedDocumentUnlockDto" } + } + } + }, + "responses": { + "201": { + "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": "Expire an open unlock request and release local handles", + "tags": ["artifacts"] + } + }, "/v1/datasets": { "post": { "operationId": "GovernedDatasetController.create", @@ -6730,6 +7098,43 @@ "maxByteSize" ] }, + "CreateProtectedDocumentUnlockDto": { + "type": "object", + "properties": { + "requestId": { "type": "string", "format": "uuid" }, + "artifactVersionId": { "type": "string", "format": "uuid" }, + "mode": { "type": "string", "enum": ["LOCAL_SECRET_INPUT", "DEVICE_KEYCHAIN"] }, + "deviceId": { "type": "string", "format": "uuid" }, + "maxAttempts": { "type": "number", "minimum": 1, "maximum": 10, "default": 3 }, + "createdAt": { "type": "string", "format": "date-time" }, + "expiresAt": { "type": "string", "format": "date-time" } + }, + "required": ["requestId", "artifactVersionId", "mode", "createdAt", "expiresAt"] + }, + "RecordProtectedDocumentUnlockOutcomeDto": { + "type": "object", + "properties": { + "handleId": { "type": "string", "format": "uuid" }, + "expectedRevision": { "type": "number", "minimum": 1 }, + "outcome": { "type": "string", "enum": ["UNLOCKED", "FAILED"] }, + "failureCode": { + "type": "string", + "enum": [ + "UNLOCK_REJECTED", + "LOCAL_DEVICE_UNAVAILABLE", + "UNSUPPORTED_DOCUMENT", + "MAX_ATTEMPTS" + ] + }, + "occurredAt": { "type": "string", "format": "date-time" } + }, + "required": ["handleId", "expectedRevision", "outcome", "occurredAt"] + }, + "ExpireProtectedDocumentUnlockDto": { + "type": "object", + "properties": { "now": { "type": "string", "format": "date-time" } }, + "required": ["now"] + }, "GovernedDatasetFieldDto": { "type": "object", "properties": { diff --git a/services/api/src/features/iae/api/protected-document-unlock.controller.ts b/services/api/src/features/iae/api/protected-document-unlock.controller.ts new file mode 100644 index 00000000..9a78f0a7 --- /dev/null +++ b/services/api/src/features/iae/api/protected-document-unlock.controller.ts @@ -0,0 +1,90 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + PROTECTED_DOCUMENT_SECRET_INPUT_PORT, + type ProtectedDocumentSecretInputPortV1, +} from '../application/protected-document-secret-input.port.js'; +import { + PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT, + type ProtectedDocumentUnlockRepositoryPortV1, +} from '../application/protected-document-unlock-repository.port.js'; +import { ProtectedDocumentUnlockService } from '../application/protected-document-unlock.service.js'; +import { + CreateProtectedDocumentUnlockDto, + ExpireProtectedDocumentUnlockDto, + RecordProtectedDocumentUnlockOutcomeDto, +} from './protected-document-unlock.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +/** IAE-015: unlock control plane exposes state/handles only; secret values stay local. */ +@ApiTags('artifacts') +@ApiBearerAuth() +@Controller('v1/protected-document-unlocks') +export class ProtectedDocumentUnlockController { + private readonly unlocks: ProtectedDocumentUnlockService; + + public constructor( + @Inject(PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT) + requests: ProtectedDocumentUnlockRepositoryPortV1, + @Inject(PROTECTED_DOCUMENT_SECRET_INPUT_PORT) secretInput: ProtectedDocumentSecretInputPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.unlocks = new ProtectedDocumentUnlockService(requests, secretInput); + } + + @Post() + @ApiOperation({ summary: 'Create a secret-free protected-document unlock request' }) + @ApiBody({ type: CreateProtectedDocumentUnlockDto }) + async create( + @Req() request: unknown, + @Body() input: CreateProtectedDocumentUnlockDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.unlocks.create(context, input); + } + + @Get(':requestId') + @ApiOperation({ summary: 'Read protected-document unlock state without credentials' }) + async find(@Req() request: unknown, @Param('requestId') requestId: string): Promise { + const context = await this.requestContext.resolve(request); + return this.unlocks.find(context, requestId); + } + + @Post(':requestId/handle') + @ApiOperation({ summary: 'Issue a one-shot local secret-input handle' }) + async issueHandle( + @Req() request: unknown, + @Param('requestId') requestId: string, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.unlocks.issueHandle(context, requestId); + } + + @Post(':requestId/outcome') + @ApiOperation({ summary: 'Record a local unlock outcome using an opaque handle' }) + @ApiBody({ type: RecordProtectedDocumentUnlockOutcomeDto }) + async recordOutcome( + @Req() request: unknown, + @Param('requestId') requestId: string, + @Body() input: RecordProtectedDocumentUnlockOutcomeDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.unlocks.recordOutcome(context, requestId, input); + } + + @Post(':requestId/expire') + @ApiOperation({ summary: 'Expire an open unlock request and release local handles' }) + @ApiBody({ type: ExpireProtectedDocumentUnlockDto }) + async expire( + @Req() request: unknown, + @Param('requestId') requestId: string, + @Body() input: ExpireProtectedDocumentUnlockDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.unlocks.expire(context, requestId, input.now); + } +} diff --git a/services/api/src/features/iae/api/protected-document-unlock.dto.ts b/services/api/src/features/iae/api/protected-document-unlock.dto.ts new file mode 100644 index 00000000..9cbdd71f --- /dev/null +++ b/services/api/src/features/iae/api/protected-document-unlock.dto.ts @@ -0,0 +1,75 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsISO8601, IsIn, IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator'; + +const unlockModes = ['LOCAL_SECRET_INPUT', 'DEVICE_KEYCHAIN'] as const; +const unlockOutcomes = ['UNLOCKED', 'FAILED'] as const; +const failureCodes = [ + 'UNLOCK_REJECTED', + 'LOCAL_DEVICE_UNAVAILABLE', + 'UNSUPPORTED_DOCUMENT', + 'MAX_ATTEMPTS', +] as const; + +export class CreateProtectedDocumentUnlockDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + requestId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + artifactVersionId!: string; + + @ApiProperty({ enum: unlockModes }) + @IsIn(unlockModes) + mode!: (typeof unlockModes)[number]; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + deviceId?: string; + + @ApiPropertyOptional({ minimum: 1, maximum: 10, default: 3 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + maxAttempts?: number; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + expiresAt!: string; +} + +export class RecordProtectedDocumentUnlockOutcomeDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + handleId!: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; + + @ApiProperty({ enum: unlockOutcomes }) + @IsIn(unlockOutcomes) + outcome!: (typeof unlockOutcomes)[number]; + + @ApiPropertyOptional({ enum: failureCodes }) + @IsOptional() + @IsIn(failureCodes) + failureCode?: (typeof failureCodes)[number]; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + occurredAt!: string; +} + +export class ExpireProtectedDocumentUnlockDto { + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + now!: string; +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index b4a84901..8336b45b 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -9,6 +9,7 @@ import { ArtifactRetentionController } from './api/artifact-retention.controller import { ArtifactExportController } from './api/artifact-export.controller.js'; import { ArtifactUploadController } from './api/artifact-upload.controller.js'; import { ArtifactAdmissionController } from './api/artifact-admission.controller.js'; +import { ProtectedDocumentUnlockController } from './api/protected-document-unlock.controller.js'; import { InMemoryArtifactIntakeRepositoryAdapter } from './adapter/in-memory-artifact-intake-repository.adapter.js'; import { PrismaArtifactIntakeRepositoryAdapter, @@ -32,6 +33,8 @@ import { } from './adapter/prisma-artifact-export-repository.adapter.js'; import { InMemoryArtifactUploadRepositoryAdapter } from './adapter/in-memory-artifact-upload-repository.adapter.js'; import { InMemoryArtifactUploadStorageAdapter } from './adapter/in-memory-artifact-upload-storage.adapter.js'; +import { InMemoryProtectedDocumentSecretInputAdapter } from './adapter/in-memory-protected-document-secret-input.adapter.js'; +import { InMemoryProtectedDocumentUnlockRepositoryAdapter } from './adapter/in-memory-protected-document-unlock-repository.adapter.js'; import { PrismaArtifactUploadRepositoryAdapter, type ArtifactUploadDatabaseClientV1, @@ -73,6 +76,14 @@ import { ARTIFACT_UPLOAD_STORAGE_PORT, type ArtifactUploadStoragePortV1, } from './application/artifact-upload-storage.port.js'; +import { + PROTECTED_DOCUMENT_SECRET_INPUT_PORT, + type ProtectedDocumentSecretInputPortV1, +} from './application/protected-document-secret-input.port.js'; +import { + PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT, + type ProtectedDocumentUnlockRepositoryPortV1, +} from './application/protected-document-unlock-repository.port.js'; import { EVIDENCE_GRANT_REPOSITORY_PORT, type EvidenceGrantRepositoryPortV1, @@ -103,6 +114,8 @@ export interface IaeModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly artifactUploadDatabase?: ArtifactUploadDatabaseClientV1; readonly artifactUploadStorage?: ArtifactUploadStoragePortV1; + readonly protectedDocumentUnlockRepository?: ProtectedDocumentUnlockRepositoryPortV1; + readonly protectedDocumentSecretInput?: ProtectedDocumentSecretInputPortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly evidenceGrantDatabase?: EvidenceGrantDatabaseClientV1; @@ -124,6 +137,7 @@ export class IaeModule { ArtifactExportController, ArtifactUploadController, ArtifactAdmissionController, + ProtectedDocumentUnlockController, ], providers: [ { @@ -178,6 +192,18 @@ export class IaeModule { provide: ARTIFACT_UPLOAD_STORAGE_PORT, useValue: options.artifactUploadStorage ?? new InMemoryArtifactUploadStorageAdapter(), }, + { + provide: PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT, + useValue: + options.protectedDocumentUnlockRepository ?? + new InMemoryProtectedDocumentUnlockRepositoryAdapter(), + }, + { + provide: PROTECTED_DOCUMENT_SECRET_INPUT_PORT, + useValue: + options.protectedDocumentSecretInput ?? + new InMemoryProtectedDocumentSecretInputAdapter(), + }, { provide: EVIDENCE_GRANT_REPOSITORY_PORT, useValue: @@ -199,6 +225,8 @@ export class IaeModule { ARTIFACT_EXPORT_REPOSITORY_PORT, ARTIFACT_UPLOAD_REPOSITORY_PORT, ARTIFACT_UPLOAD_STORAGE_PORT, + PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT, + PROTECTED_DOCUMENT_SECRET_INPUT_PORT, EVIDENCE_GRANT_REPOSITORY_PORT, ], }; diff --git a/services/api/test/features/iae/protected-document-unlock.controller.test.ts b/services/api/test/features/iae/protected-document-unlock.controller.test.ts new file mode 100644 index 00000000..58c285b2 --- /dev/null +++ b/services/api/test/features/iae/protected-document-unlock.controller.test.ts @@ -0,0 +1,74 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryProtectedDocumentSecretInputAdapter } from '../../../src/features/iae/adapter/in-memory-protected-document-secret-input.adapter.js'; +import { InMemoryProtectedDocumentUnlockRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-protected-document-unlock-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'protected-document-http', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const tenantContext = contextResult.value; + +void test('IAE-015 HTTP exposes state and handles but rejects secret fields', async () => { + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + protectedDocumentUnlockRepository: new InMemoryProtectedDocumentUnlockRepositoryAdapter(), + protectedDocumentSecretInput: new InMemoryProtectedDocumentSecretInputAdapter( + () => '2026-08-04T00:05:00.000Z', + ), + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/protected-document-unlocks', + payload: { + requestId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + mode: 'LOCAL_SECRET_INPUT', + createdAt: '2026-08-04T00:00:00.000Z', + expiresAt: '2026-08-04T00:20:00.000Z', + password: 'must-never-enter-api', + }, + }); + assert.equal(response.statusCode, 400); + assert.doesNotMatch(response.body, /must-never-enter-api|password/iu); + + const created = await app.inject({ + method: 'POST', + url: '/v1/protected-document-unlocks', + payload: { + requestId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + mode: 'LOCAL_SECRET_INPUT', + createdAt: '2026-08-04T00:00:00.000Z', + expiresAt: '2026-08-04T00:20:00.000Z', + }, + }); + assert.equal(created.statusCode, 201); + assert.doesNotMatch(created.body, /password|credential|must-never-enter-api/iu); + + const handle = await app.inject({ + method: 'POST', + url: '/v1/protected-document-unlocks/55555555-5555-4555-8555-555555555555/handle', + }); + assert.equal(handle.statusCode, 201); + assert.doesNotMatch(handle.body, /password|credential|must-never-enter-api/iu); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 3f76d4d5..9f06cd9a 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -130,6 +130,11 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/entitlements/snapshots/{snapshotId}', '/v1/entitlements/usage', '/v1/organizations/{organizationId}/devices', + '/v1/protected-document-unlocks', + '/v1/protected-document-unlocks/{requestId}', + '/v1/protected-document-unlocks/{requestId}/expire', + '/v1/protected-document-unlocks/{requestId}/handle', + '/v1/protected-document-unlocks/{requestId}/outcome', '/v1/reference-entities', '/v1/reference-entities/merge', '/v1/reference-entities/{entityId}/resolutions', From 06272d435c3ea18b16b2630690b8431083e5023d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:50:38 +0700 Subject: [PATCH 090/177] feat(iae): persist protected document unlock state --- .../migration.sql | 27 ++ services/api/prisma/schema/iae.prisma | 25 ++ ...cted-document-unlock-repository.adapter.ts | 240 ++++++++++++++++++ services/api/src/features/iae/iae.module.ts | 10 +- ...otected-document-unlock-repository.test.ts | 74 ++++++ services/api/test/prisma-foundation.test.mjs | 16 ++ 6 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 services/api/prisma/migrations/20260802280000_iae_protected_document_unlocks/migration.sql create mode 100644 services/api/src/features/iae/adapter/prisma-protected-document-unlock-repository.adapter.ts create mode 100644 services/api/test/features/iae/prisma-protected-document-unlock-repository.test.ts diff --git a/services/api/prisma/migrations/20260802280000_iae_protected_document_unlocks/migration.sql b/services/api/prisma/migrations/20260802280000_iae_protected_document_unlocks/migration.sql new file mode 100644 index 00000000..590b8cf1 --- /dev/null +++ b/services/api/prisma/migrations/20260802280000_iae_protected_document_unlocks/migration.sql @@ -0,0 +1,27 @@ +-- IAE-015: persist unlock request state only; credentials remain local/ephemeral. +CREATE TABLE "iae"."protected_document_unlock_requests" ( + "id" UUID NOT NULL, + "artifact_version_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "device_id" UUID, + "mode" VARCHAR(24) NOT NULL, + "state" VARCHAR(16) NOT NULL, + "attempt_count" INTEGER NOT NULL, + "max_attempts" INTEGER NOT NULL, + "last_failure_code" VARCHAR(32), + "created_at" TIMESTAMPTZ(6) NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + + CONSTRAINT "protected_document_unlock_requests_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "protected_document_unlock_artifact_idx" + ON "iae"."protected_document_unlock_requests"("artifact_version_id"); +CREATE INDEX "protected_document_unlock_scope_state_idx" + ON "iae"."protected_document_unlock_requests"("organization_id", "workspace_id", "project_id", "state"); +CREATE INDEX "protected_document_unlock_expiry_idx" + ON "iae"."protected_document_unlock_requests"("expires_at"); diff --git a/services/api/prisma/schema/iae.prisma b/services/api/prisma/schema/iae.prisma index 0733a70d..dd254b72 100644 --- a/services/api/prisma/schema/iae.prisma +++ b/services/api/prisma/schema/iae.prisma @@ -195,3 +195,28 @@ model ArtifactUploadSessionRecord { @@map("artifact_upload_sessions") @@schema("iae") } + +/// IAE-015: password-protected document unlock state; no secret material is persisted. +model ProtectedDocumentUnlockRequestRecord { + id String @id @db.Uuid + artifactVersionId String @map("artifact_version_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + deviceId String? @map("device_id") @db.Uuid + mode String @db.VarChar(24) + state String @db.VarChar(16) + attemptCount Int @map("attempt_count") + maxAttempts Int @map("max_attempts") + lastFailureCode String? @map("last_failure_code") @db.VarChar(32) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + revision Int @default(1) + + @@index([artifactVersionId], map: "protected_document_unlock_artifact_idx") + @@index([organizationId, workspaceId, projectId, state], map: "protected_document_unlock_scope_state_idx") + @@index([expiresAt], map: "protected_document_unlock_expiry_idx") + @@map("protected_document_unlock_requests") + @@schema("iae") +} diff --git a/services/api/src/features/iae/adapter/prisma-protected-document-unlock-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-protected-document-unlock-repository.adapter.ts new file mode 100644 index 00000000..a6e48248 --- /dev/null +++ b/services/api/src/features/iae/adapter/prisma-protected-document-unlock-repository.adapter.ts @@ -0,0 +1,240 @@ +import { + createProtectedDocumentUnlockRequestV1, + type ProtectedDocumentUnlockRequestV1, +} from '@databreeze/domain/protected-document/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + ProtectedDocumentUnlockRepositoryPortV1, + ProtectedDocumentUnlockTransactionPortV1, +} from '../application/protected-document-unlock-repository.port.js'; + +export interface ProtectedDocumentUnlockDatabaseRowV1 { + readonly id: string; + readonly artifactVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly deviceId: string | null; + readonly mode: string; + readonly state: string; + readonly attemptCount: number; + readonly maxAttempts: number; + readonly lastFailureCode: string | null; + readonly createdAt: Date; + readonly expiresAt: Date; + readonly revision: number; +} + +export interface ProtectedDocumentUnlockDatabaseCreateDataV1 { + readonly id: string; + readonly artifactVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly deviceId: string | null; + readonly mode: string; + readonly state: string; + readonly attemptCount: number; + readonly maxAttempts: number; + readonly lastFailureCode: string | null; + readonly createdAt: Date; + readonly expiresAt: Date; + readonly revision: number; +} + +export interface ProtectedDocumentUnlockDatabaseClientV1 { + readonly protectedDocumentUnlockRequestRecord: { + create(input: { + readonly data: ProtectedDocumentUnlockDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: { + readonly state: string; + readonly attemptCount: number; + readonly lastFailureCode: string | null; + readonly revision: number; + }; + }): Promise; + }; + $transaction( + work: (transaction: ProtectedDocumentUnlockDatabaseClientV1) => 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 rowScope(row: ProtectedDocumentUnlockDatabaseRowV1): 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('IAE_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: ProtectedDocumentUnlockDatabaseRowV1): ProtectedDocumentUnlockRequestV1 { + const created = createProtectedDocumentUnlockRequestV1({ + requestId: row.id, + artifactVersionId: row.artifactVersionId, + tenantScope: rowScope(row), + ...(row.deviceId === null ? {} : { deviceId: row.deviceId }), + mode: row.mode, + maxAttempts: row.maxAttempts, + createdAt: row.createdAt.toISOString(), + expiresAt: row.expiresAt.toISOString(), + }); + if (!created.accepted) throw new Error('IAE_PERSISTED_UNLOCK_INVALID'); + if ( + !['REQUESTED', 'UNLOCKED', 'FAILED', 'EXPIRED'].includes(row.state) || + !Number.isSafeInteger(row.attemptCount) || + row.attemptCount < 0 || + row.attemptCount > row.maxAttempts || + !Number.isSafeInteger(row.revision) || + row.revision < 1 + ) + throw new Error('IAE_PERSISTED_UNLOCK_STATE_INVALID'); + const normalized = { + ...created.value, + state: row.state as ProtectedDocumentUnlockRequestV1['state'], + attemptCount: row.attemptCount, + revision: row.revision, + }; + return row.lastFailureCode === null + ? Object.freeze(normalized) + : Object.freeze({ + ...normalized, + lastFailureCode: row.lastFailureCode as NonNullable< + ProtectedDocumentUnlockRequestV1['lastFailureCode'] + >, + }); +} + +function domainToCreate( + request: ProtectedDocumentUnlockRequestV1, +): ProtectedDocumentUnlockDatabaseCreateDataV1 { + return { + ...databaseScope(request.tenantScope), + id: request.requestId, + artifactVersionId: request.artifactVersionId, + deviceId: request.deviceId ?? null, + mode: request.mode, + state: request.state, + attemptCount: request.attemptCount, + maxAttempts: request.maxAttempts, + lastFailureCode: request.lastFailureCode ?? null, + createdAt: new Date(request.createdAt), + expiresAt: new Date(request.expiresAt), + revision: request.revision, + }; +} + +function visible(context: TenantScopeV1, row: ProtectedDocumentUnlockDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaProtectedDocumentUnlockTransactionAdapter + implements ProtectedDocumentUnlockTransactionPortV1 +{ + public constructor(private readonly client: ProtectedDocumentUnlockDatabaseClientV1) {} + + public async save( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, request.tenantScope)) + throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.protectedDocumentUnlockRequestRecord.findUnique({ + where: { id: request.requestId }, + }); + if (existing === null) { + await this.client.protectedDocumentUnlockRequestRecord.create({ + data: domainToCreate(request), + }); + return; + } + const current = rowToDomain(existing); + if (JSON.stringify(current) === JSON.stringify(request)) return; + if (request.revision !== current.revision + 1) throw new Error('IAE_UNLOCK_REVISION_CONFLICT'); + if ( + current.artifactVersionId !== request.artifactVersionId || + current.requestId !== request.requestId || + current.mode !== request.mode || + current.deviceId !== request.deviceId || + JSON.stringify(current.tenantScope) !== JSON.stringify(request.tenantScope) + ) + throw new Error('IAE_UNLOCK_IMMUTABLE_IDENTITY'); + await this.client.protectedDocumentUnlockRequestRecord.update({ + where: { id: request.requestId }, + data: { + state: request.state, + attemptCount: request.attemptCount, + lastFailureCode: request.lastFailureCode ?? null, + revision: request.revision, + }, + }); + } + + public async find( + context: IamTenantContextV1, + requestId: ProtectedDocumentUnlockRequestV1['requestId'], + ): Promise { + const row = await this.client.protectedDocumentUnlockRequestRecord.findUnique({ + where: { id: requestId }, + }); + return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; + } +} + +export class PrismaProtectedDocumentUnlockRepositoryAdapter + implements ProtectedDocumentUnlockRepositoryPortV1 +{ + public constructor(private readonly client: ProtectedDocumentUnlockDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ProtectedDocumentUnlockTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaProtectedDocumentUnlockTransactionAdapter(transaction)), + ); + } + + public save( + context: IamTenantContextV1, + request: ProtectedDocumentUnlockRequestV1, + ): Promise { + return new PrismaProtectedDocumentUnlockTransactionAdapter(this.client).save(context, request); + } + + public find( + context: IamTenantContextV1, + requestId: ProtectedDocumentUnlockRequestV1['requestId'], + ): Promise { + return new PrismaProtectedDocumentUnlockTransactionAdapter(this.client).find( + context, + requestId, + ); + } +} diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 8336b45b..458fc870 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -35,6 +35,10 @@ import { InMemoryArtifactUploadRepositoryAdapter } from './adapter/in-memory-art import { InMemoryArtifactUploadStorageAdapter } from './adapter/in-memory-artifact-upload-storage.adapter.js'; import { InMemoryProtectedDocumentSecretInputAdapter } from './adapter/in-memory-protected-document-secret-input.adapter.js'; import { InMemoryProtectedDocumentUnlockRepositoryAdapter } from './adapter/in-memory-protected-document-unlock-repository.adapter.js'; +import { + PrismaProtectedDocumentUnlockRepositoryAdapter, + type ProtectedDocumentUnlockDatabaseClientV1, +} from './adapter/prisma-protected-document-unlock-repository.adapter.js'; import { PrismaArtifactUploadRepositoryAdapter, type ArtifactUploadDatabaseClientV1, @@ -115,6 +119,8 @@ export interface IaeModuleOptions { readonly artifactUploadDatabase?: ArtifactUploadDatabaseClientV1; readonly artifactUploadStorage?: ArtifactUploadStoragePortV1; readonly protectedDocumentUnlockRepository?: ProtectedDocumentUnlockRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly protectedDocumentUnlockDatabase?: ProtectedDocumentUnlockDatabaseClientV1; readonly protectedDocumentSecretInput?: ProtectedDocumentSecretInputPortV1; readonly evidenceGrantRepository?: EvidenceGrantRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ @@ -196,7 +202,9 @@ export class IaeModule { provide: PROTECTED_DOCUMENT_UNLOCK_REPOSITORY_PORT, useValue: options.protectedDocumentUnlockRepository ?? - new InMemoryProtectedDocumentUnlockRepositoryAdapter(), + (options.protectedDocumentUnlockDatabase === undefined + ? new InMemoryProtectedDocumentUnlockRepositoryAdapter() + : new PrismaProtectedDocumentUnlockRepositoryAdapter(options.protectedDocumentUnlockDatabase)), }, { provide: PROTECTED_DOCUMENT_SECRET_INPUT_PORT, diff --git a/services/api/test/features/iae/prisma-protected-document-unlock-repository.test.ts b/services/api/test/features/iae/prisma-protected-document-unlock-repository.test.ts new file mode 100644 index 00000000..92d8444e --- /dev/null +++ b/services/api/test/features/iae/prisma-protected-document-unlock-repository.test.ts @@ -0,0 +1,74 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createProtectedDocumentUnlockRequestV1 } from '@databreeze/domain/protected-document/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { + PrismaProtectedDocumentUnlockRepositoryAdapter, + type ProtectedDocumentUnlockDatabaseClientV1, + type ProtectedDocumentUnlockDatabaseRowV1, +} from '../../../src/features/iae/adapter/prisma-protected-document-unlock-repository.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'prisma-protected-document', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const created = createProtectedDocumentUnlockRequestV1({ + requestId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + mode: 'LOCAL_SECRET_INPUT', + createdAt: '2026-08-04T00:00:00.000Z', + expiresAt: '2026-08-04T00:20:00.000Z', +}); +if (!created.accepted) throw new Error('fixture unlock invalid'); + +function client( + rows: ProtectedDocumentUnlockDatabaseRowV1[], +): ProtectedDocumentUnlockDatabaseClientV1 { + return { + protectedDocumentUnlockRequestRecord: { + create({ data }) { + const row = { ...data } as ProtectedDocumentUnlockDatabaseRowV1; + rows.push(row); + return Promise.resolve(row); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + update({ where, data }) { + const current = rows.find((row) => row.id === where.id); + if (!current) throw new Error('fixture unlock not found'); + const next = { ...current, ...data }; + rows[rows.indexOf(current)] = next; + return Promise.resolve(next); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('IAE-015 Prisma unlock adapter persists state without credentials', async () => { + const rows: ProtectedDocumentUnlockDatabaseRowV1[] = []; + const repository = new PrismaProtectedDocumentUnlockRepositoryAdapter(client(rows)); + await repository.save(context, created.value); + const found = await repository.find(context, created.value.requestId); + assert.deepEqual(found, created.value); + assert.equal(rows.length, 1); + const row = rows[0]; + assert.ok(row); + assert.equal(Object.hasOwn(row, 'secret'), false); + assert.equal(Object.hasOwn(row, 'password'), false); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 0acc588d..1a5ad276 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -64,6 +64,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_quality_results"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_profiles"/); + assert.match(diff.stdout, /CREATE TABLE "iae"\."protected_document_unlock_requests"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_resolutions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."mapping_definitions"/); @@ -114,6 +115,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802250000_dsm_quality_results', '20260802260000_iae_inbox_metadata', '20260802270000_dsm_profiles', + '20260802280000_iae_protected_document_unlocks', 'migration_lock.toml', ]); const migration = await readFile( @@ -443,4 +445,18 @@ test('the schema diff and centrally ordered migration inventory establish platfo ]) { assert.match(profileMigration, new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } + const protectedDocumentMigration = await readFile( + path.join(migrationsDirectory, inventory[29], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE TABLE "iae"."protected_document_unlock_requests"', + 'CREATE INDEX "protected_document_unlock_artifact_idx"', + '"last_failure_code" VARCHAR(32)', + ]) { + assert.match( + protectedDocumentMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From f978b19d491bd6b7fd7dd46315f956ffd7ca30bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:52:25 +0700 Subject: [PATCH 091/177] feat(dsm): add governed dataset export manifest contract --- packages/domain/package.json | 4 + packages/domain/src/dataset-export/v1.ts | 212 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/built-public-api-smoke.mjs | 3 + .../domain/test/dataset-export-v1.test.mjs | 54 +++++ packages/domain/test/public-api-v1.test.mjs | 2 + 6 files changed, 276 insertions(+) create mode 100644 packages/domain/src/dataset-export/v1.ts create mode 100644 packages/domain/test/dataset-export-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index c28c6afe..56ddf114 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -104,6 +104,10 @@ "types": "./src/dataset-profile/v1.ts", "import": "./dist/dataset-profile/v1.js" }, + "./dataset-export/v1": { + "types": "./src/dataset-export/v1.ts", + "import": "./dist/dataset-export/v1.js" + }, "./jobs/v1": { "types": "./src/jobs/v1.ts", "import": "./dist/jobs/v1.js" diff --git a/packages/domain/src/dataset-export/v1.ts b/packages/domain/src/dataset-export/v1.ts new file mode 100644 index 00000000..08b55a8a --- /dev/null +++ b/packages/domain/src/dataset-export/v1.ts @@ -0,0 +1,212 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** DSM-022: governed-data export verification metadata without raw dataset values. */ +export const DATASET_EXPORT_SCHEMA_VERSION_V1 = 1 as const; + +export type DatasetExportFormatV1 = 'CSV' | 'JSONL' | 'PARQUET' | 'XLSX'; +export type DatasetExportDataModeV1 = 'LOCAL' | 'HYBRID' | 'CLOUD'; +export type DatasetExportPayloadClassV1 = 'GOVERNED_DATA' | 'APPROVED_DERIVED_RESULT'; +export type DatasetExportApprovalStateV1 = 'NOT_REQUIRED' | 'PENDING' | 'APPROVED' | 'REJECTED'; +export type DatasetExportQualityStateV1 = 'PASS' | 'PASS_WITH_WARNINGS' | 'BLOCKED' | 'INCOMPLETE'; + +export interface DatasetExportManifestV1 { + readonly schemaVersion: typeof DATASET_EXPORT_SCHEMA_VERSION_V1; + readonly manifestId: StableIdentifierV1; + readonly datasetId: StableIdentifierV1; + readonly datasetVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly dataMode: DatasetExportDataModeV1; + readonly payloadClass: DatasetExportPayloadClassV1; + readonly format: DatasetExportFormatV1; + readonly rowCount: number; + readonly byteSize: number; + readonly contentSha256: string; + readonly schemaVersionId: StableIdentifierV1; + readonly mappingVersionId: StableIdentifierV1; + readonly ruleSetVersionId: StableIdentifierV1; + readonly semanticManifestHash: string; + readonly metricManifestHash: string; + readonly qualityManifestHash: string; + readonly lineageManifestHash: string; + readonly evidenceManifestHash: string; + readonly policyHash: string; + readonly qualityState: DatasetExportQualityStateV1; + readonly approvalState: DatasetExportApprovalStateV1; + readonly createdAt: StrictUtcTimestampV1; +} + +export type DatasetExportErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_MODE' + | 'INVALID_PAYLOAD_CLASS' + | 'INVALID_FORMAT' + | 'INVALID_COUNT' + | 'INVALID_SIZE' + | 'INVALID_HASH' + | 'INVALID_STATE' + | 'INVALID_QUALITY_STATE' + | 'INVALID_TIMESTAMP'; + +export type DatasetExportResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: DatasetExportErrorCodeV1 }; + +const modes = new Set(['LOCAL', 'HYBRID', 'CLOUD']); +const payloadClasses = new Set([ + 'GOVERNED_DATA', + 'APPROVED_DERIVED_RESULT', +]); +const formats = new Set(['CSV', 'JSONL', 'PARQUET', 'XLSX']); +const qualityStates = new Set([ + 'PASS', + 'PASS_WITH_WARNINGS', + 'BLOCKED', + 'INCOMPLETE', +]); +const approvalStates = new Set([ + 'NOT_REQUIRED', + 'PENDING', + 'APPROVED', + 'REJECTED', +]); + +function rejected(code: DatasetExportErrorCodeV1): DatasetExportResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +export function createDatasetExportManifestV1(input: { + readonly manifestId: unknown; + readonly datasetId: unknown; + readonly datasetVersionId: unknown; + readonly tenantScope: unknown; + readonly dataMode: unknown; + readonly payloadClass: unknown; + readonly format: unknown; + readonly rowCount: unknown; + readonly byteSize: unknown; + readonly contentSha256: unknown; + readonly schemaVersionId: unknown; + readonly mappingVersionId: unknown; + readonly ruleSetVersionId: unknown; + readonly semanticManifestHash: unknown; + readonly metricManifestHash: unknown; + readonly qualityManifestHash: unknown; + readonly lineageManifestHash: unknown; + readonly evidenceManifestHash: unknown; + readonly policyHash: unknown; + readonly qualityState: unknown; + readonly approvalState: unknown; + readonly createdAt: unknown; +}): DatasetExportResultV1 { + const manifestId = identifier(input.manifestId); + const datasetId = identifier(input.datasetId); + const datasetVersionId = identifier(input.datasetVersionId); + const schemaVersionId = identifier(input.schemaVersionId); + const mappingVersionId = identifier(input.mappingVersionId); + const ruleSetVersionId = identifier(input.ruleSetVersionId); + const tenantScope = parseTenantScopeV1(input.tenantScope); + const dataMode = input.dataMode; + const payloadClass = input.payloadClass; + const format = input.format; + const contentSha256 = hash(input.contentSha256); + const semanticManifestHash = hash(input.semanticManifestHash); + const metricManifestHash = hash(input.metricManifestHash); + const qualityManifestHash = hash(input.qualityManifestHash); + const lineageManifestHash = hash(input.lineageManifestHash); + const evidenceManifestHash = hash(input.evidenceManifestHash); + const policyHash = hash(input.policyHash); + const createdAt = timestamp(input.createdAt); + if ( + !manifestId || + !datasetId || + !datasetVersionId || + !schemaVersionId || + !mappingVersionId || + !ruleSetVersionId + ) + return rejected('INVALID_IDENTIFIER'); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (!modes.has(dataMode as DatasetExportDataModeV1)) return rejected('INVALID_MODE'); + if (!payloadClasses.has(payloadClass as DatasetExportPayloadClassV1)) + return rejected('INVALID_PAYLOAD_CLASS'); + if (!formats.has(format as DatasetExportFormatV1)) return rejected('INVALID_FORMAT'); + if ( + typeof input.rowCount !== 'number' || + !Number.isSafeInteger(input.rowCount) || + input.rowCount < 0 + ) + return rejected('INVALID_COUNT'); + if ( + typeof input.byteSize !== 'number' || + !Number.isSafeInteger(input.byteSize) || + input.byteSize < 0 + ) + return rejected('INVALID_SIZE'); + if ( + !contentSha256 || + !semanticManifestHash || + !metricManifestHash || + !qualityManifestHash || + !lineageManifestHash || + !evidenceManifestHash || + !policyHash + ) + return rejected('INVALID_HASH'); + if (!qualityStates.has(input.qualityState as DatasetExportQualityStateV1)) + return rejected('INVALID_QUALITY_STATE'); + if (!approvalStates.has(input.approvalState as DatasetExportApprovalStateV1)) + return rejected('INVALID_STATE'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ + schemaVersion: DATASET_EXPORT_SCHEMA_VERSION_V1, + manifestId, + datasetId, + datasetVersionId, + tenantScope: tenantScope.value, + dataMode: dataMode as DatasetExportDataModeV1, + payloadClass: payloadClass as DatasetExportPayloadClassV1, + format: format as DatasetExportFormatV1, + rowCount: input.rowCount, + byteSize: input.byteSize, + contentSha256, + schemaVersionId, + mappingVersionId, + ruleSetVersionId, + semanticManifestHash, + metricManifestHash, + qualityManifestHash, + lineageManifestHash, + evidenceManifestHash, + policyHash, + qualityState: input.qualityState as DatasetExportQualityStateV1, + approvalState: input.approvalState as DatasetExportApprovalStateV1, + createdAt, + }), + }); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 865b9864..36575dbc 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -11,6 +11,7 @@ export * from './dataset/v1.js'; export * from './dataset-governance/v1.js'; export * from './dataset-quality/v1.js'; export * from './dataset-profile/v1.js'; +export * from './dataset-export/v1.js'; export * from './jobs/v1.js'; export * from './approval/v1.js'; export * from './execution-attempt/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index d9c679ed..837fbfd2 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -16,6 +16,7 @@ const [ datasetGovernance, datasetQuality, datasetProfile, + datasetExport, dataMode, jobs, approval, @@ -44,6 +45,7 @@ const [ import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/dataset-quality/v1'), import('@databreeze/domain/dataset-profile/v1'), + import('@databreeze/domain/dataset-export/v1'), import('@databreeze/domain/data-mode/v1'), import('@databreeze/domain/jobs/v1'), import('@databreeze/domain/approval/v1'), @@ -74,6 +76,7 @@ assert.equal(dataset.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(datasetQuality.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(datasetProfile.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); +assert.equal(datasetExport.DATASET_EXPORT_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); assert.equal(jobs.JOB_SCHEMA_VERSION_V1, 1); assert.equal(approval.APPROVAL_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/dataset-export-v1.test.mjs b/packages/domain/test/dataset-export-v1.test.mjs new file mode 100644 index 00000000..c751d6f3 --- /dev/null +++ b/packages/domain/test/dataset-export-v1.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createDatasetExportManifestV1 } from '../dist/dataset-export/v1.js'; + +const base = { + manifestId: '11111111-1111-4111-8111-111111111111', + datasetId: '22222222-2222-4222-8222-222222222222', + datasetVersionId: '33333333-3333-4333-8333-333333333333', + tenantScope: { + scopeType: 'workspace', + organizationId: '44444444-4444-4444-8444-444444444444', + workspaceId: '55555555-5555-4555-8555-555555555555', + }, + dataMode: 'HYBRID', + payloadClass: 'GOVERNED_DATA', + format: 'CSV', + rowCount: 12, + byteSize: 2048, + contentSha256: 'a'.repeat(64), + schemaVersionId: '66666666-6666-4666-8666-666666666666', + mappingVersionId: '77777777-7777-4777-8777-777777777777', + ruleSetVersionId: '88888888-8888-4888-8888-888888888888', + semanticManifestHash: 'b'.repeat(64), + metricManifestHash: 'c'.repeat(64), + qualityManifestHash: 'd'.repeat(64), + lineageManifestHash: 'e'.repeat(64), + evidenceManifestHash: 'f'.repeat(64), + policyHash: '0'.repeat(64), + qualityState: 'PASS', + approvalState: 'APPROVED', + createdAt: '2026-08-04T00:00:00.000Z', +}; + +void test('[DSM-022] export manifests bind governance hashes and never contain raw rows', () => { + const created = createDatasetExportManifestV1(base); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(created.value.rowCount, 12); + assert.equal(Object.hasOwn(created.value, 'rows'), false); + assert.equal(Object.hasOwn(created.value, 'records'), false); + assert.equal(created.value.evidenceManifestHash, 'f'.repeat(64)); +}); + +void test('[DSM-022] exports reject invalid policy hashes and unsupported formats', () => { + assert.deepEqual(createDatasetExportManifestV1({ ...base, format: 'XML' }), { + accepted: false, + code: 'INVALID_FORMAT', + }); + assert.deepEqual(createDatasetExportManifestV1({ ...base, policyHash: 'not-a-hash' }), { + accepted: false, + code: 'INVALID_HASH', + }); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 618e8d28..3121e58f 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -34,6 +34,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './dataset-governance/v1', './dataset-quality/v1', './dataset-profile/v1', + './dataset-export/v1', './jobs/v1', './approval/v1', './execution-attempt/v1', @@ -71,6 +72,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.DATASET_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.DATASET_EXPORT_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.PROTECTED_DOCUMENT_SCHEMA_VERSION_V1, 1); From 66b6e495912929eb6f00dd4c4685381e2b4cc1d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:53:51 +0700 Subject: [PATCH 092/177] feat(dsm): coordinate governed export manifests --- ...emory-dataset-export-repository.adapter.ts | 66 +++++++++++++ .../dataset-export-repository.port.ts | 20 ++++ .../dsm/application/dataset-export.service.ts | 70 ++++++++++++++ .../dsm/dataset-export.service.test.ts | 94 +++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 services/api/src/features/dsm/adapter/in-memory-dataset-export-repository.adapter.ts create mode 100644 services/api/src/features/dsm/application/dataset-export-repository.port.ts create mode 100644 services/api/src/features/dsm/application/dataset-export.service.ts create mode 100644 services/api/test/features/dsm/dataset-export.service.test.ts diff --git a/services/api/src/features/dsm/adapter/in-memory-dataset-export-repository.adapter.ts b/services/api/src/features/dsm/adapter/in-memory-dataset-export-repository.adapter.ts new file mode 100644 index 00000000..8a836afc --- /dev/null +++ b/services/api/src/features/dsm/adapter/in-memory-dataset-export-repository.adapter.ts @@ -0,0 +1,66 @@ +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { DatasetExportManifestV1 } from '@databreeze/domain/dataset-export/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetExportRepositoryPortV1, + DatasetExportTransactionPortV1, +} from '../application/dataset-export-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(manifest: DatasetExportManifestV1): DatasetExportManifestV1 { + return Object.freeze({ + ...manifest, + tenantScope: Object.freeze({ ...manifest.tenantScope }), + }); +} + +export class InMemoryDatasetExportRepositoryAdapter implements DatasetExportRepositoryPortV1 { + private manifests = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, manifest: DatasetExportManifestV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, manifest.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = this.manifests.get(manifest.manifestId); + if (existing && JSON.stringify(existing) !== JSON.stringify(manifest)) + throw new Error('DSM_IMMUTABLE_EXPORT_MANIFEST'); + this.manifests.set(manifest.manifestId, clone(manifest)); + } + + public async find( + context: IamTenantContextV1, + manifestId: DatasetExportManifestV1['manifestId'], + ): Promise { + await Promise.resolve(); + const manifest = this.manifests.get(manifestId); + return manifest && visible(context.tenantScope, manifest.tenantScope) + ? clone(manifest) + : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetExportTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.manifests); + try { + return await work({ save: this.save.bind(this), find: this.find.bind(this) }); + } catch (error) { + this.manifests = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/dsm/application/dataset-export-repository.port.ts b/services/api/src/features/dsm/application/dataset-export-repository.port.ts new file mode 100644 index 00000000..1096fb43 --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-export-repository.port.ts @@ -0,0 +1,20 @@ +import type { DatasetExportManifestV1 } from '@databreeze/domain/dataset-export/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const DATASET_EXPORT_REPOSITORY_PORT = Symbol('DATASET_EXPORT_REPOSITORY_PORT'); + +export interface DatasetExportTransactionPortV1 { + save(context: IamTenantContextV1, manifest: DatasetExportManifestV1): Promise; + find( + context: IamTenantContextV1, + manifestId: DatasetExportManifestV1['manifestId'], + ): Promise; +} + +export interface DatasetExportRepositoryPortV1 extends DatasetExportTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetExportTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/dsm/application/dataset-export.service.ts b/services/api/src/features/dsm/application/dataset-export.service.ts new file mode 100644 index 00000000..590249b8 --- /dev/null +++ b/services/api/src/features/dsm/application/dataset-export.service.ts @@ -0,0 +1,70 @@ +import { + createDatasetExportManifestV1, + type DatasetExportManifestV1, + type DatasetExportResultV1, +} from '@databreeze/domain/dataset-export/v1'; +import { parseStableIdentifierV1, tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { DatasetVersionRepositoryPortV1 } from './dataset-version-repository.port.js'; +import type { DatasetExportRepositoryPortV1 } from './dataset-export-repository.port.js'; + +export type DatasetExportServiceErrorV1 = + | 'DATASET_VERSION_NOT_FOUND' + | 'DATASET_VERSION_MISMATCH' + | 'EXPORT_NOT_FOUND' + | 'EXPORT_SCOPE_NARROWING_REQUIRED'; +export type DatasetExportServiceResultV1 = + | DatasetExportResultV1 + | { readonly accepted: false; readonly code: DatasetExportServiceErrorV1 }; + +/** Binds a governed export manifest to an existing immutable dataset version. */ +export class DatasetExportService { + public constructor( + private readonly manifests: DatasetExportRepositoryPortV1, + private readonly versions: DatasetVersionRepositoryPortV1, + ) {} + + public async create( + context: IamTenantContextV1, + input: Omit[0], 'tenantScope'> & { + readonly tenantScope?: unknown; + }, + ): Promise> { + const created = createDatasetExportManifestV1({ + ...input, + tenantScope: input.tenantScope ?? context.tenantScope, + }); + if (!created.accepted) return created; + if (!tenantScopeContainsV1(context.tenantScope, created.value.tenantScope)) + return Object.freeze({ accepted: false, code: 'EXPORT_SCOPE_NARROWING_REQUIRED' as const }); + const version = await this.versions.find(context, created.value.datasetVersionId); + if (!version) + return Object.freeze({ accepted: false, code: 'DATASET_VERSION_NOT_FOUND' as const }); + if (version.datasetId !== created.value.datasetId) + return Object.freeze({ accepted: false, code: 'DATASET_VERSION_MISMATCH' as const }); + return this.manifests.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.manifestId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return { accepted: true, value: existing }; + throw new Error('DSM_IMMUTABLE_EXPORT_MANIFEST'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + manifestIdInput: unknown, + ): Promise> { + const manifestId = parseStableIdentifierV1(manifestIdInput); + if (!manifestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const found = await this.manifests.find(context, manifestId.value); + return found + ? Object.freeze({ accepted: true, value: found }) + : Object.freeze({ accepted: false, code: 'EXPORT_NOT_FOUND' as const }); + } +} diff --git a/services/api/test/features/dsm/dataset-export.service.test.ts b/services/api/test/features/dsm/dataset-export.service.test.ts new file mode 100644 index 00000000..af98659d --- /dev/null +++ b/services/api/test/features/dsm/dataset-export.service.test.ts @@ -0,0 +1,94 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemoryDatasetExportRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-export-repository.adapter.js'; +import { InMemoryDatasetVersionRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.js'; +import { DatasetExportService } from '../../../src/features/dsm/application/dataset-export.service.js'; +import { DatasetVersionService } from '../../../src/features/dsm/application/dataset-version.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'dataset-export-service', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const versionId = '55555555-5555-4555-8555-555555555555'; +const datasetId = '66666666-6666-4666-8666-666666666666'; + +void test('[DSM-022] export service requires an existing governed dataset version', async () => { + const versions = new InMemoryDatasetVersionRepositoryAdapter(); + const versionService = new DatasetVersionService(versions); + const registered = await versionService.register(context, { + datasetId, + versionId, + tenantScope: context.tenantScope, + inputArtifactVersionIds: [], + schemaVersionId: '77777777-7777-4777-8777-777777777777', + mappingVersionId: '88888888-8888-4888-8888-888888888888', + ruleSetVersionId: '99999999-9999-4999-8999-999999999999', + engineBuild: 'engine-1', + contentFingerprint: 'a'.repeat(64), + rowCount: 2, + qualityState: 'PASS', + lineageManifestHash: 'b'.repeat(64), + }); + assert.equal(registered.accepted, true); + const service = new DatasetExportService(new InMemoryDatasetExportRepositoryAdapter(), versions); + const missing = await service.create(context, { + manifestId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + datasetId, + datasetVersionId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + dataMode: 'HYBRID', + payloadClass: 'GOVERNED_DATA', + format: 'JSONL', + rowCount: 2, + byteSize: 100, + contentSha256: 'c'.repeat(64), + schemaVersionId: '77777777-7777-4777-8777-777777777777', + mappingVersionId: '88888888-8888-4888-8888-888888888888', + ruleSetVersionId: '99999999-9999-4999-8999-999999999999', + semanticManifestHash: 'd'.repeat(64), + metricManifestHash: 'e'.repeat(64), + qualityManifestHash: 'f'.repeat(64), + lineageManifestHash: '0'.repeat(64), + evidenceManifestHash: '1'.repeat(64), + policyHash: '2'.repeat(64), + qualityState: 'PASS', + approvalState: 'NOT_REQUIRED', + createdAt: '2026-08-04T00:00:00.000Z', + }); + assert.deepEqual(missing, { accepted: false, code: 'DATASET_VERSION_NOT_FOUND' }); + const created = await service.create(context, { + manifestId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + datasetId, + datasetVersionId: versionId, + dataMode: 'HYBRID', + payloadClass: 'GOVERNED_DATA', + format: 'JSONL', + rowCount: 2, + byteSize: 100, + contentSha256: 'c'.repeat(64), + schemaVersionId: '77777777-7777-4777-8777-777777777777', + mappingVersionId: '88888888-8888-4888-8888-888888888888', + ruleSetVersionId: '99999999-9999-4999-8999-999999999999', + semanticManifestHash: 'd'.repeat(64), + metricManifestHash: 'e'.repeat(64), + qualityManifestHash: 'f'.repeat(64), + lineageManifestHash: '0'.repeat(64), + evidenceManifestHash: '1'.repeat(64), + policyHash: '2'.repeat(64), + qualityState: 'PASS', + approvalState: 'NOT_REQUIRED', + createdAt: '2026-08-04T00:00:00.000Z', + }); + assert.equal(created.accepted, true); +}); From f3d6356ead2ce4783143c8c4c3e227ebb0774f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:56:44 +0700 Subject: [PATCH 093/177] feat(dsm): expose governed dataset export manifests --- services/api/openapi/v1.json | 203 ++++++++++++++++++ .../dsm/api/dataset-export.controller.ts | 50 +++++ .../features/dsm/api/dataset-export.dto.ts | 99 +++++++++ services/api/src/features/dsm/dsm.module.ts | 12 ++ .../dsm/dataset-export.controller.test.ts | 114 ++++++++++ services/api/test/openapi.test.ts | 2 + 6 files changed, 480 insertions(+) create mode 100644 services/api/src/features/dsm/api/dataset-export.controller.ts create mode 100644 services/api/src/features/dsm/api/dataset-export.dto.ts create mode 100644 services/api/test/features/dsm/dataset-export.controller.test.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 3900ca28..7af29559 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -5204,6 +5204,151 @@ "tags": ["datasets"] } }, + "/v1/dataset-exports": { + "post": { + "operationId": "DatasetExportController.create", + "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/CreateDatasetExportManifestDto" } + } + } + }, + "responses": { + "201": { + "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": "Create a governed dataset export verification manifest", + "tags": ["datasets"] + } + }, + "/v1/dataset-exports/{manifestId}": { + "get": { + "operationId": "DatasetExportController.find", + "parameters": [ + { "name": "manifestId", "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": "Read an immutable governed dataset export manifest", + "tags": ["datasets"] + } + }, "/v1/devices/sync/operations": { "post": { "operationId": "DeviceSyncController.enqueue", @@ -7432,6 +7577,64 @@ "createdAt" ] }, + "CreateDatasetExportManifestDto": { + "type": "object", + "properties": { + "manifestId": { "type": "string", "format": "uuid" }, + "datasetId": { "type": "string", "format": "uuid" }, + "datasetVersionId": { "type": "string", "format": "uuid" }, + "dataMode": { "type": "string", "enum": ["LOCAL", "HYBRID", "CLOUD"] }, + "payloadClass": { + "type": "string", + "enum": ["GOVERNED_DATA", "APPROVED_DERIVED_RESULT"] + }, + "format": { "type": "string", "enum": ["CSV", "JSONL", "PARQUET", "XLSX"] }, + "rowCount": { "type": "number", "minimum": 0 }, + "byteSize": { "type": "number", "minimum": 0 }, + "contentSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "schemaVersionId": { "type": "string", "format": "uuid" }, + "mappingVersionId": { "type": "string", "format": "uuid" }, + "ruleSetVersionId": { "type": "string", "format": "uuid" }, + "semanticManifestHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "metricManifestHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "qualityManifestHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "lineageManifestHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "evidenceManifestHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "policyHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "qualityState": { + "type": "string", + "enum": ["PASS", "PASS_WITH_WARNINGS", "BLOCKED", "INCOMPLETE"] + }, + "approvalState": { + "type": "string", + "enum": ["NOT_REQUIRED", "PENDING", "APPROVED", "REJECTED"] + }, + "createdAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "manifestId", + "datasetId", + "datasetVersionId", + "dataMode", + "payloadClass", + "format", + "rowCount", + "byteSize", + "contentSha256", + "schemaVersionId", + "mappingVersionId", + "ruleSetVersionId", + "semanticManifestHash", + "metricManifestHash", + "qualityManifestHash", + "lineageManifestHash", + "evidenceManifestHash", + "policyHash", + "qualityState", + "approvalState", + "createdAt" + ] + }, "CreateDeviceSyncOperationDto": { "type": "object", "properties": { diff --git a/services/api/src/features/dsm/api/dataset-export.controller.ts b/services/api/src/features/dsm/api/dataset-export.controller.ts new file mode 100644 index 00000000..80116170 --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-export.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + DATASET_EXPORT_REPOSITORY_PORT, + type DatasetExportRepositoryPortV1, +} from '../application/dataset-export-repository.port.js'; +import { DatasetExportService } from '../application/dataset-export.service.js'; +import { + DATASET_VERSION_REPOSITORY_PORT, + type DatasetVersionRepositoryPortV1, +} from '../application/dataset-version-repository.port.js'; +import { CreateDatasetExportManifestDto } from './dataset-export.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('datasets') +@ApiBearerAuth() +@Controller('v1/dataset-exports') +export class DatasetExportController { + private readonly exports: DatasetExportService; + + public constructor( + @Inject(DATASET_EXPORT_REPOSITORY_PORT) manifests: DatasetExportRepositoryPortV1, + @Inject(DATASET_VERSION_REPOSITORY_PORT) versions: DatasetVersionRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.exports = new DatasetExportService(manifests, versions); + } + + @Post() + @ApiOperation({ summary: 'Create a governed dataset export verification manifest' }) + @ApiBody({ type: CreateDatasetExportManifestDto }) + async create( + @Req() request: unknown, + @Body() input: CreateDatasetExportManifestDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.exports.create(context, input); + } + + @Get(':manifestId') + @ApiOperation({ summary: 'Read an immutable governed dataset export manifest' }) + async find(@Req() request: unknown, @Param('manifestId') manifestId: string): Promise { + const context = await this.requestContext.resolve(request); + return this.exports.find(context, manifestId); + } +} diff --git a/services/api/src/features/dsm/api/dataset-export.dto.ts b/services/api/src/features/dsm/api/dataset-export.dto.ts new file mode 100644 index 00000000..195a0c12 --- /dev/null +++ b/services/api/src/features/dsm/api/dataset-export.dto.ts @@ -0,0 +1,99 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsInt, IsISO8601, IsString, IsUUID, Matches, Max, Min } from 'class-validator'; + +export class CreateDatasetExportManifestDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + manifestId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + datasetId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + datasetVersionId!: string; + + @ApiProperty({ enum: ['LOCAL', 'HYBRID', 'CLOUD'] }) + @IsIn(['LOCAL', 'HYBRID', 'CLOUD']) + dataMode!: 'LOCAL' | 'HYBRID' | 'CLOUD'; + + @ApiProperty({ enum: ['GOVERNED_DATA', 'APPROVED_DERIVED_RESULT'] }) + @IsIn(['GOVERNED_DATA', 'APPROVED_DERIVED_RESULT']) + payloadClass!: 'GOVERNED_DATA' | 'APPROVED_DERIVED_RESULT'; + + @ApiProperty({ enum: ['CSV', 'JSONL', 'PARQUET', 'XLSX'] }) + @IsIn(['CSV', 'JSONL', 'PARQUET', 'XLSX']) + format!: 'CSV' | 'JSONL' | 'PARQUET' | 'XLSX'; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + rowCount!: number; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + byteSize!: number; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + contentSha256!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + schemaVersionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + mappingVersionId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + ruleSetVersionId!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + semanticManifestHash!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + metricManifestHash!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + qualityManifestHash!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + lineageManifestHash!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + evidenceManifestHash!: string; + + @ApiProperty({ pattern: '^[0-9a-f]{64}$' }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + policyHash!: string; + + @ApiProperty({ enum: ['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE'] }) + @IsIn(['PASS', 'PASS_WITH_WARNINGS', 'BLOCKED', 'INCOMPLETE']) + qualityState!: 'PASS' | 'PASS_WITH_WARNINGS' | 'BLOCKED' | 'INCOMPLETE'; + + @ApiProperty({ enum: ['NOT_REQUIRED', 'PENDING', 'APPROVED', 'REJECTED'] }) + @IsIn(['NOT_REQUIRED', 'PENDING', 'APPROVED', 'REJECTED']) + approvalState!: 'NOT_REQUIRED' | 'PENDING' | 'APPROVED' | 'REJECTED'; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index d44e3da1..50199ce2 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -7,7 +7,9 @@ import { RuleSetController } from './api/rule-set.controller.js'; import { DatasetVersionController } from './api/dataset-version.controller.js'; import { DatasetQualityController } from './api/dataset-quality.controller.js'; import { DatasetProfileController } from './api/dataset-profile.controller.js'; +import { DatasetExportController } from './api/dataset-export.controller.js'; import { InMemoryDatasetProfileRepositoryAdapter } from './adapter/in-memory-dataset-profile-repository.adapter.js'; +import { InMemoryDatasetExportRepositoryAdapter } from './adapter/in-memory-dataset-export-repository.adapter.js'; import { PrismaDatasetProfileRepositoryAdapter, type DatasetProfileDatabaseClientV1, @@ -70,6 +72,10 @@ import { DATASET_PROFILE_REPOSITORY_PORT, type DatasetProfileRepositoryPortV1, } from './application/dataset-profile-repository.port.js'; +import { + DATASET_EXPORT_REPOSITORY_PORT, + type DatasetExportRepositoryPortV1, +} from './application/dataset-export-repository.port.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -98,6 +104,7 @@ export interface DsmModuleOptions { readonly datasetProfileRepository?: DatasetProfileRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly datasetProfileDatabase?: DatasetProfileDatabaseClientV1; + readonly datasetExportRepository?: DatasetExportRepositoryPortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -114,6 +121,7 @@ export class DsmModule { DatasetVersionController, DatasetQualityController, DatasetProfileController, + DatasetExportController, ], providers: [ { @@ -172,6 +180,10 @@ export class DsmModule { ? new InMemoryDatasetProfileRepositoryAdapter() : new PrismaDatasetProfileRepositoryAdapter(options.datasetProfileDatabase)), }, + { + provide: DATASET_EXPORT_REPOSITORY_PORT, + useValue: options.datasetExportRepository ?? new InMemoryDatasetExportRepositoryAdapter(), + }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), diff --git a/services/api/test/features/dsm/dataset-export.controller.test.ts b/services/api/test/features/dsm/dataset-export.controller.test.ts new file mode 100644 index 00000000..2c7525f5 --- /dev/null +++ b/services/api/test/features/dsm/dataset-export.controller.test.ts @@ -0,0 +1,114 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createDatasetVersionManifestV1 } from '@databreeze/domain/dataset-governance/v1'; +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryDatasetExportRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-export-repository.adapter.js'; +import { InMemoryDatasetVersionRepositoryAdapter } from '../../../src/features/dsm/adapter/in-memory-dataset-version-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'dataset-export-http', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const tenantContext = contextResult.value; + +void test('DSM-022 export endpoint accepts verification metadata and rejects raw rows', async () => { + const versions = new InMemoryDatasetVersionRepositoryAdapter(); + const version = createDatasetVersionManifestV1({ + datasetId: '55555555-5555-4555-8555-555555555555', + versionId: '66666666-6666-4666-8666-666666666666', + tenantScope: tenantContext.tenantScope, + inputArtifactVersionIds: [], + schemaVersionId: '77777777-7777-4777-8777-777777777777', + mappingVersionId: '88888888-8888-4888-8888-888888888888', + ruleSetVersionId: '99999999-9999-4999-8999-999999999999', + engineBuild: 'engine-1', + contentFingerprint: 'a'.repeat(64), + rowCount: 2, + qualityState: 'PASS', + lineageManifestHash: 'b'.repeat(64), + }); + assert.equal(version.accepted, true); + if (!version.accepted) return; + await versions.save(tenantContext, version.value); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(tenantContext), + }; + const { app } = await createApiApplication({ + datasetVersionRepository: versions, + datasetExportRepository: new InMemoryDatasetExportRepositoryAdapter(), + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/dataset-exports', + payload: { + manifestId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + datasetId: '55555555-5555-4555-8555-555555555555', + datasetVersionId: '66666666-6666-4666-8666-666666666666', + dataMode: 'HYBRID', + payloadClass: 'GOVERNED_DATA', + format: 'CSV', + rowCount: 2, + byteSize: 100, + contentSha256: 'c'.repeat(64), + schemaVersionId: '77777777-7777-4777-8777-777777777777', + mappingVersionId: '88888888-8888-4888-8888-888888888888', + ruleSetVersionId: '99999999-9999-4999-8999-999999999999', + semanticManifestHash: 'd'.repeat(64), + metricManifestHash: 'e'.repeat(64), + qualityManifestHash: 'f'.repeat(64), + lineageManifestHash: '0'.repeat(64), + evidenceManifestHash: '1'.repeat(64), + policyHash: '2'.repeat(64), + qualityState: 'PASS', + approvalState: 'NOT_REQUIRED', + createdAt: '2026-08-04T00:00:00.000Z', + rows: [{ forbidden: 'source value' }], + }, + }); + assert.equal(response.statusCode, 400); + const valid = await app.inject({ + method: 'POST', + url: '/v1/dataset-exports', + payload: { + manifestId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + datasetId: '55555555-5555-4555-8555-555555555555', + datasetVersionId: '66666666-6666-4666-8666-666666666666', + dataMode: 'HYBRID', + payloadClass: 'GOVERNED_DATA', + format: 'CSV', + rowCount: 2, + byteSize: 100, + contentSha256: 'c'.repeat(64), + schemaVersionId: '77777777-7777-4777-8777-777777777777', + mappingVersionId: '88888888-8888-4888-8888-888888888888', + ruleSetVersionId: '99999999-9999-4999-8999-999999999999', + semanticManifestHash: 'd'.repeat(64), + metricManifestHash: 'e'.repeat(64), + qualityManifestHash: 'f'.repeat(64), + lineageManifestHash: '0'.repeat(64), + evidenceManifestHash: '1'.repeat(64), + policyHash: '2'.repeat(64), + qualityState: 'PASS', + approvalState: 'NOT_REQUIRED', + createdAt: '2026-08-04T00:00:00.000Z', + }, + }); + assert.equal(valid.statusCode, 201); + assert.doesNotMatch(valid.body, /source value|rows/iu); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 9f06cd9a..51c9ced5 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -94,6 +94,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/auth/sign-out', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', + '/v1/dataset-exports', + '/v1/dataset-exports/{manifestId}', '/v1/dataset-profiles', '/v1/dataset-profiles/page', '/v1/dataset-profiles/{profileId}', From e396283ab4dda57b51b1b74abf5edf63a76a954c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:58:49 +0700 Subject: [PATCH 094/177] feat(dsm): persist governed export manifests --- .../migration.sql | 35 +++ services/api/prisma/schema/dsm.prisma | 34 +++ ...risma-dataset-export-repository.adapter.ts | 203 ++++++++++++++++++ services/api/src/features/dsm/dsm.module.ts | 12 +- .../prisma-dataset-export-repository.test.ts | 78 +++++++ services/api/test/prisma-foundation.test.mjs | 16 ++ 6 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 services/api/prisma/migrations/20260802290000_dsm_export_manifests/migration.sql create mode 100644 services/api/src/features/dsm/adapter/prisma-dataset-export-repository.adapter.ts create mode 100644 services/api/test/features/dsm/prisma-dataset-export-repository.test.ts diff --git a/services/api/prisma/migrations/20260802290000_dsm_export_manifests/migration.sql b/services/api/prisma/migrations/20260802290000_dsm_export_manifests/migration.sql new file mode 100644 index 00000000..55b546e6 --- /dev/null +++ b/services/api/prisma/migrations/20260802290000_dsm_export_manifests/migration.sql @@ -0,0 +1,35 @@ +-- DSM-022: persist governed export verification metadata without raw rows. +CREATE TABLE "dsm"."dataset_export_manifests" ( + "id" UUID NOT NULL, + "dataset_id" UUID NOT NULL, + "dataset_version_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "data_mode" VARCHAR(16) NOT NULL, + "payload_class" VARCHAR(32) NOT NULL, + "format" VARCHAR(16) NOT NULL, + "row_count" BIGINT NOT NULL, + "byte_size" BIGINT NOT NULL, + "content_sha256" CHAR(64) NOT NULL, + "schema_version_id" UUID NOT NULL, + "mapping_version_id" UUID NOT NULL, + "rule_set_version_id" UUID NOT NULL, + "semantic_manifest_hash" CHAR(64) NOT NULL, + "metric_manifest_hash" CHAR(64) NOT NULL, + "quality_manifest_hash" CHAR(64) NOT NULL, + "lineage_manifest_hash" CHAR(64) NOT NULL, + "evidence_manifest_hash" CHAR(64) NOT NULL, + "policy_hash" CHAR(64) NOT NULL, + "quality_state" VARCHAR(24) NOT NULL, + "approval_state" VARCHAR(16) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dataset_export_manifests_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "dataset_export_manifests_dataset_version_idx" + ON "dsm"."dataset_export_manifests"("dataset_version_id"); +CREATE INDEX "dataset_export_manifests_scope_idx" + ON "dsm"."dataset_export_manifests"("organization_id", "workspace_id", "project_id", "dataset_version_id"); diff --git a/services/api/prisma/schema/dsm.prisma b/services/api/prisma/schema/dsm.prisma index 082f0833..8078d105 100644 --- a/services/api/prisma/schema/dsm.prisma +++ b/services/api/prisma/schema/dsm.prisma @@ -98,6 +98,40 @@ model DatasetProfileRecord { @@schema("dsm") } +/// DSM-022: governed export verification metadata; raw rows live only in the approved output. +model DatasetExportManifestRecord { + id String @id @db.Uuid + datasetId String @map("dataset_id") @db.Uuid + datasetVersionId String @map("dataset_version_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + dataMode String @map("data_mode") @db.VarChar(16) + payloadClass String @map("payload_class") @db.VarChar(32) + format String @db.VarChar(16) + rowCount BigInt @map("row_count") + byteSize BigInt @map("byte_size") + contentSha256 String @map("content_sha256") @db.Char(64) + schemaVersionId String @map("schema_version_id") @db.Uuid + mappingVersionId String @map("mapping_version_id") @db.Uuid + ruleSetVersionId String @map("rule_set_version_id") @db.Uuid + semanticManifestHash String @map("semantic_manifest_hash") @db.Char(64) + metricManifestHash String @map("metric_manifest_hash") @db.Char(64) + qualityManifestHash String @map("quality_manifest_hash") @db.Char(64) + lineageManifestHash String @map("lineage_manifest_hash") @db.Char(64) + evidenceManifestHash String @map("evidence_manifest_hash") @db.Char(64) + policyHash String @map("policy_hash") @db.Char(64) + qualityState String @map("quality_state") @db.VarChar(24) + approvalState String @map("approval_state") @db.VarChar(16) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([datasetVersionId], map: "dataset_export_manifests_dataset_version_idx") + @@index([organizationId, workspaceId, projectId, datasetVersionId], map: "dataset_export_manifests_scope_idx") + @@map("dataset_export_manifests") + @@schema("dsm") +} + /// DSM-025: canonical workspace reference identities are versioned and immutable. model ReferenceEntityVersionRecord { id String @id @db.Uuid diff --git a/services/api/src/features/dsm/adapter/prisma-dataset-export-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-dataset-export-repository.adapter.ts new file mode 100644 index 00000000..aaccd5db --- /dev/null +++ b/services/api/src/features/dsm/adapter/prisma-dataset-export-repository.adapter.ts @@ -0,0 +1,203 @@ +import { + createDatasetExportManifestV1, + type DatasetExportManifestV1, +} from '@databreeze/domain/dataset-export/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + DatasetExportRepositoryPortV1, + DatasetExportTransactionPortV1, +} from '../application/dataset-export-repository.port.js'; + +export interface DatasetExportDatabaseRowV1 { + readonly id: string; + readonly datasetId: string; + readonly datasetVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly dataMode: string; + readonly payloadClass: string; + readonly format: string; + readonly rowCount: bigint | number; + readonly byteSize: bigint | number; + readonly contentSha256: string; + readonly schemaVersionId: string; + readonly mappingVersionId: string; + readonly ruleSetVersionId: string; + readonly semanticManifestHash: string; + readonly metricManifestHash: string; + readonly qualityManifestHash: string; + readonly lineageManifestHash: string; + readonly evidenceManifestHash: string; + readonly policyHash: string; + readonly qualityState: string; + readonly approvalState: string; + readonly createdAt: Date; +} + +export interface DatasetExportDatabaseCreateDataV1 + extends Omit { + readonly rowCount: bigint; + readonly byteSize: bigint; +} + +export interface DatasetExportDatabaseClientV1 { + readonly datasetExportManifestRecord: { + create(input: { + readonly data: DatasetExportDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + }; + $transaction( + work: (transaction: DatasetExportDatabaseClientV1) => 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 rowScope(row: DatasetExportDatabaseRowV1): 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('DSM_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function safeInteger(input: bigint | number): number { + const value = typeof input === 'bigint' ? Number(input) : input; + if (!Number.isSafeInteger(value) || value < 0) + throw new Error('DSM_PERSISTED_EXPORT_SIZE_INVALID'); + return value; +} + +function rowToDomain(row: DatasetExportDatabaseRowV1): DatasetExportManifestV1 { + const parsed = createDatasetExportManifestV1({ + manifestId: row.id, + datasetId: row.datasetId, + datasetVersionId: row.datasetVersionId, + tenantScope: rowScope(row), + dataMode: row.dataMode, + payloadClass: row.payloadClass, + format: row.format, + rowCount: safeInteger(row.rowCount), + byteSize: safeInteger(row.byteSize), + contentSha256: row.contentSha256, + schemaVersionId: row.schemaVersionId, + mappingVersionId: row.mappingVersionId, + ruleSetVersionId: row.ruleSetVersionId, + semanticManifestHash: row.semanticManifestHash, + metricManifestHash: row.metricManifestHash, + qualityManifestHash: row.qualityManifestHash, + lineageManifestHash: row.lineageManifestHash, + evidenceManifestHash: row.evidenceManifestHash, + policyHash: row.policyHash, + qualityState: row.qualityState, + approvalState: row.approvalState, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('DSM_PERSISTED_EXPORT_INVALID'); + return parsed.value; +} + +function domainToCreate(manifest: DatasetExportManifestV1): DatasetExportDatabaseCreateDataV1 { + return { + ...databaseScope(manifest.tenantScope), + id: manifest.manifestId, + datasetId: manifest.datasetId, + datasetVersionId: manifest.datasetVersionId, + dataMode: manifest.dataMode, + payloadClass: manifest.payloadClass, + format: manifest.format, + rowCount: BigInt(manifest.rowCount), + byteSize: BigInt(manifest.byteSize), + contentSha256: manifest.contentSha256, + schemaVersionId: manifest.schemaVersionId, + mappingVersionId: manifest.mappingVersionId, + ruleSetVersionId: manifest.ruleSetVersionId, + semanticManifestHash: manifest.semanticManifestHash, + metricManifestHash: manifest.metricManifestHash, + qualityManifestHash: manifest.qualityManifestHash, + lineageManifestHash: manifest.lineageManifestHash, + evidenceManifestHash: manifest.evidenceManifestHash, + policyHash: manifest.policyHash, + qualityState: manifest.qualityState, + approvalState: manifest.approvalState, + createdAt: new Date(manifest.createdAt), + }; +} + +function visible(context: TenantScopeV1, row: DatasetExportDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaDatasetExportTransactionAdapter implements DatasetExportTransactionPortV1 { + public constructor(private readonly client: DatasetExportDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, manifest: DatasetExportManifestV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, manifest.tenantScope)) + throw new Error('DSM_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.datasetExportManifestRecord.findUnique({ + where: { id: manifest.manifestId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(manifest)) + throw new Error('DSM_IMMUTABLE_EXPORT_MANIFEST'); + return; + } + await this.client.datasetExportManifestRecord.create({ data: domainToCreate(manifest) }); + } + + public async find( + context: IamTenantContextV1, + manifestId: DatasetExportManifestV1['manifestId'], + ): Promise { + const row = await this.client.datasetExportManifestRecord.findUnique({ + where: { id: manifestId }, + }); + return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; + } +} + +export class PrismaDatasetExportRepositoryAdapter implements DatasetExportRepositoryPortV1 { + public constructor(private readonly client: DatasetExportDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: DatasetExportTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaDatasetExportTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, manifest: DatasetExportManifestV1): Promise { + return new PrismaDatasetExportTransactionAdapter(this.client).save(context, manifest); + } + + public find( + context: IamTenantContextV1, + manifestId: DatasetExportManifestV1['manifestId'], + ): Promise { + return new PrismaDatasetExportTransactionAdapter(this.client).find(context, manifestId); + } +} diff --git a/services/api/src/features/dsm/dsm.module.ts b/services/api/src/features/dsm/dsm.module.ts index 50199ce2..f3694555 100644 --- a/services/api/src/features/dsm/dsm.module.ts +++ b/services/api/src/features/dsm/dsm.module.ts @@ -10,6 +10,10 @@ import { DatasetProfileController } from './api/dataset-profile.controller.js'; import { DatasetExportController } from './api/dataset-export.controller.js'; import { InMemoryDatasetProfileRepositoryAdapter } from './adapter/in-memory-dataset-profile-repository.adapter.js'; import { InMemoryDatasetExportRepositoryAdapter } from './adapter/in-memory-dataset-export-repository.adapter.js'; +import { + PrismaDatasetExportRepositoryAdapter, + type DatasetExportDatabaseClientV1, +} from './adapter/prisma-dataset-export-repository.adapter.js'; import { PrismaDatasetProfileRepositoryAdapter, type DatasetProfileDatabaseClientV1, @@ -105,6 +109,8 @@ export interface DsmModuleOptions { /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly datasetProfileDatabase?: DatasetProfileDatabaseClientV1; readonly datasetExportRepository?: DatasetExportRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly datasetExportDatabase?: DatasetExportDatabaseClientV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -182,7 +188,11 @@ export class DsmModule { }, { provide: DATASET_EXPORT_REPOSITORY_PORT, - useValue: options.datasetExportRepository ?? new InMemoryDatasetExportRepositoryAdapter(), + useValue: + options.datasetExportRepository ?? + (options.datasetExportDatabase === undefined + ? new InMemoryDatasetExportRepositoryAdapter() + : new PrismaDatasetExportRepositoryAdapter(options.datasetExportDatabase)), }, { provide: REQUEST_TENANT_CONTEXT, diff --git a/services/api/test/features/dsm/prisma-dataset-export-repository.test.ts b/services/api/test/features/dsm/prisma-dataset-export-repository.test.ts new file mode 100644 index 00000000..166099d2 --- /dev/null +++ b/services/api/test/features/dsm/prisma-dataset-export-repository.test.ts @@ -0,0 +1,78 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createDatasetExportManifestV1 } from '@databreeze/domain/dataset-export/v1'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { + PrismaDatasetExportRepositoryAdapter, + type DatasetExportDatabaseClientV1, + type DatasetExportDatabaseRowV1, +} from '../../../src/features/dsm/adapter/prisma-dataset-export-repository.adapter.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'prisma-dataset-export', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const manifest = createDatasetExportManifestV1({ + manifestId: '55555555-5555-4555-8555-555555555555', + datasetId: '66666666-6666-4666-8666-666666666666', + datasetVersionId: '77777777-7777-4777-8777-777777777777', + tenantScope: context.tenantScope, + dataMode: 'HYBRID', + payloadClass: 'GOVERNED_DATA', + format: 'JSONL', + rowCount: 2, + byteSize: 100, + contentSha256: 'a'.repeat(64), + schemaVersionId: '88888888-8888-4888-8888-888888888888', + mappingVersionId: '99999999-9999-4999-8999-999999999999', + ruleSetVersionId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + semanticManifestHash: 'b'.repeat(64), + metricManifestHash: 'c'.repeat(64), + qualityManifestHash: 'd'.repeat(64), + lineageManifestHash: 'e'.repeat(64), + evidenceManifestHash: 'f'.repeat(64), + policyHash: '0'.repeat(64), + qualityState: 'PASS', + approvalState: 'APPROVED', + createdAt: '2026-08-04T00:00:00.000Z', +}); +if (!manifest.accepted) throw new Error('fixture export invalid'); + +function client(rows: DatasetExportDatabaseRowV1[]): DatasetExportDatabaseClientV1 { + return { + datasetExportManifestRecord: { + create({ data }) { + const row = { ...data } as DatasetExportDatabaseRowV1; + rows.push(row); + return Promise.resolve(row); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('DSM-022 Prisma export adapter persists only manifest metadata', async () => { + const rows: DatasetExportDatabaseRowV1[] = []; + const repository = new PrismaDatasetExportRepositoryAdapter(client(rows)); + await repository.save(context, manifest.value); + await repository.save(context, manifest.value); + assert.deepEqual(await repository.find(context, manifest.value.manifestId), manifest.value); + assert.equal(rows.length, 1); + assert.equal(Object.hasOwn(rows[0] as object, 'rows'), false); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 1a5ad276..cfd55f6b 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -65,6 +65,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_quality_results"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_profiles"/); assert.match(diff.stdout, /CREATE TABLE "iae"\."protected_document_unlock_requests"/); + assert.match(diff.stdout, /CREATE TABLE "dsm"\."dataset_export_manifests"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_versions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."reference_entity_resolutions"/); assert.match(diff.stdout, /CREATE TABLE "dsm"\."mapping_definitions"/); @@ -116,6 +117,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802260000_iae_inbox_metadata', '20260802270000_dsm_profiles', '20260802280000_iae_protected_document_unlocks', + '20260802290000_dsm_export_manifests', 'migration_lock.toml', ]); const migration = await readFile( @@ -459,4 +461,18 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const datasetExportMigration = await readFile( + path.join(migrationsDirectory, inventory[30], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE TABLE "dsm"."dataset_export_manifests"', + 'CREATE INDEX "dataset_export_manifests_dataset_version_idx"', + '"policy_hash" CHAR(64)', + ]) { + assert.match( + datasetExportMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From 27f8a562806e9aa6feb00159126ef34bd1ffcefa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 02:59:53 +0700 Subject: [PATCH 095/177] fix(iae): clean expired upload storage state --- .../application/artifact-upload.service.ts | 12 ++++-- .../iae/artifact-upload.service.test.ts | 40 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/services/api/src/features/iae/application/artifact-upload.service.ts b/services/api/src/features/iae/application/artifact-upload.service.ts index f4e12e51..5c69f0ad 100644 --- a/services/api/src/features/iae/application/artifact-upload.service.ts +++ b/services/api/src/features/iae/application/artifact-upload.service.ts @@ -112,9 +112,15 @@ export class ArtifactUploadService { sessionId: ArtifactUploadSessionV1['sessionId'], now: unknown, ): Promise> { - return this.mutate(context, sessionId, (session) => - expireArtifactUploadSessionV1(session, now), - ); + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.find(context, sessionId); + if (!current) return Object.freeze({ accepted: false, code: 'UPLOAD_NOT_FOUND' as const }); + const next = expireArtifactUploadSessionV1(current, now); + if (!next.accepted) return next; + await this.storage.abort(context, current); + await transaction.save(context, next.value); + return next; + }); } public async issuePartTransfer( diff --git a/services/api/test/features/iae/artifact-upload.service.test.ts b/services/api/test/features/iae/artifact-upload.service.test.ts index 87a5c5d8..1340927a 100644 --- a/services/api/test/features/iae/artifact-upload.service.test.ts +++ b/services/api/test/features/iae/artifact-upload.service.test.ts @@ -6,6 +6,17 @@ import { ArtifactUploadService } from '../../../src/features/iae/application/art import { InMemoryArtifactUploadRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-repository.adapter.js'; import { InMemoryArtifactUploadStorageAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-upload-storage.adapter.js'; +class TrackingStorageAdapter extends InMemoryArtifactUploadStorageAdapter { + public abortCalls = 0; + + public override async abort( + ...argumentsList: Parameters + ): Promise { + this.abortCalls += 1; + await super.abort(...argumentsList); + } +} + const contextResult = createIamTenantContextV1({ actorId: '11111111-1111-4111-8111-111111111111', tenantScope: { @@ -59,3 +70,32 @@ void test('IAE-014 service persists parts and rejects stale completion', async ( if (!completed.accepted) return; assert.equal(completed.value.state, 'COMPLETED'); }); + +void test('IAE-014 expiration revokes storage-side partial state before persisting terminal status', async () => { + const storage = new TrackingStorageAdapter(); + const service = new ArtifactUploadService(new InMemoryArtifactUploadRepositoryAdapter(), storage); + const created = await service.create(context, { + sessionId: '77777777-7777-4777-8777-777777777777', + artifactId: '88888888-8888-4888-8888-888888888888', + tenantScope: context.tenantScope, + expectedSha256: 'a'.repeat(64), + expectedByteSize: 4, + mediaType: 'application/octet-stream', + partSize: 4, + createdAt: '2026-08-02T00:00:00.000Z', + expiresAt: '2026-08-02T01:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const expired = await service.expire( + context, + created.value.sessionId, + '2026-08-02T01:00:00.000Z', + ); + assert.equal(expired.accepted, true); + if (!expired.accepted) return; + assert.equal(expired.value.state, 'EXPIRED'); + assert.equal(storage.abortCalls, 1); + const transfer = await service.issuePartTransfer(context, created.value.sessionId, 1); + assert.deepEqual(transfer, { accepted: false, code: 'UPLOAD_STORAGE_NOT_READY' }); +}); From 135cac86d3e0a54c001b8d79eb32c054e3e39f00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:02:18 +0700 Subject: [PATCH 096/177] feat(sa): add safe deterministic spreadsheet auditor core --- .../databreeze_engine/processors/__init__.py | 3 + .../processors/spreadsheet_auditor.py | 232 ++++++++++++++++++ .../engine/tests/test_spreadsheet_auditor.py | 53 ++++ 3 files changed, 288 insertions(+) create mode 100644 services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py create mode 100644 services/engine/tests/test_spreadsheet_auditor.py diff --git a/services/engine/src/databreeze_engine/processors/__init__.py b/services/engine/src/databreeze_engine/processors/__init__.py index 486ff7a1..2825d268 100644 --- a/services/engine/src/databreeze_engine/processors/__init__.py +++ b/services/engine/src/databreeze_engine/processors/__init__.py @@ -1 +1,4 @@ """Reviewed built-in processors composed into the closed registry.""" +from .spreadsheet_auditor import SpreadsheetAuditError, SpreadsheetAuditResult, audit_workbook + +__all__ = ["SpreadsheetAuditError", "SpreadsheetAuditResult", "audit_workbook"] diff --git a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py new file mode 100644 index 00000000..9c1add53 --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py @@ -0,0 +1,232 @@ +"""Safe deterministic workbook inventory and formula-family auditing (SA-001..SA-004).""" + +from __future__ import annotations + +import hashlib +import io +import posixpath +import re +import zipfile +from collections import Counter +from collections.abc import Iterator +from typing import Literal +from xml.etree import ElementTree as Xml + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr + +_SHEET_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +_DOC_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +_CELL_REFERENCE = re.compile(r"^(?P[A-Z]{1,3})(?P[1-9][0-9]*)$", re.IGNORECASE) +_FORMULA_REFERENCE = re.compile(r"\$?[A-Z]{1,3}\$?[1-9][0-9]*", re.IGNORECASE) +_FORMULA_SPACE = re.compile(r"\s+") +_MAX_MEMBERS = 2_048 +_MAX_XML_BYTES = 64 * 1024 * 1024 +_MAX_UNCOMPRESSED_BYTES = 256 * 1024 * 1024 +_MAX_CELLS = 1_000_000 + + +class SpreadsheetSheetSummary(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + name: StrictStr = Field(min_length=1, max_length=128) + maxRow: StrictInt = Field(ge=0) + maxColumn: StrictInt = Field(ge=0) + formulaCount: StrictInt = Field(ge=0) + + +class SpreadsheetFinding(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + sheet: StrictStr = Field(min_length=1, max_length=128) + address: StrictStr = Field(pattern=r"^[A-Z]{1,3}[1-9][0-9]*$") + kind: Literal["FORMULA_FAMILY_OUTLIER", "FORMULA_GAP"] + formulaFingerprint: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + + +class SpreadsheetAuditResult(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + workbookSha256: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + sheets: tuple[SpreadsheetSheetSummary, ...] + findings: tuple[SpreadsheetFinding, ...] + blockedReasons: tuple[Literal["MACRO", "EXTERNAL_LINK", "UNSUPPORTED_XML"], ...] + + +class SpreadsheetAuditError(ValueError): + """Stable parser failure without exposing workbook content.""" + + def __init__(self, code: Literal["INVALID_ARCHIVE", "RESOURCE_LIMIT", "MALFORMED_XML"]) -> None: + super().__init__(code) + self.code = code + + +def _safe_member(name: str) -> bool: + if not name or name.startswith("/") or "\\" in name: + return False + normalized = posixpath.normpath(name) + return normalized == name and normalized != "." and not normalized.startswith("../") + + +def _xml(data: bytes) -> Xml.Element: + if len(data) > _MAX_XML_BYTES: + raise SpreadsheetAuditError("RESOURCE_LIMIT") + if b" int: + value = 0 + for character in column.upper(): + value = value * 26 + ord(character) - 64 + return value + + +def _cell_address(reference: str) -> tuple[int, int] | None: + match = _CELL_REFERENCE.fullmatch(reference) + if match is None: + return None + return _column_number(match.group("column")), int(match.group("row")) + + +def _normalized_formula(value: str) -> str: + normalized = _FORMULA_SPACE.sub(" ", value.strip().upper()) + return _FORMULA_REFERENCE.sub("#CELL", normalized) + + +def _fingerprint(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _relationships(root: Xml.Element) -> dict[str, str]: + result: dict[str, str] = {} + for relation in root.findall(f"{{{_REL_NS}}}Relationship"): + relation_id = relation.attrib.get("Id") + target = relation.attrib.get("Target") + if relation_id is None or target is None: + continue + result[relation_id] = target + return result + + +def _sheet_targets(archive: zipfile.ZipFile) -> list[tuple[str, str]]: + workbook = _xml(archive.read("xl/workbook.xml")) + relationships = _relationships(_xml(archive.read("xl/_rels/workbook.xml.rels"))) + sheets: list[tuple[str, str]] = [] + for sheet in workbook.findall(f"{{{_SHEET_NS}}}sheets/{{{_SHEET_NS}}}sheet"): + name = sheet.attrib.get("name") + relation_id = sheet.attrib.get(f"{{{_DOC_REL_NS}}}id") + if name is None or relation_id is None: + raise SpreadsheetAuditError("MALFORMED_XML") + target = relationships.get(relation_id) + if target is None: + raise SpreadsheetAuditError("MALFORMED_XML") + target_path = posixpath.normpath(posixpath.join("xl", target)) + if not target_path.startswith("xl/") or not _safe_member(target_path): + raise SpreadsheetAuditError("INVALID_ARCHIVE") + sheets.append((name, target_path)) + return sheets + + +def _iter_cells(root: Xml.Element) -> Iterator[tuple[str, str | None]]: + for cell in root.iter(f"{{{_SHEET_NS}}}c"): + reference = cell.attrib.get("r") + if reference is None: + continue + formula = cell.find(f"{{{_SHEET_NS}}}f") + yield reference, None if formula is None else "".join(formula.itertext()) + + +def audit_workbook( + content: bytes, + *, + max_uncompressed_bytes: int = _MAX_UNCOMPRESSED_BYTES, + max_cells: int = _MAX_CELLS, +) -> SpreadsheetAuditResult: + """Inventory a workbook and report formula-family anomalies without returning values.""" + if not isinstance(content, bytes) or not content: + raise SpreadsheetAuditError("INVALID_ARCHIVE") + if max_uncompressed_bytes < 1 or max_cells < 1: + raise SpreadsheetAuditError("RESOURCE_LIMIT") + workbook_sha256 = hashlib.sha256(content).hexdigest() + try: + archive = zipfile.ZipFile(io.BytesIO(content)) + except (OSError, zipfile.BadZipFile): + raise SpreadsheetAuditError("INVALID_ARCHIVE") from None + with archive: + infos = archive.infolist() + if len(infos) > _MAX_MEMBERS: + raise SpreadsheetAuditError("RESOURCE_LIMIT") + total_size = 0 + names: set[str] = set() + blocked: set[Literal["MACRO", "EXTERNAL_LINK", "UNSUPPORTED_XML"]] = set() + for info in infos: + if not _safe_member(info.filename) or info.filename in names: + raise SpreadsheetAuditError("INVALID_ARCHIVE") + names.add(info.filename) + total_size += info.file_size + if total_size > max_uncompressed_bytes: + raise SpreadsheetAuditError("RESOURCE_LIMIT") + if info.filename.lower().endswith("vbaproject.bin"): + blocked.add("MACRO") + if info.filename.lower().startswith("xl/externallinks/"): + blocked.add("EXTERNAL_LINK") + if "xl/workbook.xml" not in names or "xl/_rels/workbook.xml.rels" not in names: + raise SpreadsheetAuditError("INVALID_ARCHIVE") + try: + targets = _sheet_targets(archive) + except KeyError: + raise SpreadsheetAuditError("MALFORMED_XML") from None + summaries: list[SpreadsheetSheetSummary] = [] + findings: list[SpreadsheetFinding] = [] + total_cells = 0 + for sheet_name, target in targets: + if target not in names: + raise SpreadsheetAuditError("INVALID_ARCHIVE") + root = _xml(archive.read(target)) + max_row = 0 + max_column = 0 + formulas: list[tuple[str, str]] = [] + for address, formula in _iter_cells(root): + total_cells += 1 + if total_cells > max_cells: + raise SpreadsheetAuditError("RESOURCE_LIMIT") + coordinates = _cell_address(address) + if coordinates is None: + blocked.add("UNSUPPORTED_XML") + continue + column, row = coordinates + max_column = max(max_column, column) + max_row = max(max_row, row) + if formula is not None: + formulas.append((address.upper(), formula)) + families = Counter(_normalized_formula(formula) for _, formula in formulas) + for address, formula in formulas: + family = _normalized_formula(formula) + if families[family] == 1 and len(formulas) >= 3: + findings.append( + SpreadsheetFinding( + sheet=sheet_name, + address=address, + kind="FORMULA_FAMILY_OUTLIER", + formulaFingerprint=_fingerprint(family), + ) + ) + summaries.append( + SpreadsheetSheetSummary( + name=sheet_name, + maxRow=max_row, + maxColumn=max_column, + formulaCount=len(formulas), + ) + ) + return SpreadsheetAuditResult( + workbookSha256=workbook_sha256, + sheets=tuple(summaries), + findings=tuple(findings), + blockedReasons=tuple(sorted(blocked)), + ) diff --git a/services/engine/tests/test_spreadsheet_auditor.py b/services/engine/tests/test_spreadsheet_auditor.py new file mode 100644 index 00000000..01112531 --- /dev/null +++ b/services/engine/tests/test_spreadsheet_auditor.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import io +import zipfile + +import pytest + +from databreeze_engine.processors.spreadsheet_auditor import SpreadsheetAuditError, audit_workbook + + +def _workbook(*, macro: bool = False, external_link: bool = False) -> bytes: + workbook = b'''''' + relationships = b'''''' + sheet = b'''SUM(B1:C1)3SUM(B1:C1)3SUM(B1:D1)4''' + output = io.BytesIO() + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("xl/workbook.xml", workbook) + archive.writestr("xl/_rels/workbook.xml.rels", relationships) + archive.writestr("xl/worksheets/sheet1.xml", sheet) + if macro: + archive.writestr("xl/vbaProject.bin", b"not executed") + if external_link: + archive.writestr("xl/externalLinks/externalLink1.xml", b"") + return output.getvalue() + + +def test_audit_is_value_free_and_reports_formula_family_outlier() -> None: + result = audit_workbook(_workbook()) + assert result.sheets[0].name == "Inventory" + assert result.sheets[0].formulaCount == 3 + assert len(result.findings) == 1 + assert result.findings[0].address == "C1" + assert "SUM(B1:D1)" not in result.model_dump_json() + assert result.blockedReasons == () + + +@pytest.mark.parametrize("flag", ["macro", "external_link"]) +def test_audit_discloses_blocked_execution_features_without_running_them(flag: str) -> None: + result = audit_workbook(_workbook(**{flag: True})) + if flag == "macro": + assert "MACRO" in result.blockedReasons + else: + assert "EXTERNAL_LINK" in result.blockedReasons + + +def test_audit_rejects_archive_traversal_and_cell_resource_exhaustion() -> None: + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr("../escape.xml", b"bad") + with pytest.raises(SpreadsheetAuditError, match="INVALID_ARCHIVE"): + audit_workbook(output.getvalue()) + with pytest.raises(SpreadsheetAuditError, match="RESOURCE_LIMIT"): + audit_workbook(_workbook(), max_cells=1) From 7a03e215900647d41d2bd6cc40eafb1d04d0ea86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:04:19 +0700 Subject: [PATCH 097/177] feat(sa): add value-free spreadsheet audit result contract --- packages/domain/package.json | 4 + packages/domain/src/spreadsheet-audit/v1.ts | 210 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/built-public-api-smoke.mjs | 3 + packages/domain/test/public-api-v1.test.mjs | 2 + .../domain/test/spreadsheet-audit-v1.test.mjs | 56 +++++ 6 files changed, 276 insertions(+) create mode 100644 packages/domain/src/spreadsheet-audit/v1.ts create mode 100644 packages/domain/test/spreadsheet-audit-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index 56ddf114..3499f462 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -108,6 +108,10 @@ "types": "./src/dataset-export/v1.ts", "import": "./dist/dataset-export/v1.js" }, + "./spreadsheet-audit/v1": { + "types": "./src/spreadsheet-audit/v1.ts", + "import": "./dist/spreadsheet-audit/v1.js" + }, "./jobs/v1": { "types": "./src/jobs/v1.ts", "import": "./dist/jobs/v1.js" diff --git a/packages/domain/src/spreadsheet-audit/v1.ts b/packages/domain/src/spreadsheet-audit/v1.ts new file mode 100644 index 00000000..651a14c5 --- /dev/null +++ b/packages/domain/src/spreadsheet-audit/v1.ts @@ -0,0 +1,210 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** SA-001..SA-006: value-free, exact-version spreadsheet audit results. */ +export const SPREADSHEET_AUDIT_SCHEMA_VERSION_V1 = 1 as const; + +export type SpreadsheetAuditFindingKindV1 = 'FORMULA_FAMILY_OUTLIER' | 'FORMULA_GAP'; +export type SpreadsheetAuditSeverityV1 = 'INFO' | 'WARNING' | 'ERROR'; +export type SpreadsheetAuditBlockedReasonV1 = 'MACRO' | 'EXTERNAL_LINK' | 'UNSUPPORTED_XML'; + +export interface SpreadsheetAuditSheetV1 { + readonly sheetId: StableIdentifierV1; + readonly name: string; + readonly maxRow: number; + readonly maxColumn: number; + readonly formulaCount: number; +} + +export interface SpreadsheetAuditFindingV1 { + readonly findingId: StableIdentifierV1; + readonly sheetId: StableIdentifierV1; + readonly address: string; + readonly kind: SpreadsheetAuditFindingKindV1; + readonly severity: SpreadsheetAuditSeverityV1; + readonly formulaFingerprint: string; +} + +export interface SpreadsheetAuditResultV1 { + readonly schemaVersion: typeof SPREADSHEET_AUDIT_SCHEMA_VERSION_V1; + readonly auditId: StableIdentifierV1; + readonly artifactVersionId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly workbookSha256: string; + readonly sheets: readonly SpreadsheetAuditSheetV1[]; + readonly findings: readonly SpreadsheetAuditFindingV1[]; + readonly blockedReasons: readonly SpreadsheetAuditBlockedReasonV1[]; + readonly processorVersion: string; + readonly createdAt: StrictUtcTimestampV1; +} + +export type SpreadsheetAuditErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_TEXT' + | 'INVALID_HASH' + | 'INVALID_COORDINATE' + | 'INVALID_COUNT' + | 'INVALID_SEVERITY' + | 'INVALID_KIND' + | 'INVALID_BLOCKED_REASON' + | 'DUPLICATE_IDENTIFIER' + | 'DUPLICATE_SHEET' + | 'INVALID_TIMESTAMP'; + +export type SpreadsheetAuditResultValidationV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: SpreadsheetAuditErrorCodeV1 }; + +function rejected(code: SpreadsheetAuditErrorCodeV1): SpreadsheetAuditResultValidationV1 { + return Object.freeze({ accepted: false, code }); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +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 hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) + ? input.toLowerCase() + : undefined; +} + +function count(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0 ? input : undefined; +} + +function sheet(input: unknown): SpreadsheetAuditSheetV1 | undefined { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; + const record = input as Record; + const sheetId = identifier(record['sheetId']); + const name = text(record['name'], 128); + const maxRow = count(record['maxRow']); + const maxColumn = count(record['maxColumn']); + const formulaCount = count(record['formulaCount']); + if ( + !sheetId || + !name || + maxRow === undefined || + maxColumn === undefined || + formulaCount === undefined + ) + return undefined; + if (maxRow > 1_000_000 || maxColumn > 16_384 || formulaCount > 1_000_000) return undefined; + return Object.freeze({ sheetId, name, maxRow, maxColumn, formulaCount }); +} + +function finding(input: unknown): SpreadsheetAuditFindingV1 | undefined { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; + const record = input as Record; + const findingId = identifier(record['findingId']); + const sheetId = identifier(record['sheetId']); + const address = text(record['address'], 16); + const kind = record['kind']; + const severity = record['severity']; + const formulaFingerprint = hash(record['formulaFingerprint']); + if (!findingId || !sheetId || !address || !/^[A-Z]{1,3}[1-9][0-9]*$/u.test(address.toUpperCase())) + return undefined; + if (kind !== 'FORMULA_FAMILY_OUTLIER' && kind !== 'FORMULA_GAP') return undefined; + if (severity !== 'INFO' && severity !== 'WARNING' && severity !== 'ERROR') return undefined; + if (!formulaFingerprint) return undefined; + return Object.freeze({ + findingId, + sheetId, + address: address.toUpperCase(), + kind: kind as SpreadsheetAuditFindingKindV1, + severity: severity as SpreadsheetAuditSeverityV1, + formulaFingerprint, + }); +} + +export function createSpreadsheetAuditResultV1(input: { + readonly auditId: unknown; + readonly artifactVersionId: unknown; + readonly tenantScope: unknown; + readonly workbookSha256: unknown; + readonly sheets: unknown; + readonly findings: unknown; + readonly blockedReasons: unknown; + readonly processorVersion: unknown; + readonly createdAt: unknown; +}): SpreadsheetAuditResultValidationV1 { + const auditId = identifier(input.auditId); + const artifactVersionId = identifier(input.artifactVersionId); + const tenantScope = parseTenantScopeV1(input.tenantScope); + const workbookSha256 = hash(input.workbookSha256); + const processorVersion = text(input.processorVersion, 128); + const createdAt = timestamp(input.createdAt); + if (!auditId || !artifactVersionId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope.accepted) return rejected('INVALID_SCOPE'); + if (!workbookSha256) return rejected('INVALID_HASH'); + if (!processorVersion) return rejected('INVALID_TEXT'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + if (!Array.isArray(input.sheets) || input.sheets.length === 0 || input.sheets.length > 512) + return rejected('INVALID_COUNT'); + const sheets = input.sheets.map(sheet); + if (sheets.some((candidate): candidate is undefined => candidate === undefined)) + return rejected('INVALID_COUNT'); + const validSheets = sheets as SpreadsheetAuditSheetV1[]; + if (new Set(validSheets.map((candidate) => candidate.sheetId)).size !== validSheets.length) + return rejected('DUPLICATE_IDENTIFIER'); + if (new Set(validSheets.map((candidate) => candidate.name)).size !== validSheets.length) + return rejected('DUPLICATE_SHEET'); + if (!Array.isArray(input.findings) || input.findings.length > 10_000) + return rejected('INVALID_COUNT'); + const findings = input.findings.map(finding); + if (findings.some((candidate): candidate is undefined => candidate === undefined)) + return rejected('INVALID_COUNT'); + const validFindings = findings as SpreadsheetAuditFindingV1[]; + if (new Set(validFindings.map((candidate) => candidate.findingId)).size !== validFindings.length) + return rejected('DUPLICATE_IDENTIFIER'); + const sheetIds = new Set(validSheets.map((candidate) => candidate.sheetId)); + if (validFindings.some((candidate) => !sheetIds.has(candidate.sheetId))) + return rejected('INVALID_IDENTIFIER'); + if (!Array.isArray(input.blockedReasons) || input.blockedReasons.length > 3) + return rejected('INVALID_BLOCKED_REASON'); + const blockedReasons = input.blockedReasons; + if ( + blockedReasons.some( + (candidate) => + candidate !== 'MACRO' && candidate !== 'EXTERNAL_LINK' && candidate !== 'UNSUPPORTED_XML', + ) + ) + return rejected('INVALID_BLOCKED_REASON'); + if (new Set(blockedReasons).size !== blockedReasons.length) + return rejected('INVALID_BLOCKED_REASON'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ + schemaVersion: SPREADSHEET_AUDIT_SCHEMA_VERSION_V1, + auditId, + artifactVersionId, + tenantScope: tenantScope.value, + workbookSha256, + sheets: Object.freeze(validSheets), + findings: Object.freeze(validFindings), + blockedReasons: Object.freeze([...blockedReasons] as SpreadsheetAuditBlockedReasonV1[]), + processorVersion, + createdAt, + }), + }); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 36575dbc..383101b9 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -12,6 +12,7 @@ export * from './dataset-governance/v1.js'; export * from './dataset-quality/v1.js'; export * from './dataset-profile/v1.js'; export * from './dataset-export/v1.js'; +export * from './spreadsheet-audit/v1.js'; export * from './jobs/v1.js'; export * from './approval/v1.js'; export * from './execution-attempt/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 837fbfd2..88cd28a4 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -17,6 +17,7 @@ const [ datasetQuality, datasetProfile, datasetExport, + spreadsheetAudit, dataMode, jobs, approval, @@ -46,6 +47,7 @@ const [ import('@databreeze/domain/dataset-quality/v1'), import('@databreeze/domain/dataset-profile/v1'), import('@databreeze/domain/dataset-export/v1'), + import('@databreeze/domain/spreadsheet-audit/v1'), import('@databreeze/domain/data-mode/v1'), import('@databreeze/domain/jobs/v1'), import('@databreeze/domain/approval/v1'), @@ -77,6 +79,7 @@ assert.equal(datasetGovernance.DATASET_GOVERNANCE_SCHEMA_VERSION_V1, 1); assert.equal(datasetQuality.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(datasetProfile.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(datasetExport.DATASET_EXPORT_SCHEMA_VERSION_V1, 1); +assert.equal(spreadsheetAudit.SPREADSHEET_AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); assert.equal(jobs.JOB_SCHEMA_VERSION_V1, 1); assert.equal(approval.APPROVAL_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 3121e58f..49613204 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -35,6 +35,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './dataset-quality/v1', './dataset-profile/v1', './dataset-export/v1', + './spreadsheet-audit/v1', './jobs/v1', './approval/v1', './execution-attempt/v1', @@ -73,6 +74,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_EXPORT_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.SPREADSHEET_AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.PROTECTED_DOCUMENT_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/spreadsheet-audit-v1.test.mjs b/packages/domain/test/spreadsheet-audit-v1.test.mjs new file mode 100644 index 00000000..4ee400d5 --- /dev/null +++ b/packages/domain/test/spreadsheet-audit-v1.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createSpreadsheetAuditResultV1 } from '../dist/spreadsheet-audit/v1.js'; + +const sheetId = '11111111-1111-4111-8111-111111111111'; +const base = { + auditId: '22222222-2222-4222-8222-222222222222', + artifactVersionId: '33333333-3333-4333-8333-333333333333', + tenantScope: { + scopeType: 'workspace', + organizationId: '44444444-4444-4444-8444-444444444444', + workspaceId: '55555555-5555-4555-8555-555555555555', + }, + workbookSha256: 'a'.repeat(64), + sheets: [{ sheetId, name: 'Inventory', maxRow: 10, maxColumn: 4, formulaCount: 3 }], + findings: [ + { + findingId: '66666666-6666-4666-8666-666666666666', + sheetId, + address: 'c1', + kind: 'FORMULA_FAMILY_OUTLIER', + severity: 'WARNING', + formulaFingerprint: 'b'.repeat(64), + }, + ], + blockedReasons: ['MACRO'], + processorVersion: 'spreadsheet-auditor@1.0.0', + createdAt: '2026-08-04T00:00:00.000Z', +}; + +void test('[SA-001, SA-004] audit results retain exact value-free evidence coordinates', () => { + const result = createSpreadsheetAuditResultV1(base); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.findings[0]?.address, 'C1'); + assert.equal(Object.hasOwn(result.value, 'formula'), false); + assert.equal(Object.hasOwn(result.value, 'sourceValue'), false); +}); + +void test('[SA-005] findings cannot reference an unknown sheet or duplicate IDs', () => { + assert.deepEqual( + createSpreadsheetAuditResultV1({ + ...base, + findings: [{ ...base.findings[0], sheetId: '77777777-7777-4777-8777-777777777777' }], + }), + { accepted: false, code: 'INVALID_IDENTIFIER' }, + ); + assert.deepEqual( + createSpreadsheetAuditResultV1({ + ...base, + findings: [base.findings[0], base.findings[0]], + }), + { accepted: false, code: 'DUPLICATE_IDENTIFIER' }, + ); +}); From 032ba163829aed3dbbd492bc5337f0e2bcea96fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:08:19 +0700 Subject: [PATCH 098/177] feat(sa): coordinate immutable spreadsheet audit results --- ...ry-spreadsheet-audit-repository.adapter.ts | 91 +++++++++++++++++++ .../spreadsheet-audit-repository.port.ts | 24 +++++ .../application/spreadsheet-audit.service.ts | 72 +++++++++++++++ .../sa/spreadsheet-audit.service.test.ts | 90 ++++++++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts create mode 100644 services/api/src/features/sa/application/spreadsheet-audit-repository.port.ts create mode 100644 services/api/src/features/sa/application/spreadsheet-audit.service.ts create mode 100644 services/api/test/features/sa/spreadsheet-audit.service.test.ts diff --git a/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts b/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts new file mode 100644 index 00000000..9fb2bb66 --- /dev/null +++ b/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts @@ -0,0 +1,91 @@ +import { + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import type { SpreadsheetAuditResultV1 } from '@databreeze/domain/spreadsheet-audit/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + SpreadsheetAuditRepositoryPortV1, + SpreadsheetAuditTransactionPortV1, +} from '../application/spreadsheet-audit-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(result: SpreadsheetAuditResultV1): SpreadsheetAuditResultV1 { + return Object.freeze({ + ...result, + tenantScope: Object.freeze({ ...result.tenantScope }), + sheets: Object.freeze(result.sheets.map((sheet) => Object.freeze({ ...sheet }))), + findings: Object.freeze(result.findings.map((finding) => Object.freeze({ ...finding }))), + blockedReasons: Object.freeze([...result.blockedReasons]), + }); +} + +export class InMemorySpreadsheetAuditRepositoryAdapter + implements SpreadsheetAuditRepositoryPortV1 +{ + private results = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async save(context: IamTenantContextV1, result: SpreadsheetAuditResultV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, result.tenantScope)) + throw new Error('SA_SCOPE_NARROWING_REQUIRED'); + const existing = this.results.get(result.auditId); + if (existing && JSON.stringify(existing) !== JSON.stringify(result)) + throw new Error('SA_IMMUTABLE_AUDIT_RESULT'); + this.results.set(result.auditId, clone(result)); + } + + public async find( + context: IamTenantContextV1, + auditId: SpreadsheetAuditResultV1['auditId'], + ): Promise { + await Promise.resolve(); + const result = this.results.get(auditId); + return result && visible(context.tenantScope, result.tenantScope) ? clone(result) : undefined; + } + + public async list( + context: IamTenantContextV1, + artifactVersionId: SpreadsheetAuditResultV1['artifactVersionId'], + ): Promise { + await Promise.resolve(); + return [...this.results.values()] + .filter( + (result) => + result.artifactVersionId === artifactVersionId && + visible(context.tenantScope, result.tenantScope), + ) + .sort((left, right) => left.auditId.localeCompare(right.auditId)) + .map(clone); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: SpreadsheetAuditTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.results); + try { + return await work({ + save: this.save.bind(this), + find: this.find.bind(this), + list: this.list.bind(this), + }); + } catch (error) { + this.results = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/sa/application/spreadsheet-audit-repository.port.ts b/services/api/src/features/sa/application/spreadsheet-audit-repository.port.ts new file mode 100644 index 00000000..c68febb2 --- /dev/null +++ b/services/api/src/features/sa/application/spreadsheet-audit-repository.port.ts @@ -0,0 +1,24 @@ +import type { SpreadsheetAuditResultV1 } from '@databreeze/domain/spreadsheet-audit/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const SPREADSHEET_AUDIT_REPOSITORY_PORT = Symbol('SPREADSHEET_AUDIT_REPOSITORY_PORT'); + +export interface SpreadsheetAuditTransactionPortV1 { + save(context: IamTenantContextV1, result: SpreadsheetAuditResultV1): Promise; + find( + context: IamTenantContextV1, + auditId: SpreadsheetAuditResultV1['auditId'], + ): Promise; + list( + context: IamTenantContextV1, + artifactVersionId: SpreadsheetAuditResultV1['artifactVersionId'], + ): Promise; +} + +export interface SpreadsheetAuditRepositoryPortV1 extends SpreadsheetAuditTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: SpreadsheetAuditTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/sa/application/spreadsheet-audit.service.ts b/services/api/src/features/sa/application/spreadsheet-audit.service.ts new file mode 100644 index 00000000..8d4f7e7c --- /dev/null +++ b/services/api/src/features/sa/application/spreadsheet-audit.service.ts @@ -0,0 +1,72 @@ +import { + createSpreadsheetAuditResultV1, + type SpreadsheetAuditResultValidationV1, + type SpreadsheetAuditResultV1, +} from '@databreeze/domain/spreadsheet-audit/v1'; +import { + parseStableIdentifierV1, + tenantScopeContainsV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { SpreadsheetAuditRepositoryPortV1 } from './spreadsheet-audit-repository.port.js'; + +export type SpreadsheetAuditServiceErrorV1 = + | 'AUDIT_NOT_FOUND' + | 'AUDIT_SCOPE_NARROWING_REQUIRED' + | 'INVALID_IDENTIFIER'; + +export type SpreadsheetAuditServiceResultV1 = + | SpreadsheetAuditResultValidationV1 + | { readonly accepted: false; readonly code: SpreadsheetAuditServiceErrorV1 }; + +/** Coordinates immutable, value-free spreadsheet audit results. */ +export class SpreadsheetAuditService { + public constructor(private readonly repository: SpreadsheetAuditRepositoryPortV1) {} + + public async register( + context: IamTenantContextV1, + input: Parameters[0], + ): Promise> { + const created = createSpreadsheetAuditResultV1(input); + if (!created.accepted) return created; + if (!tenantScopeContainsV1(context.tenantScope, created.value.tenantScope)) + return Object.freeze({ accepted: false, code: 'AUDIT_SCOPE_NARROWING_REQUIRED' as const }); + return this.repository.withTransaction(context, async (transaction) => { + const existing = await transaction.find(context, created.value.auditId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return Object.freeze({ accepted: true, value: existing }); + throw new Error('SA_IMMUTABLE_AUDIT_RESULT'); + } + await transaction.save(context, created.value); + return created; + }); + } + + public async find( + context: IamTenantContextV1, + auditIdInput: unknown, + ): Promise> { + const auditId = parseStableIdentifierV1(auditIdInput); + if (!auditId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const found = await this.repository.find(context, auditId.value); + return found + ? Object.freeze({ accepted: true, value: found }) + : Object.freeze({ accepted: false, code: 'AUDIT_NOT_FOUND' as const }); + } + + public async list( + context: IamTenantContextV1, + artifactVersionIdInput: unknown, + ): Promise> { + const artifactVersionId = parseStableIdentifierV1(artifactVersionIdInput); + if (!artifactVersionId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + return Object.freeze({ + accepted: true, + value: await this.repository.list(context, artifactVersionId.value), + }); + } +} diff --git a/services/api/test/features/sa/spreadsheet-audit.service.test.ts b/services/api/test/features/sa/spreadsheet-audit.service.test.ts new file mode 100644 index 00000000..1b9aa151 --- /dev/null +++ b/services/api/test/features/sa/spreadsheet-audit.service.test.ts @@ -0,0 +1,90 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { InMemorySpreadsheetAuditRepositoryAdapter } from '../../../src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.js'; +import { SpreadsheetAuditService } from '../../../src/features/sa/application/spreadsheet-audit.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'spreadsheet-audit-service', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const input = { + auditId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + workbookSha256: 'a'.repeat(64), + sheets: [ + { + sheetId: '77777777-7777-4777-8777-777777777777', + name: 'Orders', + maxRow: 10, + maxColumn: 4, + formulaCount: 2, + }, + ], + findings: [ + { + findingId: '88888888-8888-4888-8888-888888888888', + sheetId: '77777777-7777-4777-8777-777777777777', + address: 'C4', + kind: 'FORMULA_FAMILY_OUTLIER' as const, + severity: 'WARNING' as const, + formulaFingerprint: 'b'.repeat(64), + }, + ], + blockedReasons: [], + processorVersion: 'spreadsheet-auditor-0.1.0', + createdAt: '2026-08-04T00:00:00.000Z', +}; + +void test('[SA-001, SA-004] service stores immutable, value-free audit results idempotently', async () => { + const service = new SpreadsheetAuditService(new InMemorySpreadsheetAuditRepositoryAdapter()); + const first = await service.register(context, input); + assert.equal(first.accepted, true); + const second = await service.register(context, input); + assert.deepEqual(second, first); + const listed = await service.list(context, input.artifactVersionId); + assert.equal(listed.accepted, true); + if (listed.accepted) assert.equal(listed.value.length, 1); +}); + +void test('[SA-005] service rejects a result that broadens the authenticated tenant scope', async () => { + const service = new SpreadsheetAuditService(new InMemorySpreadsheetAuditRepositoryAdapter()); + const rejected = await service.register(context, { + ...input, + tenantScope: { + scopeType: 'organization', + organizationId: context.tenantScope.organizationId, + }, + }); + assert.deepEqual(rejected, { accepted: false, code: 'AUDIT_SCOPE_NARROWING_REQUIRED' }); +}); + +void test('[SA-005] service hides results from a different organization', async () => { + const service = new SpreadsheetAuditService(new InMemorySpreadsheetAuditRepositoryAdapter()); + await service.register(context, input); + const otherContextResult = createIamTenantContextV1({ + ...context, + tenantScope: { + scopeType: 'organization', + organizationId: '99999999-9999-4999-8999-999999999999', + }, + idempotencyKey: 'spreadsheet-audit-other-tenant', + }); + if (!otherContextResult.accepted) throw new Error('other context invalid'); + assert.deepEqual(await service.find(otherContextResult.value, input.auditId), { + accepted: false, + code: 'AUDIT_NOT_FOUND', + }); +}); From 3a88d39b34412bab741191a83b28a0d57af0d331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:13:42 +0700 Subject: [PATCH 099/177] feat(sa): expose spreadsheet audit API boundary --- services/api/openapi/v1.json | 275 ++++++++++++++++++ services/api/src/app.module.ts | 5 +- services/api/src/bootstrap.ts | 4 +- .../sa/api/spreadsheet-audit.controller.ts | 55 ++++ .../features/sa/api/spreadsheet-audit.dto.ts | 121 ++++++++ services/api/src/features/sa/sa.module.ts | 39 +++ .../sa/spreadsheet-audit.controller.test.ts | 93 ++++++ services/api/test/openapi.test.ts | 2 + 8 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 services/api/src/features/sa/api/spreadsheet-audit.controller.ts create mode 100644 services/api/src/features/sa/api/spreadsheet-audit.dto.ts create mode 100644 services/api/src/features/sa/sa.module.ts create mode 100644 services/api/test/features/sa/spreadsheet-audit.controller.test.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 7af29559..b9ffee49 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -6874,6 +6874,223 @@ "summary": "Read the append-only usage ledger state in the caller scope", "tags": ["entitlements"] } + }, + "/v1/spreadsheet-audits": { + "post": { + "operationId": "SpreadsheetAuditController.register", + "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/CreateSpreadsheetAuditResultDto" } + } + } + }, + "responses": { + "201": { + "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": "Register an immutable, value-free spreadsheet audit result", + "tags": ["spreadsheet-audits"] + }, + "get": { + "operationId": "SpreadsheetAuditController.list", + "parameters": [ + { + "name": "artifactVersionId", + "required": true, + "in": "query", + "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 spreadsheet audit results for an exact artifact version", + "tags": ["spreadsheet-audits"] + } + }, + "/v1/spreadsheet-audits/{auditId}": { + "get": { + "operationId": "SpreadsheetAuditController.find", + "parameters": [ + { "name": "auditId", "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": "Read an exact immutable spreadsheet audit result", + "tags": ["spreadsheet-audits"] + } } }, "info": { @@ -7939,6 +8156,64 @@ "publishedAt" ] }, + "SpreadsheetAuditSheetDto": { + "type": "object", + "properties": { + "sheetId": { "type": "string", "format": "uuid" }, + "name": { "type": "string", "maxLength": 128 }, + "maxRow": { "type": "number", "minimum": 0, "maximum": 1000000 }, + "maxColumn": { "type": "number", "minimum": 0, "maximum": 16384 }, + "formulaCount": { "type": "number", "minimum": 0, "maximum": 1000000 } + }, + "required": ["sheetId", "name", "maxRow", "maxColumn", "formulaCount"] + }, + "SpreadsheetAuditFindingDto": { + "type": "object", + "properties": { + "findingId": { "type": "string", "format": "uuid" }, + "sheetId": { "type": "string", "format": "uuid" }, + "address": { "type": "string", "pattern": "^[A-Za-z]{1,3}[1-9][0-9]*$" }, + "kind": { "type": "string", "enum": ["FORMULA_FAMILY_OUTLIER", "FORMULA_GAP"] }, + "severity": { "type": "string", "enum": ["INFO", "WARNING", "ERROR"] }, + "formulaFingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "required": ["findingId", "sheetId", "address", "kind", "severity", "formulaFingerprint"] + }, + "CreateSpreadsheetAuditResultDto": { + "type": "object", + "properties": { + "auditId": { "type": "string", "format": "uuid" }, + "artifactVersionId": { "type": "string", "format": "uuid" }, + "workbookSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "sheets": { + "minItems": 1, + "maxItems": 512, + "type": "array", + "items": { "$ref": "#/components/schemas/SpreadsheetAuditSheetDto" } + }, + "findings": { + "maxItems": 10000, + "type": "array", + "items": { "$ref": "#/components/schemas/SpreadsheetAuditFindingDto" } + }, + "blockedReasons": { + "type": "array", + "items": { "type": "string", "enum": ["MACRO", "EXTERNAL_LINK", "UNSUPPORTED_XML"] } + }, + "processorVersion": { "type": "string", "maxLength": 128 }, + "createdAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "auditId", + "artifactVersionId", + "workbookSha256", + "sheets", + "findings", + "blockedReasons", + "processorVersion", + "createdAt" + ] + }, "Identifier": { "title": "Stable UUID Identifier", "description": "An opaque stable UUID identifier.", diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index 7a0ee73b..d3b56a14 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -7,6 +7,7 @@ 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 { SaModule, type SaModuleOptions } from './features/sa/sa.module.js'; import { SessionRequestTenantContextAdapter } from './platform/http/session-tenant-context.adapter.js'; export type AppModuleOptions = SystemModuleOptions & @@ -15,7 +16,8 @@ export type AppModuleOptions = SystemModuleOptions & DsmModuleOptions & DsoModuleOptions & AudModuleOptions & - BuaModuleOptions; + BuaModuleOptions & + SaModuleOptions; @Module({}) export class AppModule { @@ -40,6 +42,7 @@ export class AppModule { DsoModule.register(composedOptions), AudModule.register(composedOptions), BuaModule.register(composedOptions), + SaModule.register(composedOptions), ], }; } diff --git a/services/api/src/bootstrap.ts b/services/api/src/bootstrap.ts index 94eb97d4..66d9dc54 100644 --- a/services/api/src/bootstrap.ts +++ b/services/api/src/bootstrap.ts @@ -9,6 +9,7 @@ import type { IamModuleOptions } from './features/iam/iam.module.js'; import type { IaeModuleOptions } from './features/iae/iae.module.js'; import type { DsmModuleOptions } from './features/dsm/dsm.module.js'; import type { DsoModuleOptions } from './features/dso/dso.module.js'; +import type { SaModuleOptions } from './features/sa/sa.module.js'; import type { ClientCompatibilityPort } from './features/system/application/client-compatibility.port.js'; import type { ReadinessPort } from './features/system/application/readiness.port.js'; import { ProblemDetailsFilter } from './platform/http/problem-details.filter.js'; @@ -28,7 +29,8 @@ export interface ApiApplicationOptions extends IamModuleOptions, IaeModuleOptions, DsmModuleOptions, - DsoModuleOptions { + DsoModuleOptions, + SaModuleOptions { readonly compatibilityPort?: ClientCompatibilityPort; readonly readinessPort?: ReadinessPort; readonly requestContext?: RequestContextOptions; diff --git a/services/api/src/features/sa/api/spreadsheet-audit.controller.ts b/services/api/src/features/sa/api/spreadsheet-audit.controller.ts new file mode 100644 index 00000000..c868445a --- /dev/null +++ b/services/api/src/features/sa/api/spreadsheet-audit.controller.ts @@ -0,0 +1,55 @@ +import { Body, Controller, Get, Inject, Param, Post, Query, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + SPREADSHEET_AUDIT_REPOSITORY_PORT, + type SpreadsheetAuditRepositoryPortV1, +} from '../application/spreadsheet-audit-repository.port.js'; +import { SpreadsheetAuditService } from '../application/spreadsheet-audit.service.js'; +import { CreateSpreadsheetAuditResultDto } from './spreadsheet-audit.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('spreadsheet-audits') +@ApiBearerAuth() +@Controller('v1/spreadsheet-audits') +export class SpreadsheetAuditController { + private readonly audits: SpreadsheetAuditService; + + public constructor( + @Inject(SPREADSHEET_AUDIT_REPOSITORY_PORT) repository: SpreadsheetAuditRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) { + this.audits = new SpreadsheetAuditService(repository); + } + + @Post() + @ApiOperation({ summary: 'Register an immutable, value-free spreadsheet audit result' }) + @ApiBody({ type: CreateSpreadsheetAuditResultDto }) + async register( + @Req() request: unknown, + @Body() input: CreateSpreadsheetAuditResultDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.audits.register(context, { ...input, tenantScope: context.tenantScope }); + } + + @Get(':auditId') + @ApiOperation({ summary: 'Read an exact immutable spreadsheet audit result' }) + async find(@Req() request: unknown, @Param('auditId') auditId: string): Promise { + const context = await this.requestContext.resolve(request); + return this.audits.find(context, auditId); + } + + @Get() + @ApiOperation({ summary: 'List spreadsheet audit results for an exact artifact version' }) + async list( + @Req() request: unknown, + @Query('artifactVersionId') artifactVersionId: string, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.audits.list(context, artifactVersionId); + } +} diff --git a/services/api/src/features/sa/api/spreadsheet-audit.dto.ts b/services/api/src/features/sa/api/spreadsheet-audit.dto.ts new file mode 100644 index 00000000..c562acba --- /dev/null +++ b/services/api/src/features/sa/api/spreadsheet-audit.dto.ts @@ -0,0 +1,121 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsIn, + IsInt, + IsISO8601, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +const sha256Pattern = '^[0-9a-f]{64}$'; + +export class SpreadsheetAuditSheetDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + sheetId!: string; + + @ApiProperty({ maxLength: 128 }) + @IsString() + @MaxLength(128) + name!: string; + + @ApiProperty({ minimum: 0, maximum: 1_000_000 }) + @IsInt() + @Min(0) + @Max(1_000_000) + maxRow!: number; + + @ApiProperty({ minimum: 0, maximum: 16_384 }) + @IsInt() + @Min(0) + @Max(16_384) + maxColumn!: number; + + @ApiProperty({ minimum: 0, maximum: 1_000_000 }) + @IsInt() + @Min(0) + @Max(1_000_000) + formulaCount!: number; +} + +export class SpreadsheetAuditFindingDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + findingId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + sheetId!: string; + + @ApiProperty({ pattern: '^[A-Za-z]{1,3}[1-9][0-9]*$' }) + @IsString() + @Matches(/^[A-Za-z]{1,3}[1-9][0-9]*$/u) + address!: string; + + @ApiProperty({ enum: ['FORMULA_FAMILY_OUTLIER', 'FORMULA_GAP'] }) + @IsIn(['FORMULA_FAMILY_OUTLIER', 'FORMULA_GAP']) + kind!: 'FORMULA_FAMILY_OUTLIER' | 'FORMULA_GAP'; + + @ApiProperty({ enum: ['INFO', 'WARNING', 'ERROR'] }) + @IsIn(['INFO', 'WARNING', 'ERROR']) + severity!: 'INFO' | 'WARNING' | 'ERROR'; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + formulaFingerprint!: string; +} + +export class CreateSpreadsheetAuditResultDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + auditId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + artifactVersionId!: string; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + workbookSha256!: string; + + @ApiProperty({ type: [SpreadsheetAuditSheetDto], minItems: 1, maxItems: 512 }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(512) + @ValidateNested({ each: true }) + @Type(() => SpreadsheetAuditSheetDto) + sheets!: SpreadsheetAuditSheetDto[]; + + @ApiProperty({ type: [SpreadsheetAuditFindingDto], maxItems: 10_000 }) + @IsArray() + @ArrayMaxSize(10_000) + @ValidateNested({ each: true }) + @Type(() => SpreadsheetAuditFindingDto) + findings!: SpreadsheetAuditFindingDto[]; + + @ApiProperty({ enum: ['MACRO', 'EXTERNAL_LINK', 'UNSUPPORTED_XML'], isArray: true }) + @IsArray() + @ArrayMaxSize(3) + @IsIn(['MACRO', 'EXTERNAL_LINK', 'UNSUPPORTED_XML'], { each: true }) + blockedReasons!: Array<'MACRO' | 'EXTERNAL_LINK' | 'UNSUPPORTED_XML'>; + + @ApiProperty({ maxLength: 128 }) + @IsString() + @MaxLength(128) + processorVersion!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + createdAt!: string; +} diff --git a/services/api/src/features/sa/sa.module.ts b/services/api/src/features/sa/sa.module.ts new file mode 100644 index 00000000..b19b2dd1 --- /dev/null +++ b/services/api/src/features/sa/sa.module.ts @@ -0,0 +1,39 @@ +import { type DynamicModule, Module } from '@nestjs/common'; + +import { SpreadsheetAuditController } from './api/spreadsheet-audit.controller.js'; +import { InMemorySpreadsheetAuditRepositoryAdapter } from './adapter/in-memory-spreadsheet-audit-repository.adapter.js'; +import { + SPREADSHEET_AUDIT_REPOSITORY_PORT, + type SpreadsheetAuditRepositoryPortV1, +} from './application/spreadsheet-audit-repository.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, + UnavailableRequestTenantContextAdapter, +} from '../../platform/http/request-tenant-context.port.js'; + +export interface SaModuleOptions { + readonly spreadsheetAuditRepository?: SpreadsheetAuditRepositoryPortV1; + readonly requestTenantContext?: RequestTenantContextPortV1; +} + +@Module({}) +export class SaModule { + public static register(options: SaModuleOptions = {}): DynamicModule { + return { + module: SaModule, + controllers: [SpreadsheetAuditController], + providers: [ + { + provide: SPREADSHEET_AUDIT_REPOSITORY_PORT, + useValue: options.spreadsheetAuditRepository ?? new InMemorySpreadsheetAuditRepositoryAdapter(), + }, + { + provide: REQUEST_TENANT_CONTEXT, + useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), + }, + ], + exports: [SPREADSHEET_AUDIT_REPOSITORY_PORT], + }; + } +} diff --git a/services/api/test/features/sa/spreadsheet-audit.controller.test.ts b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts new file mode 100644 index 00000000..d3740010 --- /dev/null +++ b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts @@ -0,0 +1,93 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemorySpreadsheetAuditRepositoryAdapter } from '../../../src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'spreadsheet-audit-http', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const payload = { + auditId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + workbookSha256: 'a'.repeat(64), + sheets: [ + { + sheetId: '77777777-7777-4777-8777-777777777777', + name: 'Orders', + maxRow: 10, + maxColumn: 4, + formulaCount: 2, + }, + ], + findings: [ + { + findingId: '88888888-8888-4888-8888-888888888888', + sheetId: '77777777-7777-4777-8777-777777777777', + address: 'c4', + kind: 'FORMULA_FAMILY_OUTLIER', + severity: 'WARNING', + formulaFingerprint: 'b'.repeat(64), + }, + ], + blockedReasons: [], + processorVersion: 'spreadsheet-auditor-0.1.0', + createdAt: '2026-08-04T00:00:00.000Z', +}; + +void test('SA-001/SA-004 HTTP stores value-free audit results and rejects source values', async () => { + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(context), + }; + const { app } = await createApiApplication({ + spreadsheetAuditRepository: new InMemorySpreadsheetAuditRepositoryAdapter(), + requestTenantContext, + }); + try { + const rejected = await app.inject({ + method: 'POST', + url: '/v1/spreadsheet-audits', + payload: { ...payload, formula: '=SUM(A1:A3)', sourceValue: '42' }, + }); + assert.equal(rejected.statusCode, 400); + assert.doesNotMatch(rejected.body, /SUM|42|sourceValue/iu); + + const created = await app.inject({ + method: 'POST', + url: '/v1/spreadsheet-audits', + payload, + }); + assert.equal(created.statusCode, 201); + assert.match(created.body, /"address":"C4"/u); + assert.doesNotMatch(created.body, /sourceValue|source value|SUM\(A1:A3\)/iu); + + const found = await app.inject({ + method: 'GET', + url: `/v1/spreadsheet-audits/${payload.auditId}`, + }); + assert.equal(found.statusCode, 200); + assert.match(found.body, /"auditId":"55555555-5555-4555-8555-555555555555"/u); + + const listed = await app.inject({ + method: 'GET', + url: `/v1/spreadsheet-audits?artifactVersionId=${payload.artifactVersionId}`, + }); + assert.equal(listed.statusCode, 200); + assert.match(listed.body, /"accepted":true/u); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 51c9ced5..143a562b 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -142,6 +142,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/reference-entities/{entityId}/resolutions', '/v1/reference-entities/{entityId}/versions', '/v1/reference-entities/{entityId}/versions/{versionId}', + '/v1/spreadsheet-audits', + '/v1/spreadsheet-audits/{auditId}', '/v1/system/compatibility', '/v1/system/compatibility/check', ]); From 534b51871fa379d0cccf5d04543d224514041a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:16:12 +0700 Subject: [PATCH 100/177] feat(sa): persist spreadsheet audit results --- .../migration.sql | 24 +++ services/api/prisma/schema/platform.prisma | 2 +- services/api/prisma/schema/sa.prisma | 20 ++ ...ma-spreadsheet-audit-repository.adapter.ts | 181 ++++++++++++++++++ services/api/src/features/sa/sa.module.ts | 12 +- .../foundation-module-composition.test.ts | 18 ++ ...risma-spreadsheet-audit-repository.test.ts | 86 +++++++++ services/api/test/prisma-foundation.test.mjs | 18 ++ 8 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 services/api/prisma/migrations/20260802300000_sa_spreadsheet_audits/migration.sql create mode 100644 services/api/prisma/schema/sa.prisma create mode 100644 services/api/src/features/sa/adapter/prisma-spreadsheet-audit-repository.adapter.ts create mode 100644 services/api/test/features/sa/prisma-spreadsheet-audit-repository.test.ts diff --git a/services/api/prisma/migrations/20260802300000_sa_spreadsheet_audits/migration.sql b/services/api/prisma/migrations/20260802300000_sa_spreadsheet_audits/migration.sql new file mode 100644 index 00000000..2872f72c --- /dev/null +++ b/services/api/prisma/migrations/20260802300000_sa_spreadsheet_audits/migration.sql @@ -0,0 +1,24 @@ +-- SA-001..SA-006: persist immutable value-free spreadsheet audit metadata. +CREATE SCHEMA IF NOT EXISTS "sa"; + +CREATE TABLE "sa"."spreadsheet_audit_results" ( + "id" UUID NOT NULL, + "artifact_version_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "workbook_sha256" CHAR(64) NOT NULL, + "sheets" JSONB NOT NULL, + "findings" JSONB NOT NULL, + "blocked_reasons" JSONB NOT NULL, + "processor_version" VARCHAR(128) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "spreadsheet_audit_results_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "spreadsheet_audits_artifact_version_idx" + ON "sa"."spreadsheet_audit_results"("artifact_version_id"); +CREATE INDEX "spreadsheet_audits_scope_idx" + ON "sa"."spreadsheet_audit_results"("organization_id", "workspace_id", "project_id", "artifact_version_id"); diff --git a/services/api/prisma/schema/platform.prisma b/services/api/prisma/schema/platform.prisma index 9ead3c4f..c4f3d74f 100644 --- a/services/api/prisma/schema/platform.prisma +++ b/services/api/prisma/schema/platform.prisma @@ -7,7 +7,7 @@ generator client { datasource db { provider = "postgresql" - schemas = ["platform", "system", "iam", "iae", "aud", "bua", "dsm", "jra", "dso"] + schemas = ["platform", "system", "iam", "iae", "aud", "bua", "dsm", "jra", "dso", "sa"] } /// Platform-owned registry documenting database-schema ownership boundaries. diff --git a/services/api/prisma/schema/sa.prisma b/services/api/prisma/schema/sa.prisma new file mode 100644 index 00000000..cb7ba19b --- /dev/null +++ b/services/api/prisma/schema/sa.prisma @@ -0,0 +1,20 @@ +/// SA-001..SA-006: immutable, value-free spreadsheet audit results. +model SpreadsheetAuditResultRecord { + id String @id @db.Uuid + artifactVersionId String @map("artifact_version_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + workbookSha256 String @map("workbook_sha256") @db.Char(64) + sheets Json + findings Json + blockedReasons Json @map("blocked_reasons") + processorVersion String @map("processor_version") @db.VarChar(128) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + + @@index([artifactVersionId], map: "spreadsheet_audits_artifact_version_idx") + @@index([organizationId, workspaceId, projectId, artifactVersionId], map: "spreadsheet_audits_scope_idx") + @@map("spreadsheet_audit_results") + @@schema("sa") +} diff --git a/services/api/src/features/sa/adapter/prisma-spreadsheet-audit-repository.adapter.ts b/services/api/src/features/sa/adapter/prisma-spreadsheet-audit-repository.adapter.ts new file mode 100644 index 00000000..f45fe727 --- /dev/null +++ b/services/api/src/features/sa/adapter/prisma-spreadsheet-audit-repository.adapter.ts @@ -0,0 +1,181 @@ +import { + createSpreadsheetAuditResultV1, + type SpreadsheetAuditResultV1, +} from '@databreeze/domain/spreadsheet-audit/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + SpreadsheetAuditRepositoryPortV1, + SpreadsheetAuditTransactionPortV1, +} from '../application/spreadsheet-audit-repository.port.js'; + +export interface SpreadsheetAuditDatabaseRowV1 { + readonly id: string; + readonly artifactVersionId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly workbookSha256: string; + readonly sheets: unknown; + readonly findings: unknown; + readonly blockedReasons: unknown; + readonly processorVersion: string; + readonly createdAt: Date; +} + +export interface SpreadsheetAuditDatabaseCreateDataV1 + extends Omit { + readonly createdAt: Date; +} + +export interface SpreadsheetAuditDatabaseClientV1 { + readonly spreadsheetAuditResultRecord: { + create(input: { + readonly data: SpreadsheetAuditDatabaseCreateDataV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly id: 'asc' }; + }): Promise; + }; + $transaction( + work: (transaction: SpreadsheetAuditDatabaseClientV1) => 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 rowScope(row: SpreadsheetAuditDatabaseRowV1): 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('SA_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function rowToDomain(row: SpreadsheetAuditDatabaseRowV1): SpreadsheetAuditResultV1 { + const parsed = createSpreadsheetAuditResultV1({ + auditId: row.id, + artifactVersionId: row.artifactVersionId, + tenantScope: rowScope(row), + workbookSha256: row.workbookSha256, + sheets: row.sheets, + findings: row.findings, + blockedReasons: row.blockedReasons, + processorVersion: row.processorVersion, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('SA_PERSISTED_AUDIT_RESULT_INVALID'); + return parsed.value; +} + +function domainToCreate(result: SpreadsheetAuditResultV1): SpreadsheetAuditDatabaseCreateDataV1 { + return { + ...databaseScope(result.tenantScope), + id: result.auditId, + artifactVersionId: result.artifactVersionId, + workbookSha256: result.workbookSha256, + sheets: result.sheets, + findings: result.findings, + blockedReasons: result.blockedReasons, + processorVersion: result.processorVersion, + createdAt: new Date(result.createdAt), + }; +} + +function visible(context: TenantScopeV1, row: SpreadsheetAuditDatabaseRowV1): boolean { + const candidate = rowScope(row); + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaSpreadsheetAuditTransactionAdapter implements SpreadsheetAuditTransactionPortV1 { + public constructor(private readonly client: SpreadsheetAuditDatabaseClientV1) {} + + public async save(context: IamTenantContextV1, result: SpreadsheetAuditResultV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, result.tenantScope)) + throw new Error('SA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.spreadsheetAuditResultRecord.findUnique({ + where: { id: result.auditId }, + }); + if (existing !== null) { + if (JSON.stringify(rowToDomain(existing)) !== JSON.stringify(result)) + throw new Error('SA_IMMUTABLE_AUDIT_RESULT'); + return; + } + await this.client.spreadsheetAuditResultRecord.create({ data: domainToCreate(result) }); + } + + public async find( + context: IamTenantContextV1, + auditId: SpreadsheetAuditResultV1['auditId'], + ): Promise { + const row = await this.client.spreadsheetAuditResultRecord.findUnique({ + where: { id: auditId }, + }); + return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; + } + + public async list( + context: IamTenantContextV1, + artifactVersionId: SpreadsheetAuditResultV1['artifactVersionId'], + ): Promise { + const rows = await this.client.spreadsheetAuditResultRecord.findMany({ + where: { artifactVersionId, organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, row)).map(rowToDomain); + } +} + +export class PrismaSpreadsheetAuditRepositoryAdapter implements SpreadsheetAuditRepositoryPortV1 { + public constructor(private readonly client: SpreadsheetAuditDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: SpreadsheetAuditTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaSpreadsheetAuditTransactionAdapter(transaction)), + ); + } + + public save(context: IamTenantContextV1, result: SpreadsheetAuditResultV1): Promise { + return new PrismaSpreadsheetAuditTransactionAdapter(this.client).save(context, result); + } + + public find( + context: IamTenantContextV1, + auditId: SpreadsheetAuditResultV1['auditId'], + ): Promise { + return new PrismaSpreadsheetAuditTransactionAdapter(this.client).find(context, auditId); + } + + public list( + context: IamTenantContextV1, + artifactVersionId: SpreadsheetAuditResultV1['artifactVersionId'], + ): Promise { + return new PrismaSpreadsheetAuditTransactionAdapter(this.client).list( + context, + artifactVersionId, + ); + } +} diff --git a/services/api/src/features/sa/sa.module.ts b/services/api/src/features/sa/sa.module.ts index b19b2dd1..49c5e389 100644 --- a/services/api/src/features/sa/sa.module.ts +++ b/services/api/src/features/sa/sa.module.ts @@ -2,6 +2,10 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { SpreadsheetAuditController } from './api/spreadsheet-audit.controller.js'; import { InMemorySpreadsheetAuditRepositoryAdapter } from './adapter/in-memory-spreadsheet-audit-repository.adapter.js'; +import { + PrismaSpreadsheetAuditRepositoryAdapter, + type SpreadsheetAuditDatabaseClientV1, +} from './adapter/prisma-spreadsheet-audit-repository.adapter.js'; import { SPREADSHEET_AUDIT_REPOSITORY_PORT, type SpreadsheetAuditRepositoryPortV1, @@ -14,6 +18,8 @@ import { export interface SaModuleOptions { readonly spreadsheetAuditRepository?: SpreadsheetAuditRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly spreadsheetAuditDatabase?: SpreadsheetAuditDatabaseClientV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -26,7 +32,11 @@ export class SaModule { providers: [ { provide: SPREADSHEET_AUDIT_REPOSITORY_PORT, - useValue: options.spreadsheetAuditRepository ?? new InMemorySpreadsheetAuditRepositoryAdapter(), + useValue: + options.spreadsheetAuditRepository ?? + (options.spreadsheetAuditDatabase === undefined + ? new InMemorySpreadsheetAuditRepositoryAdapter() + : new PrismaSpreadsheetAuditRepositoryAdapter(options.spreadsheetAuditDatabase)), }, { provide: REQUEST_TENANT_CONTEXT, diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index d644d1bb..760bfe2a 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -25,6 +25,9 @@ import { ENTITLEMENT_REPOSITORY_PORT } from '../../src/features/bua/application/ 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'; +import { SaModule } from '../../src/features/sa/sa.module.js'; +import { SPREADSHEET_AUDIT_REPOSITORY_PORT } from '../../src/features/sa/application/spreadsheet-audit-repository.port.js'; +import { PrismaSpreadsheetAuditRepositoryAdapter } from '../../src/features/sa/adapter/prisma-spreadsheet-audit-repository.adapter.js'; function moduleTypes(): readonly unknown[] { const registered = AppModule.register(); @@ -39,6 +42,21 @@ void test('[IAM-001, AUD-001, BUA-001] application composition includes identity const types = moduleTypes(); assert.ok(types.includes(AudModule)); assert.ok(types.includes(BuaModule)); + assert.ok(types.includes(SaModule)); +}); + +void test('[SA-001] configured spreadsheet audit persistence uses the Prisma adapter', () => { + const registered = SaModule.register({ spreadsheetAuditDatabase: {} as never }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === SPREADSHEET_AUDIT_REPOSITORY_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaSpreadsheetAuditRepositoryAdapter); }); void test('[AUD-001] configured audit persistence uses the Prisma adapter instead of the local fallback', () => { diff --git a/services/api/test/features/sa/prisma-spreadsheet-audit-repository.test.ts b/services/api/test/features/sa/prisma-spreadsheet-audit-repository.test.ts new file mode 100644 index 00000000..f7b8caa1 --- /dev/null +++ b/services/api/test/features/sa/prisma-spreadsheet-audit-repository.test.ts @@ -0,0 +1,86 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { + PrismaSpreadsheetAuditRepositoryAdapter, + type SpreadsheetAuditDatabaseClientV1, + type SpreadsheetAuditDatabaseRowV1, +} from '../../../src/features/sa/adapter/prisma-spreadsheet-audit-repository.adapter.js'; +import { createSpreadsheetAuditResultV1 } from '@databreeze/domain/spreadsheet-audit/v1'; + +const contextResult = createIamTenantContextV1({ + actorId: '11111111-1111-4111-8111-111111111111', + tenantScope: { + scopeType: 'workspace', + organizationId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + }, + authorizationEpoch: 1, + correlationId: '44444444-4444-4444-8444-444444444444', + idempotencyKey: 'prisma-spreadsheet-audit', +}); +if (!contextResult.accepted) throw new Error('fixture context invalid'); +const context = contextResult.value; + +const result = createSpreadsheetAuditResultV1({ + auditId: '55555555-5555-4555-8555-555555555555', + artifactVersionId: '66666666-6666-4666-8666-666666666666', + tenantScope: context.tenantScope, + workbookSha256: 'a'.repeat(64), + sheets: [ + { + sheetId: '77777777-7777-4777-8777-777777777777', + name: 'Orders', + maxRow: 10, + maxColumn: 4, + formulaCount: 2, + }, + ], + findings: [], + blockedReasons: ['EXTERNAL_LINK'], + processorVersion: 'spreadsheet-auditor-0.1.0', + createdAt: '2026-08-04T00:00:00.000Z', +}); +if (!result.accepted) throw new Error('fixture result invalid'); + +function client(rows: SpreadsheetAuditDatabaseRowV1[]): SpreadsheetAuditDatabaseClientV1 { + return { + spreadsheetAuditResultRecord: { + create({ data }) { + const row = { ...data } as SpreadsheetAuditDatabaseRowV1; + rows.push(row); + return Promise.resolve(row); + }, + findUnique({ where }) { + return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); + }, + findMany({ where }) { + return Promise.resolve( + rows + .filter( + (row) => + row.artifactVersionId === where['artifactVersionId'] && + row.organizationId === where['organizationId'], + ) + .sort((left, right) => left.id.localeCompare(right.id)), + ); + }, + }, + $transaction(work) { + return work(this); + }, + }; +} + +void test('SA-001/SA-004 Prisma adapter persists only value-free audit metadata', async () => { + const rows: SpreadsheetAuditDatabaseRowV1[] = []; + const repository = new PrismaSpreadsheetAuditRepositoryAdapter(client(rows)); + await repository.save(context, result.value); + await repository.save(context, result.value); + assert.deepEqual(await repository.find(context, result.value.auditId), result.value); + assert.equal((await repository.list(context, result.value.artifactVersionId)).length, 1); + assert.equal(rows.length, 1); + assert.equal(Object.hasOwn(rows[0] as object, 'formula'), false); + assert.equal(Object.hasOwn(rows[0] as object, 'sourceValue'), false); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index cfd55f6b..40063985 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -51,6 +51,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE SCHEMA IF NOT EXISTS "dsm"/); assert.match(diff.stdout, /CREATE SCHEMA IF NOT EXISTS "jra"/); assert.match(diff.stdout, /CREATE SCHEMA IF NOT EXISTS "dso"/); + assert.match(diff.stdout, /CREATE SCHEMA IF NOT EXISTS "sa"/); assert.match(diff.stdout, /CREATE TABLE "platform"\."schema_registry"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."users"/); assert.match(diff.stdout, /CREATE TABLE "iae"\."artifact_versions"/); @@ -78,6 +79,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dso"\."device_sync_operations"/); 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 "sa"\."spreadsheet_audit_results"/); 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"/); @@ -118,6 +120,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802270000_dsm_profiles', '20260802280000_iae_protected_document_unlocks', '20260802290000_dsm_export_manifests', + '20260802300000_sa_spreadsheet_audits', 'migration_lock.toml', ]); const migration = await readFile( @@ -475,4 +478,19 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const spreadsheetAuditMigration = await readFile( + path.join(migrationsDirectory, inventory[31], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE SCHEMA IF NOT EXISTS "sa"', + 'CREATE TABLE "sa"."spreadsheet_audit_results"', + 'CREATE INDEX "spreadsheet_audits_artifact_version_idx"', + '"blocked_reasons" JSONB', + ]) { + assert.match( + spreadsheetAuditMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From bdd03af56518fb22d7145da3c8315d3160eea74c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:17:52 +0700 Subject: [PATCH 101/177] feat(sa): bridge engine audits to value-free manifests --- .../databreeze_engine/processors/__init__.py | 16 ++- .../spreadsheet_auditor_manifest.py | 101 ++++++++++++++++++ .../engine/tests/test_spreadsheet_auditor.py | 46 +++++++- 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 services/engine/src/databreeze_engine/processors/spreadsheet_auditor_manifest.py diff --git a/services/engine/src/databreeze_engine/processors/__init__.py b/services/engine/src/databreeze_engine/processors/__init__.py index 2825d268..4c676897 100644 --- a/services/engine/src/databreeze_engine/processors/__init__.py +++ b/services/engine/src/databreeze_engine/processors/__init__.py @@ -1,4 +1,18 @@ """Reviewed built-in processors composed into the closed registry.""" from .spreadsheet_auditor import SpreadsheetAuditError, SpreadsheetAuditResult, audit_workbook +from .spreadsheet_auditor_manifest import ( + SpreadsheetAuditManifest, + SpreadsheetAuditManifestFinding, + SpreadsheetAuditManifestSheet, + build_spreadsheet_audit_manifest, +) -__all__ = ["SpreadsheetAuditError", "SpreadsheetAuditResult", "audit_workbook"] +__all__ = [ + "SpreadsheetAuditError", + "SpreadsheetAuditManifest", + "SpreadsheetAuditManifestFinding", + "SpreadsheetAuditManifestSheet", + "SpreadsheetAuditResult", + "audit_workbook", + "build_spreadsheet_audit_manifest", +] diff --git a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor_manifest.py b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor_manifest.py new file mode 100644 index 00000000..440ce6a1 --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor_manifest.py @@ -0,0 +1,101 @@ +"""Map the safe workbook audit into the value-free API manifest (SA-001..SA-006).""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Annotated, Literal + +from databreeze_contracts.v1 import Identifier, TenantScope, UtcTimestamp +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, StringConstraints + +from .spreadsheet_auditor import SpreadsheetAuditResult + +_ProcessorVersion = Annotated[StrictStr, StringConstraints(min_length=1, max_length=128)] +_Sha256 = Annotated[StrictStr, StringConstraints(pattern=r"^[0-9a-f]{64}$")] + + +class SpreadsheetAuditManifestSheet(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + sheetId: Identifier + name: Annotated[StrictStr, StringConstraints(min_length=1, max_length=128)] + maxRow: StrictInt = Field(ge=0, le=1_000_000) + maxColumn: StrictInt = Field(ge=0, le=16_384) + formulaCount: StrictInt = Field(ge=0, le=1_000_000) + + +class SpreadsheetAuditManifestFinding(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + findingId: Identifier + sheetId: Identifier + address: Annotated[StrictStr, StringConstraints(pattern=r"^[A-Z]{1,3}[1-9][0-9]*$")] + kind: Literal["FORMULA_FAMILY_OUTLIER", "FORMULA_GAP"] + severity: Literal["INFO", "WARNING", "ERROR"] + formulaFingerprint: _Sha256 + + +class SpreadsheetAuditManifest(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + schemaVersion: Literal[1] + auditId: Identifier + artifactVersionId: Identifier + tenantScope: TenantScope + workbookSha256: _Sha256 + sheets: tuple[SpreadsheetAuditManifestSheet, ...] + findings: tuple[SpreadsheetAuditManifestFinding, ...] + blockedReasons: tuple[Literal["MACRO", "EXTERNAL_LINK", "UNSUPPORTED_XML"], ...] + processorVersion: _ProcessorVersion + createdAt: UtcTimestamp + + +def build_spreadsheet_audit_manifest( + result: SpreadsheetAuditResult, + *, + audit_id: str, + artifact_version_id: str, + tenant_scope: TenantScope, + processor_version: str, + created_at: str, + sheet_ids: Mapping[str, str], + finding_ids: Mapping[tuple[str, str], str], +) -> SpreadsheetAuditManifest: + """Attach server-issued identities without ever copying workbook values.""" + if set(sheet_ids) != {sheet.name for sheet in result.sheets}: + raise ValueError("SHEET_ID_MAPPING_INCOMPLETE") + if len(set(sheet_ids.values())) != len(sheet_ids): + raise ValueError("SHEET_ID_MAPPING_DUPLICATE") + sheets = tuple( + SpreadsheetAuditManifestSheet( + sheetId=sheet_ids[sheet.name], + name=sheet.name, + maxRow=sheet.maxRow, + maxColumn=sheet.maxColumn, + formulaCount=sheet.formulaCount, + ) + for sheet in result.sheets + ) + findings = tuple( + SpreadsheetAuditManifestFinding( + findingId=finding_ids[(finding.sheet, finding.address)], + sheetId=sheet_ids[finding.sheet], + address=finding.address.upper(), + kind=finding.kind, + severity="WARNING", + formulaFingerprint=finding.formulaFingerprint, + ) + for finding in result.findings + ) + return SpreadsheetAuditManifest( + schemaVersion=1, + auditId=audit_id, + artifactVersionId=artifact_version_id, + tenantScope=tenant_scope, + workbookSha256=result.workbookSha256, + sheets=sheets, + findings=findings, + blockedReasons=result.blockedReasons, + processorVersion=processor_version, + createdAt=created_at, + ) diff --git a/services/engine/tests/test_spreadsheet_auditor.py b/services/engine/tests/test_spreadsheet_auditor.py index 01112531..409bff91 100644 --- a/services/engine/tests/test_spreadsheet_auditor.py +++ b/services/engine/tests/test_spreadsheet_auditor.py @@ -5,7 +5,11 @@ import pytest -from databreeze_engine.processors.spreadsheet_auditor import SpreadsheetAuditError, audit_workbook +from databreeze_engine.processors import ( + SpreadsheetAuditError, + audit_workbook, + build_spreadsheet_audit_manifest, +) def _workbook(*, macro: bool = False, external_link: bool = False) -> bytes: @@ -51,3 +55,43 @@ def test_audit_rejects_archive_traversal_and_cell_resource_exhaustion() -> None: audit_workbook(output.getvalue()) with pytest.raises(SpreadsheetAuditError, match="RESOURCE_LIMIT"): audit_workbook(_workbook(), max_cells=1) + + +def test_manifest_adds_opaque_identities_without_source_values() -> None: + result = audit_workbook(_workbook()) + manifest = build_spreadsheet_audit_manifest( + result, + audit_id="55555555-5555-4555-8555-555555555555", + artifact_version_id="66666666-6666-4666-8666-666666666666", + tenant_scope={ + "scopeType": "workspace", + "organizationId": "22222222-2222-4222-8222-222222222222", + "workspaceId": "33333333-3333-4333-8333-333333333333", + }, + processor_version="spreadsheet-auditor-0.1.0", + created_at="2026-08-04T00:00:00.000Z", + sheet_ids={"Inventory": "77777777-7777-4777-8777-777777777777"}, + finding_ids={("Inventory", "C1"): "88888888-8888-4888-8888-888888888888"}, + ) + encoded = manifest.model_dump_json() + assert "SUM(B1:D1)" not in encoded + assert "A1" not in encoded + assert manifest.findings[0].sheetId == "77777777-7777-4777-8777-777777777777" + + +def test_manifest_requires_complete_server_identity_mappings() -> None: + with pytest.raises(ValueError, match="SHEET_ID_MAPPING_INCOMPLETE"): + build_spreadsheet_audit_manifest( + audit_workbook(_workbook()), + audit_id="55555555-5555-4555-8555-555555555555", + artifact_version_id="66666666-6666-4666-8666-666666666666", + tenant_scope={ + "scopeType": "workspace", + "organizationId": "22222222-2222-4222-8222-222222222222", + "workspaceId": "33333333-3333-4333-8333-333333333333", + }, + processor_version="spreadsheet-auditor-0.1.0", + created_at="2026-08-04T00:00:00.000Z", + sheet_ids={}, + finding_ids={}, + ) From 45c4ed0c4fb70549d21c13e60468578fc7cd769d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:18:22 +0700 Subject: [PATCH 102/177] docs(sa): record vertical slice evidence and limits --- .../sa-spreadsheet-auditor-slice.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/release-evidence/sa-spreadsheet-auditor-slice.md diff --git a/docs/release-evidence/sa-spreadsheet-auditor-slice.md b/docs/release-evidence/sa-spreadsheet-auditor-slice.md new file mode 100644 index 00000000..bfa61d45 --- /dev/null +++ b/docs/release-evidence/sa-spreadsheet-auditor-slice.md @@ -0,0 +1,41 @@ +# Spreadsheet Auditor vertical-slice evidence + +This record describes the implemented checkpoint on the Spreadsheet Auditor plan. It is +deliberately marked **partial**: it does not release SA-001..SA-027 or replace the full +module gate in `docs/plans/110-spreadsheet-auditor.md`. + +## Included in this checkpoint + +- Safe deterministic XLSX inventory and formula-family anomaly detection in + `services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py`. +- A Python manifest bridge that adds server-issued opaque identities and tenant scope without + copying workbook values in + `services/engine/src/databreeze_engine/processors/spreadsheet_auditor_manifest.py`. +- The canonical value-free TypeScript result contract in + `packages/domain/src/spreadsheet-audit/v1.ts`. +- Tenant-scoped API registration, lookup, and artifact-version listing under + `/v1/spreadsheet-audits`. +- Immutable in-memory and Prisma adapters with the `sa` PostgreSQL schema and migration. +- Unknown-field rejection tests proving formulas, source values, and raw rows cannot enter the + HTTP result boundary. + +## Evidence collected + +- Domain build, public API smoke test, and domain test suite pass. +- API typecheck, API test compilation, targeted API controller/adapter tests, OpenAPI generation, + Prisma validation/generation, and migration inventory checks pass. +- Python sources and tests pass `python -m py_compile`. + +The Python `uv` test command remains blocked by the existing Windows engine environment: the +checked-in `.venv\Scripts\python.exe` exits with `0xc0e90002` before pytest starts. Recreate or +repair that environment in a dedicated follow-up task; do not mark the engine requirement +verified from the compile-only result. + +## Safety and rollback + +- The parser rejects archive traversal, duplicate members, XML entity/DTD payloads, and resource + exhaustion; macros and external links are disclosed as blocked reasons and never executed. +- Result persistence is immutable and tenant scoped. Replaying the same audit ID is idempotent; + conflicting content fails closed. +- The slice is independently reversible through the commits on + `feat/artifacts-datasets-completion`; the next integration step is a reviewed PR to `dev`. From 203cc7a13972a60901df43768c4d9d1dc4c67c8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:23:24 +0700 Subject: [PATCH 103/177] fix(foundation): keep application layers adapter-independent --- packages/domain/src/protected-document/v1.ts | 3 ++- packages/domain/src/spreadsheet-audit/v1.ts | 21 ++++++++++--------- .../application/artifact-upload.service.ts | 3 +-- .../protected-document-unlock.service.ts | 3 +-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/domain/src/protected-document/v1.ts b/packages/domain/src/protected-document/v1.ts index cfec850b..31481aaa 100644 --- a/packages/domain/src/protected-document/v1.ts +++ b/packages/domain/src/protected-document/v1.ts @@ -158,7 +158,8 @@ export function recordProtectedDocumentUnlockResultV1( }, ): ProtectedDocumentUnlockResultV1 { if (request.state !== 'REQUESTED') return rejected('INVALID_STATE'); - if (input.expectedRevision !== request.revision) return rejected('REVISION_CONFLICT'); + const expectedRevision = revision(input.expectedRevision); + if (expectedRevision !== request.revision) return rejected('REVISION_CONFLICT'); const occurredAt = timestamp(input.occurredAt); if (!occurredAt) return rejected('INVALID_TIMESTAMP'); if (Date.parse(occurredAt) >= Date.parse(request.expiresAt)) return rejected('EXPIRED'); diff --git a/packages/domain/src/spreadsheet-audit/v1.ts b/packages/domain/src/spreadsheet-audit/v1.ts index 651a14c5..8e3ca9a2 100644 --- a/packages/domain/src/spreadsheet-audit/v1.ts +++ b/packages/domain/src/spreadsheet-audit/v1.ts @@ -89,6 +89,10 @@ function hash(input: unknown): string | undefined { : undefined; } +function isBlockedReason(input: unknown): input is SpreadsheetAuditBlockedReasonV1 { + return input === 'MACRO' || input === 'EXTERNAL_LINK' || input === 'UNSUPPORTED_XML'; +} + function count(input: unknown): number | undefined { return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0 ? input : undefined; } @@ -182,15 +186,12 @@ export function createSpreadsheetAuditResultV1(input: { return rejected('INVALID_IDENTIFIER'); if (!Array.isArray(input.blockedReasons) || input.blockedReasons.length > 3) return rejected('INVALID_BLOCKED_REASON'); - const blockedReasons = input.blockedReasons; - if ( - blockedReasons.some( - (candidate) => - candidate !== 'MACRO' && candidate !== 'EXTERNAL_LINK' && candidate !== 'UNSUPPORTED_XML', - ) - ) - return rejected('INVALID_BLOCKED_REASON'); - if (new Set(blockedReasons).size !== blockedReasons.length) + const validBlockedReasons: SpreadsheetAuditBlockedReasonV1[] = []; + for (const candidate of input.blockedReasons) { + if (!isBlockedReason(candidate)) return rejected('INVALID_BLOCKED_REASON'); + validBlockedReasons.push(candidate); + } + if (new Set(validBlockedReasons).size !== validBlockedReasons.length) return rejected('INVALID_BLOCKED_REASON'); return Object.freeze({ accepted: true, @@ -202,7 +203,7 @@ export function createSpreadsheetAuditResultV1(input: { workbookSha256, sheets: Object.freeze(validSheets), findings: Object.freeze(validFindings), - blockedReasons: Object.freeze([...blockedReasons] as SpreadsheetAuditBlockedReasonV1[]), + blockedReasons: Object.freeze(validBlockedReasons), processorVersion, createdAt, }), diff --git a/services/api/src/features/iae/application/artifact-upload.service.ts b/services/api/src/features/iae/application/artifact-upload.service.ts index 5c69f0ad..211e2b96 100644 --- a/services/api/src/features/iae/application/artifact-upload.service.ts +++ b/services/api/src/features/iae/application/artifact-upload.service.ts @@ -11,7 +11,6 @@ import { tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { ArtifactUploadRepositoryPortV1 } from './artifact-upload-repository.port.js'; -import { InMemoryArtifactUploadStorageAdapter } from '../adapter/in-memory-artifact-upload-storage.adapter.js'; import type { ArtifactUploadPartTransferV1, ArtifactUploadStoragePortV1, @@ -28,7 +27,7 @@ export type ArtifactUploadServiceResultV1 = export class ArtifactUploadService { public constructor( private readonly repository: ArtifactUploadRepositoryPortV1, - private readonly storage: ArtifactUploadStoragePortV1 = new InMemoryArtifactUploadStorageAdapter(), + private readonly storage: ArtifactUploadStoragePortV1, ) {} public async create( diff --git a/services/api/src/features/iae/application/protected-document-unlock.service.ts b/services/api/src/features/iae/application/protected-document-unlock.service.ts index 6c0c9454..cc654785 100644 --- a/services/api/src/features/iae/application/protected-document-unlock.service.ts +++ b/services/api/src/features/iae/application/protected-document-unlock.service.ts @@ -13,7 +13,6 @@ import type { ProtectedDocumentSecretInputResultV1, ProtectedDocumentUnlockHandleV1, } from './protected-document-secret-input.port.js'; -import { InMemoryProtectedDocumentSecretInputAdapter } from '../adapter/in-memory-protected-document-secret-input.adapter.js'; import type { ProtectedDocumentUnlockRepositoryPortV1 } from './protected-document-unlock-repository.port.js'; export type ProtectedDocumentUnlockServiceErrorV1 = @@ -28,7 +27,7 @@ export type ProtectedDocumentUnlockServiceResultV1 = export class ProtectedDocumentUnlockService { public constructor( private readonly requests: ProtectedDocumentUnlockRepositoryPortV1, - private readonly secretInput: ProtectedDocumentSecretInputPortV1 = new InMemoryProtectedDocumentSecretInputAdapter(), + private readonly secretInput: ProtectedDocumentSecretInputPortV1, ) {} public async create( From a110846094ce9837a87b2df6bbebd7bf17e09053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:27:35 +0700 Subject: [PATCH 104/177] fix(sa): preserve formula geometry and formatting gates --- services/api/src/features/iae/iae.module.ts | 4 +++- .../in-memory-spreadsheet-audit-repository.adapter.ts | 9 ++------- .../sa/application/spreadsheet-audit.service.ts | 5 +---- .../processors/spreadsheet_auditor.py | 11 ++++++++++- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/services/api/src/features/iae/iae.module.ts b/services/api/src/features/iae/iae.module.ts index 458fc870..300ca017 100644 --- a/services/api/src/features/iae/iae.module.ts +++ b/services/api/src/features/iae/iae.module.ts @@ -204,7 +204,9 @@ export class IaeModule { options.protectedDocumentUnlockRepository ?? (options.protectedDocumentUnlockDatabase === undefined ? new InMemoryProtectedDocumentUnlockRepositoryAdapter() - : new PrismaProtectedDocumentUnlockRepositoryAdapter(options.protectedDocumentUnlockDatabase)), + : new PrismaProtectedDocumentUnlockRepositoryAdapter( + options.protectedDocumentUnlockDatabase, + )), }, { provide: PROTECTED_DOCUMENT_SECRET_INPUT_PORT, diff --git a/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts b/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts index 9fb2bb66..43ff005f 100644 --- a/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts +++ b/services/api/src/features/sa/adapter/in-memory-spreadsheet-audit-repository.adapter.ts @@ -1,7 +1,4 @@ -import { - tenantScopeContainsV1, - type TenantScopeV1, -} from '@databreeze/domain/tenant-scope/v1'; +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; import type { SpreadsheetAuditResultV1 } from '@databreeze/domain/spreadsheet-audit/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; @@ -24,9 +21,7 @@ function clone(result: SpreadsheetAuditResultV1): SpreadsheetAuditResultV1 { }); } -export class InMemorySpreadsheetAuditRepositoryAdapter - implements SpreadsheetAuditRepositoryPortV1 -{ +export class InMemorySpreadsheetAuditRepositoryAdapter implements SpreadsheetAuditRepositoryPortV1 { private results = new Map(); private transactionTail: Promise = Promise.resolve(); diff --git a/services/api/src/features/sa/application/spreadsheet-audit.service.ts b/services/api/src/features/sa/application/spreadsheet-audit.service.ts index 8d4f7e7c..36c5377f 100644 --- a/services/api/src/features/sa/application/spreadsheet-audit.service.ts +++ b/services/api/src/features/sa/application/spreadsheet-audit.service.ts @@ -3,10 +3,7 @@ import { type SpreadsheetAuditResultValidationV1, type SpreadsheetAuditResultV1, } from '@databreeze/domain/spreadsheet-audit/v1'; -import { - parseStableIdentifierV1, - tenantScopeContainsV1, -} from '@databreeze/domain/tenant-scope/v1'; +import { parseStableIdentifierV1, tenantScopeContainsV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { SpreadsheetAuditRepositoryPortV1 } from './spreadsheet-audit-repository.port.js'; diff --git a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py index 9c1add53..fb864479 100644 --- a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py +++ b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py @@ -94,8 +94,17 @@ def _cell_address(reference: str) -> tuple[int, int] | None: def _normalized_formula(value: str) -> str: + """Normalize row movement while retaining formula range geometry.""" normalized = _FORMULA_SPACE.sub(" ", value.strip().upper()) - return _FORMULA_REFERENCE.sub("#CELL", normalized) + + def reference(match: re.Match[str]) -> str: + token = match.group(0).replace("$", "") + column = re.match(r"[A-Z]{1,3}", token) + if column is None: + return "#CELL" + return f"{column.group(0)}#ROW" + + return _FORMULA_REFERENCE.sub(reference, normalized) def _fingerprint(value: str) -> str: From b85d8433dc529b5537992dfdef5df26d6a48a5d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:32:22 +0700 Subject: [PATCH 105/177] fix(engine): satisfy deterministic processor lint gates --- .../processors/dataset_quality.py | 4 +++- .../engine/tests/test_spreadsheet_auditor.py | 18 +++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/services/engine/src/databreeze_engine/processors/dataset_quality.py b/services/engine/src/databreeze_engine/processors/dataset_quality.py index 42708e21..264fa8f1 100644 --- a/services/engine/src/databreeze_engine/processors/dataset_quality.py +++ b/services/engine/src/databreeze_engine/processors/dataset_quality.py @@ -18,7 +18,9 @@ class QualityFinding(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, strict=True) - ruleId: StrictStr = Field(pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + ruleId: StrictStr = Field( + pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + ) severity: QualitySeverity messageCode: StrictStr = Field(pattern=r"^[A-Z][A-Z0-9_.-]{0,95}$") occurrenceCount: StrictInt = Field(ge=0) diff --git a/services/engine/tests/test_spreadsheet_auditor.py b/services/engine/tests/test_spreadsheet_auditor.py index 409bff91..7e1c175f 100644 --- a/services/engine/tests/test_spreadsheet_auditor.py +++ b/services/engine/tests/test_spreadsheet_auditor.py @@ -13,9 +13,21 @@ def _workbook(*, macro: bool = False, external_link: bool = False) -> bytes: - workbook = b'''''' - relationships = b'''''' - sheet = b'''SUM(B1:C1)3SUM(B1:C1)3SUM(B1:D1)4''' + workbook = ( + b'' + b'' + ) + relationships = ( + b'' + b'' + ) + sheet = ( + b'' + b'SUM(B1:C1)3' + b'SUM(B1:C1)3' + b'SUM(B1:D1)4' + ) output = io.BytesIO() with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: archive.writestr("xl/workbook.xml", workbook) From 545977873b05f343e39b2b35121af6bedbdfb1cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:33:19 +0700 Subject: [PATCH 106/177] fix(engine): match Ruff processor formatting --- .../engine/src/databreeze_engine/processors/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/services/engine/src/databreeze_engine/processors/__init__.py b/services/engine/src/databreeze_engine/processors/__init__.py index 4c676897..4e26bbac 100644 --- a/services/engine/src/databreeze_engine/processors/__init__.py +++ b/services/engine/src/databreeze_engine/processors/__init__.py @@ -1,5 +1,10 @@ """Reviewed built-in processors composed into the closed registry.""" -from .spreadsheet_auditor import SpreadsheetAuditError, SpreadsheetAuditResult, audit_workbook + +from .spreadsheet_auditor import ( + SpreadsheetAuditError, + SpreadsheetAuditResult, + audit_workbook, +) from .spreadsheet_auditor_manifest import ( SpreadsheetAuditManifest, SpreadsheetAuditManifestFinding, From e1071452d65165ff4c7a991368f593c2fa58e3fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:49:12 +0700 Subject: [PATCH 107/177] fix(iae): fail closed for unavailable evidence --- .../iae/application/artifact.service.ts | 16 +++--- .../iae/artifact-read.controller.test.ts | 50 +++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/services/api/src/features/iae/application/artifact.service.ts b/services/api/src/features/iae/application/artifact.service.ts index ed95a5a6..c5d5f6cc 100644 --- a/services/api/src/features/iae/application/artifact.service.ts +++ b/services/api/src/features/iae/application/artifact.service.ts @@ -94,6 +94,8 @@ export class ArtifactService { if (!evidence) return undefined; const version = await transaction.findVersion(context, versionId); if (!version) return undefined; + if (version.status === 'DELETED' || evidence.sourceState !== 'AVAILABLE') + return Object.freeze({ evidence, version, action: 'UNAVAILABLE' as const }); const placements = await transaction.listPlacements(context, version.versionId); const cloud = placements.find( (placement) => placement.kind === 'CLOUD' && placement.available, @@ -107,12 +109,14 @@ export class ArtifactService { }); const local = placements.find((placement) => placement.kind === 'LOCAL'); if (local) - return Object.freeze({ - evidence, - version, - action: local.available ? ('OPEN_ON_SOURCE_DEVICE' as const) : ('UNAVAILABLE' as const), - placementReference: local.opaqueReference, - }); + return local.available + ? Object.freeze({ + evidence, + version, + action: 'OPEN_ON_SOURCE_DEVICE' as const, + placementReference: local.opaqueReference, + }) + : Object.freeze({ evidence, version, action: 'UNAVAILABLE' as const }); return Object.freeze({ evidence, version, action: 'UNAVAILABLE' as const }); }); } diff --git a/services/api/test/features/iae/artifact-read.controller.test.ts b/services/api/test/features/iae/artifact-read.controller.test.ts index 5850915b..cc8fde1a 100644 --- a/services/api/test/features/iae/artifact-read.controller.test.ts +++ b/services/api/test/features/iae/artifact-read.controller.test.ts @@ -144,3 +144,53 @@ void test('[IAE-008, IAM-009] artifact reads do not enumerate a sibling workspac const result = await service.find(sibling.value, parsedVersionId.value); assert.equal(result.version, undefined); }); + +void test('[IAE-006, IAE-019] unavailable or deleted evidence never resolves to an open handle', async () => { + const repository = new InMemoryArtifactRepositoryAdapter(); + const tenantContext = context(); + const service = new ArtifactService(repository); + const registered = await service.register(tenantContext, { + version: { + artifactId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceKind: 'FILE', + dataMode: 'Cloud', + contentSha256: 'c'.repeat(64), + byteSize: 10, + mediaType: 'text/csv', + displayName: 'unavailable.csv', + createdAt: '2026-01-01T00:00:00.000Z', + }, + placement: { + placementId, + tenantScope: tenantContext.tenantScope, + kind: 'CLOUD', + opaqueReference: 'cloud-placement-000001', + contentSha256: 'c'.repeat(64), + }, + evidence: { + evidenceId, + tenantScope: tenantContext.tenantScope, + coordinate: { kind: 'ROW', row: 1, field: 'amount' }, + sourceState: 'SOURCE_OFFLINE', + }, + }); + assert.equal(registered.accepted, true); + if (!registered.accepted) return; + const registeredEvidence = registered.value.evidence; + if (!registeredEvidence) return; + + const resolved = await service.resolveEvidence( + tenantContext, + registered.value.version.versionId, + registeredEvidence.evidenceId, + ); + assert.deepEqual( + resolved && { action: resolved.action, placementReference: resolved.placementReference }, + { + action: 'UNAVAILABLE', + placementReference: undefined, + }, + ); +}); From e52bd2a306e1db80669bfe1ffff9fab7500bab5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:52:15 +0700 Subject: [PATCH 108/177] feat(iae): expose safe evidence resolution --- services/api/openapi/v1.json | 70 +++++++++++++++++++ .../iae/api/artifact-read.controller.ts | 17 +++++ .../iae/artifact-read.controller.test.ts | 35 ++++++++++ services/api/test/openapi.test.ts | 1 + 4 files changed, 123 insertions(+) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index b9ffee49..f30fb7d4 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -1897,6 +1897,76 @@ "tags": ["artifacts"] } }, + "/v1/artifact-versions/{versionId}/evidence/{evidenceId}/resolve": { + "get": { + "operationId": "ArtifactReadController.resolveEvidence", + "parameters": [ + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "evidenceId", "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": "Resolve one exact evidence reference to a safe opaque action", + "tags": ["artifacts"] + } + }, "/v1/artifact-versions/{versionId}/lineage": { "get": { "operationId": "ArtifactLineageController.forDerived", diff --git a/services/api/src/features/iae/api/artifact-read.controller.ts b/services/api/src/features/iae/api/artifact-read.controller.ts index d2df83b2..41696279 100644 --- a/services/api/src/features/iae/api/artifact-read.controller.ts +++ b/services/api/src/features/iae/api/artifact-read.controller.ts @@ -50,4 +50,21 @@ export class ArtifactReadController { if (!result.version) return { accepted: false, code: 'NOT_FOUND' as const }; return Object.freeze({ accepted: true, value: result.evidence }); } + + @Get(':versionId/evidence/:evidenceId/resolve') + @ApiOperation({ summary: 'Resolve one exact evidence reference to a safe opaque action' }) + async resolveEvidence( + @Req() request: unknown, + @Param('versionId') versionIdInput: string, + @Param('evidenceId') evidenceIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + const evidenceId = parseStableIdentifierV1(evidenceIdInput); + if (!versionId.accepted || !evidenceId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const result = await this.artifacts.resolveEvidence(context, versionId.value, evidenceId.value); + if (!result) return { accepted: false, code: 'NOT_FOUND' as const }; + return Object.freeze({ accepted: true, value: result }); + } } diff --git a/services/api/test/features/iae/artifact-read.controller.test.ts b/services/api/test/features/iae/artifact-read.controller.test.ts index cc8fde1a..05ba4286 100644 --- a/services/api/test/features/iae/artifact-read.controller.test.ts +++ b/services/api/test/features/iae/artifact-read.controller.test.ts @@ -95,6 +95,41 @@ void test('[IAE-006, IAE-008, IAE-019, IAE-020] artifact reads return exact cont row: 1, field: 'amount', }); + + const resolutionResponse = await app.inject({ + method: 'GET', + url: `/v1/artifact-versions/${versionId}/evidence/${evidenceId}/resolve`, + }); + assert.equal(resolutionResponse.statusCode, 200); + assert.deepEqual(JSON.parse(resolutionResponse.body), { + accepted: true, + value: { + evidence: { + schemaVersion: 1, + evidenceId, + artifactVersionId: versionId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + coordinate: { kind: 'ROW', row: 1, field: 'amount' }, + sourceState: 'AVAILABLE', + }, + version: { + schemaVersion: 1, + artifactId, + versionId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + sourceKind: 'FILE', + dataMode: 'Local', + contentSha256: 'a'.repeat(64), + byteSize: 10, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: '2026-01-01T00:00:00.000Z', + status: 'ACTIVE', + }, + action: 'OPEN_ON_SOURCE_DEVICE', + placementReference: 'local-placement-000001', + }, + }); } finally { await app.close(); } diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 143a562b..488a7fe1 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -75,6 +75,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifact-versions/{versionId}/deletion-requests', '/v1/artifact-versions/{versionId}/derived-lineage', '/v1/artifact-versions/{versionId}/evidence', + '/v1/artifact-versions/{versionId}/evidence/{evidenceId}/resolve', '/v1/artifact-versions/{versionId}/lineage', '/v1/artifact-versions/{versionId}/placements/{placementId}', '/v1/artifacts/evidence-grants/{grantId}', From 7927cf29ebf52b6ac3f1fe01f1b79ed0b49d9d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:54:47 +0700 Subject: [PATCH 109/177] fix(iae): make evidence persistence idempotent --- .../prisma-artifact-repository.adapter.ts | 11 +++++++++++ .../iae/prisma-artifact-repository.test.ts | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts index b0ff651e..bced1d09 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts @@ -104,6 +104,9 @@ export interface ArtifactDatabaseClientV1 { }; readonly evidenceReference: { create(input: { readonly data: EvidenceCreateDataV1 }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; findMany(input: { readonly where: Readonly>; }): Promise; @@ -349,6 +352,14 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { if (versionRow === null) throw new Error('IAE_VERSION_NOT_FOUND'); if (!tenantScopeContainsV1(context.tenantScope, evidence.tenantScope)) throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.evidenceReference.findUnique({ + where: { id: evidence.evidenceId }, + }); + if (existing !== null) { + const persisted = rowToEvidence(existing, rowToVersion(versionRow)); + if (JSON.stringify(persisted) === JSON.stringify(evidence)) return; + throw new Error('IAE_IMMUTABLE_EVIDENCE'); + } await this.client.evidenceReference.create({ data: { ...databaseScope(evidence.tenantScope), diff --git a/services/api/test/features/iae/prisma-artifact-repository.test.ts b/services/api/test/features/iae/prisma-artifact-repository.test.ts index 0f4ec5f6..d503684f 100644 --- a/services/api/test/features/iae/prisma-artifact-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-repository.test.ts @@ -103,6 +103,11 @@ function client( evidence.push(persisted); return Promise.resolve(persisted); }, + findUnique(input) { + return Promise.resolve( + evidence.find((candidate) => candidate.id === input.where.id) ?? null, + ); + }, findMany(input) { return Promise.resolve( evidence.filter( @@ -165,6 +170,19 @@ void test('[IAE-003, IAE-004, IAE-005, IAM-009] Prisma artifact adapter keeps pl await repository.savePlacement(context('placement'), placement.value); await repository.savePlacement(context('placement-repeat'), placement.value); await repository.saveEvidence(context('evidence'), evidenceRef.value); + await repository.saveEvidence(context('evidence-repeat'), evidenceRef.value); + const conflictingEvidence = createEvidenceReferenceV1({ + evidenceId, + artifactVersion: artifact.value, + tenantScope: artifact.value.tenantScope, + coordinate: { kind: 'ROW', row: 2 }, + }); + assert.equal(conflictingEvidence.accepted, true); + if (!conflictingEvidence.accepted) return; + await assert.rejects( + repository.saveEvidence(context('evidence-conflict'), conflictingEvidence.value), + /IAE_IMMUTABLE_EVIDENCE/, + ); assert.equal((await repository.listPlacements(context('list-placement'), versionId)).length, 1); assert.equal((await repository.listEvidence(context('list-evidence'), versionId)).length, 1); }); From 081147b4dbc04ba68ee6ffbd0d965626adfcca57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:56:01 +0700 Subject: [PATCH 110/177] fix(sa): validate finding geometry --- packages/domain/src/spreadsheet-audit/v1.ts | 20 ++++++++++++++++--- .../domain/test/spreadsheet-audit-v1.test.mjs | 17 ++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/domain/src/spreadsheet-audit/v1.ts b/packages/domain/src/spreadsheet-audit/v1.ts index 8e3ca9a2..7f170e1b 100644 --- a/packages/domain/src/spreadsheet-audit/v1.ts +++ b/packages/domain/src/spreadsheet-audit/v1.ts @@ -97,6 +97,12 @@ function count(input: unknown): number | undefined { return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0 ? input : undefined; } +function columnNumber(value: string): number { + let result = 0; + for (const character of value) result = result * 26 + character.charCodeAt(0) - 64; + return result; +} + function sheet(input: unknown): SpreadsheetAuditSheetV1 | undefined { if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; const record = input as Record; @@ -181,9 +187,17 @@ export function createSpreadsheetAuditResultV1(input: { const validFindings = findings as SpreadsheetAuditFindingV1[]; if (new Set(validFindings.map((candidate) => candidate.findingId)).size !== validFindings.length) return rejected('DUPLICATE_IDENTIFIER'); - const sheetIds = new Set(validSheets.map((candidate) => candidate.sheetId)); - if (validFindings.some((candidate) => !sheetIds.has(candidate.sheetId))) - return rejected('INVALID_IDENTIFIER'); + const sheetsById = new Map(validSheets.map((candidate) => [candidate.sheetId, candidate])); + for (const candidate of validFindings) { + const targetSheet = sheetsById.get(candidate.sheetId); + if (!targetSheet) return rejected('INVALID_IDENTIFIER'); + const address = /^([A-Z]{1,3})([1-9][0-9]*)$/u.exec(candidate.address); + if (!address) return rejected('INVALID_COORDINATE'); + const column = columnNumber(address[1] ?? ''); + const row = Number(address[2]); + if (column > targetSheet.maxColumn || row > targetSheet.maxRow) + return rejected('INVALID_COORDINATE'); + } if (!Array.isArray(input.blockedReasons) || input.blockedReasons.length > 3) return rejected('INVALID_BLOCKED_REASON'); const validBlockedReasons: SpreadsheetAuditBlockedReasonV1[] = []; diff --git a/packages/domain/test/spreadsheet-audit-v1.test.mjs b/packages/domain/test/spreadsheet-audit-v1.test.mjs index 4ee400d5..44ed6045 100644 --- a/packages/domain/test/spreadsheet-audit-v1.test.mjs +++ b/packages/domain/test/spreadsheet-audit-v1.test.mjs @@ -54,3 +54,20 @@ void test('[SA-005] findings cannot reference an unknown sheet or duplicate IDs' { accepted: false, code: 'DUPLICATE_IDENTIFIER' }, ); }); + +void test('[SA-006] findings must stay inside the exact sheet geometry', () => { + assert.deepEqual( + createSpreadsheetAuditResultV1({ + ...base, + findings: [{ ...base.findings[0], address: 'E1' }], + }), + { accepted: false, code: 'INVALID_COORDINATE' }, + ); + assert.deepEqual( + createSpreadsheetAuditResultV1({ + ...base, + findings: [{ ...base.findings[0], address: 'A11' }], + }), + { accepted: false, code: 'INVALID_COORDINATE' }, + ); +}); From 2563aabca99cbe82dac1f02d379c4ad3b48d95ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 03:59:44 +0700 Subject: [PATCH 111/177] feat(iae): persist artifact scan state --- packages/domain/src/artifact-intake/v1.ts | 4 +++- packages/domain/src/artifact/v1.ts | 11 +++++++++++ packages/domain/test/artifact-v1.test.mjs | 12 ++++++++++++ .../adapter/in-memory-artifact-repository.adapter.ts | 4 +++- .../adapter/prisma-artifact-repository.adapter.ts | 9 +++++++-- .../iae/application/artifact-admission.service.ts | 1 + .../iae/application/artifact-repository.port.ts | 2 ++ .../features/iae/artifact-admission.service.test.ts | 1 + .../features/iae/artifact-read.controller.test.ts | 1 + 9 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/domain/src/artifact-intake/v1.ts b/packages/domain/src/artifact-intake/v1.ts index ef57f1e9..0a89dc3d 100644 --- a/packages/domain/src/artifact-intake/v1.ts +++ b/packages/domain/src/artifact-intake/v1.ts @@ -8,6 +8,9 @@ import { type TenantScopeV1, } from '../tenant-scope/v1.js'; import type { ArtifactVersionV1 } from '../artifact/v1.js'; +import type { ArtifactScanStateV1 } from '../artifact/v1.js'; + +export type { ArtifactScanStateV1 } from '../artifact/v1.js'; /** IAE-001, IAE-009, IAE-010, IAE-013: intake admission is explicit and idempotent. */ export const ARTIFACT_INTAKE_SCHEMA_VERSION_V1 = 1 as const; @@ -20,7 +23,6 @@ export type InboxItemStateV1 = | 'RESOLVED' | 'QUARANTINED' | 'ARCHIVED'; -export type ArtifactScanStateV1 = 'PENDING' | 'CLEAN' | 'MALICIOUS' | 'FAILED'; export type InboxPriorityV1 = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'; export interface InboxItemV1 { diff --git a/packages/domain/src/artifact/v1.ts b/packages/domain/src/artifact/v1.ts index df93850b..b6f5735a 100644 --- a/packages/domain/src/artifact/v1.ts +++ b/packages/domain/src/artifact/v1.ts @@ -14,6 +14,7 @@ export const ARTIFACT_SCHEMA_VERSION_V1 = 1 as const; export type ArtifactDataModeV1 = 'Local' | 'Hybrid' | 'Cloud'; export type ArtifactSourceKindV1 = 'FILE' | 'FOLDER' | 'CAPTURE' | 'GENERATED'; export type ArtifactVersionStatusV1 = 'QUARANTINED' | 'ACTIVE' | 'DELETED'; +export type ArtifactScanStateV1 = 'PENDING' | 'CLEAN' | 'MALICIOUS' | 'FAILED'; export type ArtifactPlacementKindV1 = 'LOCAL' | 'CLOUD'; export type EvidenceSourceStateV1 = 'AVAILABLE' | 'SOURCE_OFFLINE' | 'DELETED'; @@ -42,6 +43,7 @@ export interface ArtifactVersionV1 { readonly displayName: string; readonly createdAt: StrictUtcTimestampV1; readonly status: ArtifactVersionStatusV1; + readonly scanState: ArtifactScanStateV1; } export interface ContentPlacementV1 { @@ -83,6 +85,7 @@ export type ArtifactErrorCodeV1 = | 'INVALID_MEDIA_TYPE' | 'INVALID_NAME' | 'INVALID_STATUS' + | 'INVALID_SCAN_STATE' | 'INVALID_REVISION' | 'REVISION_CONFLICT' | 'INVALID_REFERENCE' @@ -172,6 +175,10 @@ function isStatus(input: unknown): input is ArtifactVersionStatusV1 { return input === 'QUARANTINED' || input === 'ACTIVE' || input === 'DELETED'; } +function isScanState(input: unknown): input is ArtifactScanStateV1 { + return input === 'PENDING' || input === 'CLEAN' || input === 'MALICIOUS' || input === 'FAILED'; +} + export function createArtifactVersionV1(input: { readonly artifactId: unknown; readonly versionId: unknown; @@ -184,6 +191,7 @@ export function createArtifactVersionV1(input: { readonly displayName: unknown; readonly createdAt: unknown; readonly status?: unknown; + readonly scanState?: unknown; }): ArtifactResultV1 { const artifactId = stableId(input.artifactId); const versionId = stableId(input.versionId); @@ -193,6 +201,7 @@ export function createArtifactVersionV1(input: { const displayNameValue = displayName(input.displayName); const createdAt = timestamp(input.createdAt); const status = input.status ?? 'ACTIVE'; + const scanState = input.scanState ?? 'PENDING'; if (!artifactId || !versionId) return rejected('INVALID_IDENTIFIER'); if (!tenantScope) return rejected('INVALID_SCOPE'); if (!isSourceKind(input.sourceKind)) return rejected('INVALID_KIND'); @@ -208,6 +217,7 @@ export function createArtifactVersionV1(input: { if (!displayNameValue) return rejected('INVALID_NAME'); if (!createdAt) return rejected('INVALID_TIMESTAMP'); if (!isStatus(status)) return rejected('INVALID_STATUS'); + if (!isScanState(scanState)) return rejected('INVALID_SCAN_STATE'); return accepted( Object.freeze({ schemaVersion: ARTIFACT_SCHEMA_VERSION_V1, @@ -222,6 +232,7 @@ export function createArtifactVersionV1(input: { displayName: displayNameValue, createdAt, status, + scanState, }), ); } diff --git a/packages/domain/test/artifact-v1.test.mjs b/packages/domain/test/artifact-v1.test.mjs index b182283a..811ddc54 100644 --- a/packages/domain/test/artifact-v1.test.mjs +++ b/packages/domain/test/artifact-v1.test.mjs @@ -31,9 +31,21 @@ void test('[IAE-001, IAE-003] artifact versions normalize and freeze immutable m assert.equal(result.accepted, true); if (!result.accepted) return; assert.equal(result.value.contentSha256, 'a'.repeat(64)); + assert.equal(result.value.scanState, 'PENDING'); assert.equal(Object.isFrozen(result.value), true); }); +void test('[IAE-009, IAE-010] artifact scan state is bounded and immutable metadata includes it', () => { + const result = createArtifactVersionV1({ ...base, scanState: 'MALICIOUS' }); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.scanState, 'MALICIOUS'); + assert.deepEqual(createArtifactVersionV1({ ...base, scanState: 'UNKNOWN' }), { + accepted: false, + code: 'INVALID_SCAN_STATE', + }); +}); + void test('[IAE-002, DSO-003] Local artifacts accept only opaque local placements', () => { const artifact = createArtifactVersionV1(base); assert.equal(artifact.accepted, true); diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts index 1315c91a..84f41586 100644 --- a/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts @@ -1,5 +1,6 @@ import { tenantScopeContainsV1, + type ArtifactScanStateV1, type ArtifactVersionV1, type ContentPlacementV1, type EvidenceReferenceV1, @@ -68,6 +69,7 @@ export class InMemoryArtifactRepositoryAdapter implements ArtifactRepositoryPort context: IamTenantContextV1, versionId: ArtifactVersionV1['versionId'], status: ArtifactVersionV1['status'], + scanState?: ArtifactScanStateV1, ): Promise { await Promise.resolve(); const current = this.versions.get(versionId); @@ -78,7 +80,7 @@ export class InMemoryArtifactRepositoryAdapter implements ArtifactRepositoryPort throw new Error('IAE_INVALID_STATUS'); if (current.status === 'DELETED' && status !== 'DELETED') throw new Error('IAE_TERMINAL_STATUS'); - const next = cloneVersion({ ...current, status }); + const next = cloneVersion({ ...current, status, scanState: scanState ?? current.scanState }); this.versions.set(versionId, next); return next; } diff --git a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts index bced1d09..05e5bbf0 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts @@ -2,6 +2,7 @@ import { createArtifactVersionV1, createContentPlacementV1, createEvidenceReferenceV1, + type ArtifactScanStateV1, type ArtifactVersionV1, type ContentPlacementV1, type EvidenceReferenceV1, @@ -33,6 +34,7 @@ export interface ArtifactVersionDatabaseRowV1 { readonly displayName: string; readonly createdAt: Date; readonly status: string; + readonly scanState?: string; } export interface ContentPlacementDatabaseRowV1 { @@ -84,7 +86,7 @@ export interface ArtifactDatabaseClientV1 { }): Promise; update(input: { readonly where: { readonly id: string }; - readonly data: { readonly status: string }; + readonly data: { readonly status: string; readonly scanState: ArtifactScanStateV1 }; }): Promise; }; readonly contentPlacement: { @@ -152,6 +154,7 @@ function rowToVersion(row: ArtifactVersionDatabaseRowV1): ArtifactVersionV1 { displayName: row.displayName, createdAt: row.createdAt.toISOString(), status: row.status, + scanState: row.scanState ?? 'PENDING', }); if (!parsed.accepted) throw new Error('IAE_PERSISTED_ARTIFACT_INVALID'); return parsed.value; @@ -226,6 +229,7 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { displayName: version.displayName, createdAt: new Date(version.createdAt), status: version.status, + scanState: version.scanState, }, }); } @@ -246,6 +250,7 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { context: IamTenantContextV1, versionId: ArtifactVersionV1['versionId'], status: ArtifactVersionV1['status'], + scanState?: ArtifactScanStateV1, ): Promise { const row = await this.client.artifactVersion.findUnique({ where: { id: versionId } }); if (row === null || !visible(context.tenantScope, row)) return undefined; @@ -258,7 +263,7 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { throw new Error('IAE_TERMINAL_STATUS'); const updated = await this.client.artifactVersion.update({ where: { id: versionId }, - data: { status }, + data: { status, scanState: scanState ?? current.scanState }, }); return rowToVersion(updated); } diff --git a/services/api/src/features/iae/application/artifact-admission.service.ts b/services/api/src/features/iae/application/artifact-admission.service.ts index 34219e8a..b36451d8 100644 --- a/services/api/src/features/iae/application/artifact-admission.service.ts +++ b/services/api/src/features/iae/application/artifact-admission.service.ts @@ -37,6 +37,7 @@ export class ArtifactAdmissionService { context, versionId, admission.value.status, + admission.value.scanState, ); if (!updated) return Object.freeze({ accepted: false, code: 'ADMISSION_UPDATE_FAILED' as const }); diff --git a/services/api/src/features/iae/application/artifact-repository.port.ts b/services/api/src/features/iae/application/artifact-repository.port.ts index 4a2e768c..ae649069 100644 --- a/services/api/src/features/iae/application/artifact-repository.port.ts +++ b/services/api/src/features/iae/application/artifact-repository.port.ts @@ -1,4 +1,5 @@ import type { + ArtifactScanStateV1, ArtifactVersionV1, ContentPlacementV1, EvidenceReferenceV1, @@ -18,6 +19,7 @@ export interface ArtifactTransactionPortV1 { context: IamTenantContextV1, versionId: ArtifactVersionV1['versionId'], status: ArtifactVersionV1['status'], + scanState?: ArtifactScanStateV1, ): Promise; savePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise; updatePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise; diff --git a/services/api/test/features/iae/artifact-admission.service.test.ts b/services/api/test/features/iae/artifact-admission.service.test.ts index 70d96704..5c494f34 100644 --- a/services/api/test/features/iae/artifact-admission.service.test.ts +++ b/services/api/test/features/iae/artifact-admission.service.test.ts @@ -49,6 +49,7 @@ void test('IAE-009/010 admission updates only the status projection after scanne assert.equal(admitted.accepted, true); if (!admitted.accepted) return; assert.equal(admitted.value.version.status, 'ACTIVE'); + assert.equal(admitted.value.version.scanState, 'CLEAN'); const rejected = await service.admit(context, artifact.value.versionId, { actualSha256: 'b'.repeat(64), actualByteSize: 4, diff --git a/services/api/test/features/iae/artifact-read.controller.test.ts b/services/api/test/features/iae/artifact-read.controller.test.ts index 05ba4286..41c9bed0 100644 --- a/services/api/test/features/iae/artifact-read.controller.test.ts +++ b/services/api/test/features/iae/artifact-read.controller.test.ts @@ -125,6 +125,7 @@ void test('[IAE-006, IAE-008, IAE-019, IAE-020] artifact reads return exact cont displayName: 'orders.csv', createdAt: '2026-01-01T00:00:00.000Z', status: 'ACTIVE', + scanState: 'PENDING', }, action: 'OPEN_ON_SOURCE_DEVICE', placementReference: 'local-placement-000001', From d4cda4d4edde47b82ceb146b98ab9f9c147e6635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:01:51 +0700 Subject: [PATCH 112/177] feat(iae): expose retention request reads --- services/api/openapi/v1.json | 69 +++++++++++++++++++ .../iae/api/artifact-retention.controller.ts | 9 ++- .../application/artifact-retention.service.ts | 13 ++++ .../iae/artifact-retention.service.test.ts | 6 +- services/api/test/openapi.test.ts | 1 + 5 files changed, 96 insertions(+), 2 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index f30fb7d4..87e58e83 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -2183,6 +2183,75 @@ "tags": ["artifacts"] } }, + "/v1/artifact-deletion-requests/{requestId}": { + "get": { + "operationId": "ArtifactRetentionController.find", + "parameters": [ + { "name": "requestId", "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": "Read one governed artifact deletion request", + "tags": ["artifacts"] + } + }, "/v1/artifact-versions/{versionId}/deletion-requests": { "post": { "operationId": "ArtifactRetentionController.request", diff --git a/services/api/src/features/iae/api/artifact-retention.controller.ts b/services/api/src/features/iae/api/artifact-retention.controller.ts index 806e8e52..4b031c49 100644 --- a/services/api/src/features/iae/api/artifact-retention.controller.ts +++ b/services/api/src/features/iae/api/artifact-retention.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Inject, Param, Post, Req } from '@nestjs/common'; +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { @@ -33,6 +33,13 @@ export class ArtifactRetentionController { this.retention = new ArtifactRetentionService(requests, artifacts); } + @Get('artifact-deletion-requests/:requestId') + @ApiOperation({ summary: 'Read one governed artifact deletion request' }) + async find(@Req() request: unknown, @Param('requestId') requestId: string): Promise { + const context = await this.requestContext.resolve(request); + return this.retention.find(context, requestId); + } + @Post('artifact-versions/:versionId/deletion-requests') @ApiOperation({ summary: 'Request governed deletion of an exact artifact version' }) @ApiBody({ type: CreateArtifactDeletionRequestDto }) diff --git a/services/api/src/features/iae/application/artifact-retention.service.ts b/services/api/src/features/iae/application/artifact-retention.service.ts index 1958e7b8..eba3231f 100644 --- a/services/api/src/features/iae/application/artifact-retention.service.ts +++ b/services/api/src/features/iae/application/artifact-retention.service.ts @@ -62,6 +62,19 @@ export class ArtifactRetentionService { }); } + public async find( + context: IamTenantContextV1, + requestIdInput: unknown, + ): Promise> { + const requestId = parseStableIdentifierV1(requestIdInput); + if (!requestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const request = await this.requests.find(context, requestId.value); + return request + ? Object.freeze({ accepted: true, value: request }) + : Object.freeze({ accepted: false, code: 'REQUEST_NOT_FOUND' as const }); + } + public async authorize( context: IamTenantContextV1, input: { diff --git a/services/api/test/features/iae/artifact-retention.service.test.ts b/services/api/test/features/iae/artifact-retention.service.test.ts index a5ad0348..059c3aaf 100644 --- a/services/api/test/features/iae/artifact-retention.service.test.ts +++ b/services/api/test/features/iae/artifact-retention.service.test.ts @@ -93,5 +93,9 @@ void test('[IAE-016, IAE-021] retention service preserves blocked requests and a expectedRevision: request.value.revision, }); assert.equal(authorized.accepted, true); - if (authorized.accepted) assert.equal(authorized.value.state, 'AUTHORIZED'); + if (authorized.accepted) { + assert.equal(authorized.value.state, 'AUTHORIZED'); + const found = await service.find(tenantContext, authorized.value.requestId); + assert.deepEqual(found, authorized); + } }); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 488a7fe1..b2040d55 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -63,6 +63,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, assert.deepEqual(paths, [ '/health/live', '/health/ready', + '/v1/artifact-deletion-requests/{requestId}', '/v1/artifact-deletion-requests/{requestId}/authorize', '/v1/artifact-upload-sessions', '/v1/artifact-upload-sessions/{sessionId}', From 70a4596d94d835bd0939006cdb18dc74ad7443eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:03:19 +0700 Subject: [PATCH 113/177] fix(dsm): enforce deterministic quality state --- packages/domain/src/dataset-quality/v1.ts | 5 +++++ packages/domain/test/dataset-quality-v1.test.mjs | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/packages/domain/src/dataset-quality/v1.ts b/packages/domain/src/dataset-quality/v1.ts index 60d76374..f3434d47 100644 --- a/packages/domain/src/dataset-quality/v1.ts +++ b/packages/domain/src/dataset-quality/v1.ts @@ -275,6 +275,11 @@ export function createDatasetQualityResultV1(input: { const typedFindings = findings as DatasetQualityFindingV1[]; if (new Set(typedFindings.map((candidate) => candidate.findingId)).size !== typedFindings.length) return rejected('DUPLICATE_FINDING'); + if ( + input.qualityState !== 'INCOMPLETE' && + qualityStateFromFindingsV1(typedFindings) !== input.qualityState + ) + return rejected('INVALID_QUALITY_STATE'); return accepted( Object.freeze({ schemaVersion: DATASET_QUALITY_SCHEMA_VERSION_V1, diff --git a/packages/domain/test/dataset-quality-v1.test.mjs b/packages/domain/test/dataset-quality-v1.test.mjs index 2d7a621e..e378911d 100644 --- a/packages/domain/test/dataset-quality-v1.test.mjs +++ b/packages/domain/test/dataset-quality-v1.test.mjs @@ -68,6 +68,10 @@ void test('[DSM-020] quality state is deterministic from finding severity and co 'BLOCKED', ); assert.equal(qualityStateFromFindingsV1([], true), 'INCOMPLETE'); + assert.deepEqual(result({ qualityState: 'PASS' }), { + accepted: false, + code: 'INVALID_QUALITY_STATE', + }); }); void test('[DSM-013] quality result validation rejects malformed hashes, counts, and duplicate findings', () => { From 931277505cca15ce65b688fe8bd94fb85d149c4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:06:40 +0700 Subject: [PATCH 114/177] fix(iae): reject invalid lineage references --- packages/domain/src/artifact-governance/v1.ts | 8 +++++ .../test/artifact-governance-v1.test.mjs | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/domain/src/artifact-governance/v1.ts b/packages/domain/src/artifact-governance/v1.ts index a23d8957..ca06568b 100644 --- a/packages/domain/src/artifact-governance/v1.ts +++ b/packages/domain/src/artifact-governance/v1.ts @@ -119,8 +119,12 @@ export function createArtifactLineageV1(input: { return rejected('INVALID_IDENTIFIER'); if (new Set(sourceArtifactVersionIds).size !== sourceArtifactVersionIds.length) return rejected('DUPLICATE_IDENTIFIER'); + if (sourceArtifactVersionIds.includes(derivedArtifactVersionId)) + return rejected('DUPLICATE_IDENTIFIER'); if (input.sourceTenantScopes !== undefined) { if (!Array.isArray(input.sourceTenantScopes)) return rejected('INVALID_SCOPE'); + if (input.sourceTenantScopes.length !== sourceArtifactVersionIds.length) + return rejected('INVALID_SCOPE'); for (const candidate of input.sourceTenantScopes) { const sourceScope = scope(candidate); if (!sourceScope || !tenantScopesEqualV1(sourceScope, tenantScope)) @@ -129,6 +133,7 @@ export function createArtifactLineageV1(input: { } if (!Array.isArray(input.coordinateLineage)) return rejected('INVALID_LINEAGE'); const coordinateLineage: CoordinateLineageV1[] = []; + const coordinatePairs = new Set(); for (const candidate of input.coordinateLineage) { if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return rejected('INVALID_LINEAGE'); @@ -139,6 +144,9 @@ export function createArtifactLineageV1(input: { if (!sourceEvidenceId || !derivedEvidenceId) return rejected('INVALID_IDENTIFIER'); if (!['COPIED', 'NORMALIZED', 'AGGREGATED', 'REDACTED'].includes(transform as string)) return rejected('INVALID_TRANSFORM'); + const pair = `${sourceEvidenceId}:${derivedEvidenceId}`; + if (coordinatePairs.has(pair)) return rejected('DUPLICATE_IDENTIFIER'); + coordinatePairs.add(pair); coordinateLineage.push( Object.freeze({ sourceEvidenceId, diff --git a/packages/domain/test/artifact-governance-v1.test.mjs b/packages/domain/test/artifact-governance-v1.test.mjs index bbe404c8..8f35696f 100644 --- a/packages/domain/test/artifact-governance-v1.test.mjs +++ b/packages/domain/test/artifact-governance-v1.test.mjs @@ -34,6 +34,41 @@ void test('[IAE-003, IAE-007, IAE-012] lineage pins source versions and typed tr if (result.accepted) assert.equal(result.value.coordinateLineage[0]?.transform, 'NORMALIZED'); }); +void test('[IAE-007] lineage rejects self-references, scope cardinality drift, and duplicate coordinate mappings', () => { + const base = { + lineageId: '00000000-0000-4000-8000-000000000015', + derivedArtifactVersionId: '00000000-0000-4000-8000-000000000016', + tenantScope: scope, + sourceArtifactVersionIds: ['00000000-0000-4000-8000-000000000017'], + processorVersion: 'normalizer@1', + coordinateLineage: [ + { + sourceEvidenceId: '00000000-0000-4000-8000-000000000018', + derivedEvidenceId: '00000000-0000-4000-8000-000000000019', + transform: 'COPIED', + }, + ], + }; + assert.deepEqual( + createArtifactLineageV1({ + ...base, + sourceArtifactVersionIds: [base.derivedArtifactVersionId], + }), + { accepted: false, code: 'DUPLICATE_IDENTIFIER' }, + ); + assert.deepEqual( + createArtifactLineageV1({ ...base, sourceTenantScopes: [] }), + { accepted: false, code: 'INVALID_SCOPE' }, + ); + assert.deepEqual( + createArtifactLineageV1({ + ...base, + coordinateLineage: [...base.coordinateLineage, ...base.coordinateLineage], + }), + { accepted: false, code: 'DUPLICATE_IDENTIFIER' }, + ); +}); + void test('[IAE-008] derived data mode cannot be wider than its least-permissive source', () => { const source = createArtifactVersionV1({ artifactId: '00000000-0000-4000-8000-000000000020', From 6431c9a72f523af69ad2cc10d7d0ea67e1a73731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:10:27 +0700 Subject: [PATCH 115/177] feat(iae): enforce one lineage per derived version --- .../20260803000000_iae_lineage_uniqueness/migration.sql | 3 +++ services/api/prisma/schema/iae.prisma | 2 +- services/api/test/prisma-foundation.test.mjs | 9 +++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 services/api/prisma/migrations/20260803000000_iae_lineage_uniqueness/migration.sql diff --git a/services/api/prisma/migrations/20260803000000_iae_lineage_uniqueness/migration.sql b/services/api/prisma/migrations/20260803000000_iae_lineage_uniqueness/migration.sql new file mode 100644 index 00000000..d8e50779 --- /dev/null +++ b/services/api/prisma/migrations/20260803000000_iae_lineage_uniqueness/migration.sql @@ -0,0 +1,3 @@ +-- IAE-007: one immutable lineage record is authoritative for each derived version. +CREATE UNIQUE INDEX "artifact_lineage_derived_version_key" + ON "iae"."artifact_lineage"("derived_artifact_version_id"); diff --git a/services/api/prisma/schema/iae.prisma b/services/api/prisma/schema/iae.prisma index dd254b72..1752f715 100644 --- a/services/api/prisma/schema/iae.prisma +++ b/services/api/prisma/schema/iae.prisma @@ -62,7 +62,7 @@ model ArtifactLineageRecord { coordinateLineage Json @map("coordinate_lineage") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - @@index([derivedArtifactVersionId], map: "artifact_lineage_derived_version_idx") + @@unique([derivedArtifactVersionId], map: "artifact_lineage_derived_version_key") @@index([organizationId, workspaceId, projectId], map: "artifact_lineage_scope_idx") @@map("artifact_lineage") @@schema("iae") diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 40063985..6d13ca9f 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -121,6 +121,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802280000_iae_protected_document_unlocks', '20260802290000_dsm_export_manifests', '20260802300000_sa_spreadsheet_audits', + '20260803000000_iae_lineage_uniqueness', 'migration_lock.toml', ]); const migration = await readFile( @@ -493,4 +494,12 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const lineageUniquenessMigration = await readFile( + path.join(migrationsDirectory, inventory[32], 'migration.sql'), + 'utf8', + ); + assert.match( + lineageUniquenessMigration, + /CREATE UNIQUE INDEX "artifact_lineage_derived_version_key"/, + ); }); From 556dd7326504bcb06f9b9f824165dc6fee07a300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:10:30 +0700 Subject: [PATCH 116/177] test(sa): ignore correlation id in content assertion --- .../api/test/features/sa/spreadsheet-audit.controller.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/api/test/features/sa/spreadsheet-audit.controller.test.ts b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts index d3740010..0c1f6995 100644 --- a/services/api/test/features/sa/spreadsheet-audit.controller.test.ts +++ b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts @@ -63,7 +63,9 @@ void test('SA-001/SA-004 HTTP stores value-free audit results and rejects source payload: { ...payload, formula: '=SUM(A1:A3)', sourceValue: '42' }, }); assert.equal(rejected.statusCode, 400); - assert.doesNotMatch(rejected.body, /SUM|42|sourceValue/iu); + const rejectedBody = JSON.parse(rejected.body) as Record; + const { correlationId: _correlationId, ...rejectedWithoutCorrelation } = rejectedBody; + assert.doesNotMatch(JSON.stringify(rejectedWithoutCorrelation), /SUM|42|sourceValue/iu); const created = await app.inject({ method: 'POST', From 968168edf45bf97b7f481c0fdc914d2424221256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:13:09 +0700 Subject: [PATCH 117/177] feat(sa): detect deterministic formula gaps --- .../processors/spreadsheet_auditor.py | 53 +++++++++++++++++-- .../engine/tests/test_spreadsheet_auditor.py | 25 +++++++-- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py index fb864479..0371a36c 100644 --- a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py +++ b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py @@ -1,4 +1,4 @@ -"""Safe deterministic workbook inventory and formula-family auditing (SA-001..SA-004).""" +"""Safe deterministic workbook inventory and formula-family auditing (SA-001..SA-007).""" from __future__ import annotations @@ -86,6 +86,14 @@ def _column_number(column: str) -> int: return value +def _column_name(column: int) -> str: + value = "" + while column > 0: + column, remainder = divmod(column - 1, 26) + value = chr(65 + remainder) + value + return value + + def _cell_address(reference: str) -> tuple[int, int] | None: match = _CELL_REFERENCE.fullmatch(reference) if match is None: @@ -199,7 +207,7 @@ def audit_workbook( root = _xml(archive.read(target)) max_row = 0 max_column = 0 - formulas: list[tuple[str, str]] = [] + cells: list[tuple[str, str | None]] = [] for address, formula in _iter_cells(root): total_cells += 1 if total_cells > max_cells: @@ -211,8 +219,8 @@ def audit_workbook( column, row = coordinates max_column = max(max_column, column) max_row = max(max_row, row) - if formula is not None: - formulas.append((address.upper(), formula)) + cells.append((address.upper(), formula)) + formulas = [(address, formula) for address, formula in cells if formula is not None] families = Counter(_normalized_formula(formula) for _, formula in formulas) for address, formula in formulas: family = _normalized_formula(formula) @@ -225,6 +233,43 @@ def audit_workbook( formulaFingerprint=_fingerprint(family), ) ) + cells_by_column: dict[int, dict[int, str | None]] = {} + for address, formula in cells: + coordinates = _cell_address(address) + if coordinates is None: + continue + column, row = coordinates + cells_by_column.setdefault(column, {})[row] = formula + gap_keys: set[tuple[str, str]] = set() + for column, rows in cells_by_column.items(): + formula_rows = sorted(row for row, formula in rows.items() if formula is not None) + for previous_row, next_row in zip(formula_rows, formula_rows[1:]): + if next_row - previous_row <= 1: + continue + previous_formula = rows[previous_row] + next_formula = rows[next_row] + if previous_formula is None or next_formula is None: + continue + previous_family = _normalized_formula(previous_formula) + if previous_family != _normalized_formula(next_formula): + continue + populated_rows = sorted( + row for row in rows if previous_row < row < next_row + ) + for row in populated_rows: + address = f"{_column_name(column)}{row}" + key = (address, previous_family) + if key in gap_keys: + continue + gap_keys.add(key) + findings.append( + SpreadsheetFinding( + sheet=sheet_name, + address=address, + kind="FORMULA_GAP", + formulaFingerprint=_fingerprint(previous_family), + ) + ) summaries.append( SpreadsheetSheetSummary( name=sheet_name, diff --git a/services/engine/tests/test_spreadsheet_auditor.py b/services/engine/tests/test_spreadsheet_auditor.py index 7e1c175f..fe1adce4 100644 --- a/services/engine/tests/test_spreadsheet_auditor.py +++ b/services/engine/tests/test_spreadsheet_auditor.py @@ -12,7 +12,9 @@ ) -def _workbook(*, macro: bool = False, external_link: bool = False) -> bytes: +def _workbook( + *, macro: bool = False, external_link: bool = False, formula_gap: bool = False +) -> bytes: workbook = ( b'' @@ -22,11 +24,18 @@ def _workbook(*, macro: bool = False, external_link: bool = False) -> bytes: b'' b'' ) + sheet_rows = ( + b'SUM(B1:C1)3' + b'9' + b'SUM(B3:C3)3' + if formula_gap + else b'SUM(B1:C1)3' + b'SUM(B1:C1)3' + b'SUM(B1:D1)4' + ) sheet = ( b'' - b'SUM(B1:C1)3' - b'SUM(B1:C1)3' - b'SUM(B1:D1)4' + b'' + sheet_rows + b'' ) output = io.BytesIO() with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: @@ -50,6 +59,14 @@ def test_audit_is_value_free_and_reports_formula_family_outlier() -> None: assert result.blockedReasons == () +def test_audit_reports_a_formula_gap_without_returning_the_intervening_value() -> None: + result = audit_workbook(_workbook(formula_gap=True)) + assert [(finding.address, finding.kind) for finding in result.findings] == [ + ("A2", "FORMULA_GAP"), + ] + assert all("value" not in finding.model_dump() for finding in result.findings) + + @pytest.mark.parametrize("flag", ["macro", "external_link"]) def test_audit_discloses_blocked_execution_features_without_running_them(flag: str) -> None: result = audit_workbook(_workbook(**{flag: True})) From d87cdac37deed4be24593b697777bc3956de32c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:14:07 +0700 Subject: [PATCH 118/177] fix(iae): validate evidence geometry before lookup --- packages/domain/src/artifact/v1.ts | 36 +++++++++++++++++++++++ packages/domain/test/artifact-v1.test.mjs | 14 +++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/domain/src/artifact/v1.ts b/packages/domain/src/artifact/v1.ts index b6f5735a..df85eaba 100644 --- a/packages/domain/src/artifact/v1.ts +++ b/packages/domain/src/artifact/v1.ts @@ -341,12 +341,48 @@ function spreadsheetColumnNumber(value: string): number { return result; } +function nonNegativeCount(input: unknown): input is number { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0; +} + +function isEvidenceGeometry(input: unknown): input is EvidenceGeometryV1 { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return false; + const record = input as Record; + if (record['kind'] === 'SPREADSHEET') { + if (!Array.isArray(record['sheets']) || record['sheets'].length > 512) return false; + const names = new Set(); + return record['sheets'].every((candidate) => { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) + return false; + const sheet = candidate as Record; + const name = boundedText(sheet['name'], 255); + if ( + !name || + names.has(name) || + !nonNegativeCount(sheet['maxRow']) || + !nonNegativeCount(sheet['maxColumn']) || + sheet['maxRow'] > 1_000_000 || + sheet['maxColumn'] > 16_384 + ) + return false; + names.add(name); + return true; + }); + } + if (record['kind'] === 'PAGED') + return nonNegativeCount(record['maxPage']) && record['maxPage'] <= 10_000_000; + if (record['kind'] === 'TABULAR') + return nonNegativeCount(record['maxRow']) && record['maxRow'] <= 1_000_000_000; + return false; +} + /** IAE-006: evidence coordinates are checked against the exact source geometry. */ export function validateEvidenceCoordinateV1( coordinate: EvidenceCoordinateV1, geometry?: EvidenceGeometryV1, ): ArtifactResultV1 { if (!geometry) return accepted(true); + if (!isEvidenceGeometry(geometry)) return rejected('INVALID_COORDINATE'); if (coordinate.kind === 'CELL') { if (geometry.kind !== 'SPREADSHEET') return rejected('COORDINATE_OUT_OF_BOUNDS'); const sheet = geometry.sheets.find((candidate) => candidate.name === coordinate.sheet); diff --git a/packages/domain/test/artifact-v1.test.mjs b/packages/domain/test/artifact-v1.test.mjs index 811ddc54..0ad377a9 100644 --- a/packages/domain/test/artifact-v1.test.mjs +++ b/packages/domain/test/artifact-v1.test.mjs @@ -125,4 +125,18 @@ void test('[IAE-006] evidence coordinates are validated against exact source geo validateEvidenceCoordinateV1({ kind: 'PAGE', page: 4 }, { kind: 'PAGED', maxPage: 3 }), { accepted: false, code: 'COORDINATE_OUT_OF_BOUNDS' }, ); + assert.deepEqual( + validateEvidenceCoordinateV1( + { kind: 'CELL', sheet: 'Sheet1', address: 'B4' }, + { kind: 'SPREADSHEET', sheets: null }, + ), + { accepted: false, code: 'INVALID_COORDINATE' }, + ); + assert.deepEqual( + validateEvidenceCoordinateV1( + { kind: 'PAGE', page: 1 }, + { kind: 'PAGED', maxPage: '3' }, + ), + { accepted: false, code: 'INVALID_COORDINATE' }, + ); }); From 27789f7406df475dbe1a5214a5e514982bfccf63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:15:14 +0700 Subject: [PATCH 119/177] fix(sa): preserve absolute formula references --- .../processors/spreadsheet_auditor.py | 16 ++++++-- .../engine/tests/test_spreadsheet_auditor.py | 40 ++++++++++++++----- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py index 0371a36c..5887f246 100644 --- a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py +++ b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py @@ -106,11 +106,19 @@ def _normalized_formula(value: str) -> str: normalized = _FORMULA_SPACE.sub(" ", value.strip().upper()) def reference(match: re.Match[str]) -> str: - token = match.group(0).replace("$", "") - column = re.match(r"[A-Z]{1,3}", token) - if column is None: + token = re.fullmatch( + r"(?P\$?)(?P[A-Z]{1,3})" + r"(?P\$?)(?P[1-9][0-9]*)", + match.group(0), + re.IGNORECASE, + ) + if token is None: return "#CELL" - return f"{column.group(0)}#ROW" + column = token.group("column").upper() + row = token.group("row") + column_prefix = "$" if token.group("column_absolute") else "" + row_value = f"${row}" if token.group("row_absolute") else "#ROW" + return f"{column_prefix}{column}{row_value}" return _FORMULA_REFERENCE.sub(reference, normalized) diff --git a/services/engine/tests/test_spreadsheet_auditor.py b/services/engine/tests/test_spreadsheet_auditor.py index fe1adce4..ec562873 100644 --- a/services/engine/tests/test_spreadsheet_auditor.py +++ b/services/engine/tests/test_spreadsheet_auditor.py @@ -13,7 +13,11 @@ def _workbook( - *, macro: bool = False, external_link: bool = False, formula_gap: bool = False + *, + macro: bool = False, + external_link: bool = False, + formula_gap: bool = False, + absolute_reference: bool = False, ) -> bytes: workbook = ( b'' b'' ) - sheet_rows = ( - b'SUM(B1:C1)3' - b'9' - b'SUM(B3:C3)3' - if formula_gap - else b'SUM(B1:C1)3' - b'SUM(B1:C1)3' - b'SUM(B1:D1)4' - ) + if absolute_reference: + sheet_rows = ( + b'SUM($B$1:C1)3' + b'SUM(B2:C2)3' + b'SUM(B3:C3)3' + ) + elif formula_gap: + sheet_rows = ( + b'SUM(B1:C1)3' + b'9' + b'SUM(B3:C3)3' + ) + else: + sheet_rows = ( + b'SUM(B1:C1)3' + b'SUM(B1:C1)3' + b'SUM(B1:D1)4' + ) sheet = ( b'' b'' + sheet_rows + b'' @@ -67,6 +80,13 @@ def test_audit_reports_a_formula_gap_without_returning_the_intervening_value() - assert all("value" not in finding.model_dump() for finding in result.findings) +def test_formula_family_normalization_preserves_absolute_references() -> None: + result = audit_workbook(_workbook(absolute_reference=True)) + assert [(finding.address, finding.kind) for finding in result.findings] == [ + ("A1", "FORMULA_FAMILY_OUTLIER"), + ] + + @pytest.mark.parametrize("flag", ["macro", "external_link"]) def test_audit_discloses_blocked_execution_features_without_running_them(flag: str) -> None: result = audit_workbook(_workbook(**{flag: True})) From 4d3f40d75551e0a0c3b2d59024259b16441a19b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:17:02 +0700 Subject: [PATCH 120/177] fix(api): expose audit and entitlement composition options --- services/api/src/bootstrap.ts | 4 ++++ .../features/foundation-module-composition.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/services/api/src/bootstrap.ts b/services/api/src/bootstrap.ts index 66d9dc54..c3fed050 100644 --- a/services/api/src/bootstrap.ts +++ b/services/api/src/bootstrap.ts @@ -9,6 +9,8 @@ import type { IamModuleOptions } from './features/iam/iam.module.js'; import type { IaeModuleOptions } from './features/iae/iae.module.js'; import type { DsmModuleOptions } from './features/dsm/dsm.module.js'; import type { DsoModuleOptions } from './features/dso/dso.module.js'; +import type { AudModuleOptions } from './features/aud/aud.module.js'; +import type { BuaModuleOptions } from './features/bua/bua.module.js'; import type { SaModuleOptions } from './features/sa/sa.module.js'; import type { ClientCompatibilityPort } from './features/system/application/client-compatibility.port.js'; import type { ReadinessPort } from './features/system/application/readiness.port.js'; @@ -30,6 +32,8 @@ export interface ApiApplicationOptions IaeModuleOptions, DsmModuleOptions, DsoModuleOptions, + AudModuleOptions, + BuaModuleOptions, SaModuleOptions { readonly compatibilityPort?: ClientCompatibilityPort; readonly readinessPort?: ReadinessPort; diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 760bfe2a..6ea3f0bc 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { AppModule } from '../../src/app.module.js'; +import type { ApiApplicationOptions } from '../../src/bootstrap.js'; import { IamModule } from '../../src/features/iam/iam.module.js'; import { AUTHENTICATION_USE_CASE, @@ -38,6 +39,15 @@ function moduleTypes(): readonly unknown[] { ); } +void test('[AUD-001, BUA-001] API application options expose durable module adapters', () => { + const options = { + auditRepository: {} as never, + entitlementRepository: {} as never, + } satisfies ApiApplicationOptions; + const registered = AppModule.register(options); + assert.equal(registered.module, AppModule); +}); + void test('[IAM-001, AUD-001, BUA-001] application composition includes identity, audit, and entitlements modules', () => { const types = moduleTypes(); assert.ok(types.includes(AudModule)); From 868c573bd2bf5875552960b1e7b341aa7c9d4610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:18:41 +0700 Subject: [PATCH 121/177] fix(iam): wrap direct bootstrap saves in transactions --- .../prisma-identity-bootstrap-repository.adapter.ts | 4 +++- .../iam/prisma-identity-bootstrap-repository.test.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) 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 index f30ecced..03c7d76f 100644 --- 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 @@ -305,7 +305,9 @@ export class PrismaIdentityBootstrapRepositoryAdapter implements IdentityBootstr } public save(bootstrap: PersonalOrganizationBootstrapV1) { - return new PrismaIdentityBootstrapTransactionAdapter(this.client).save(bootstrap); + return this.client.$transaction((transaction) => + new PrismaIdentityBootstrapTransactionAdapter(transaction).save(bootstrap), + ); } public withTransaction( 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 index 4a187435..ccdbe89b 100644 --- a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts +++ b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts @@ -40,6 +40,7 @@ function createDatabase(): { readonly workspaces: Map; readonly projects: Map; readonly memberships: Map; + readonly transactionCalls: { value: number }; } { const users = new Map([ [ @@ -59,6 +60,7 @@ function createDatabase(): { const workspaces = new Map(); const projects = new Map(); const memberships = new Map(); + const transactionCalls = { value: 0 }; const client = { userIdentity: { findUnique: async ({ where }: { readonly where: { readonly id: string } }) => @@ -117,6 +119,7 @@ function createDatabase(): { $transaction: async ( work: (transaction: IdentityBootstrapDatabaseClientV1) => Promise, ) => { + transactionCalls.value += 1; const before = { organizations: new Map(organizations), workspaces: new Map(workspaces), @@ -138,17 +141,19 @@ function createDatabase(): { } }, } as unknown as IdentityBootstrapDatabaseClientV1; - return { client, users, organizations, workspaces, projects, memberships }; + return { client, users, organizations, workspaces, projects, memberships, transactionCalls }; } 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 { client, organizations, workspaces, projects, memberships, transactionCalls } = + 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(transactionCalls.value, 1); assert.equal(organizations.size, 1); assert.equal(workspaces.size, 1); assert.equal(projects.size, 1); From 237ba56e26760f04f8c79314e407bcef507a30f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:20:25 +0700 Subject: [PATCH 122/177] fix(iam): guard membership updates by revision --- .../adapter/prisma-iam-repository.adapter.ts | 11 ++++---- .../iam/prisma-iam-repository.test.ts | 28 +++++++++++++++---- 2 files changed, 28 insertions(+), 11 deletions(-) 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 index ccf18823..995fe68c 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -37,10 +37,10 @@ interface IamMembershipDelegateV1 { readonly where: Readonly>; }): Promise; create(input: { readonly data: IamMembershipDatabaseRowV1 }): Promise; - update(input: { - readonly where: { readonly id: string }; + updateMany(input: { + readonly where: { readonly id: string; readonly revision: number }; readonly data: Partial; - }): Promise; + }): Promise<{ readonly count: number }>; } export interface IamDatabaseClientV1 { @@ -167,14 +167,15 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { !tenantScopesEqualV1(existing.scope, membership.scope) ) throw new Error('IAM_MEMBERSHIP_SCOPE_IMMUTABLE'); - await this.client.membershipIdentity.update({ - where: { id: membership.id }, + const updated = await this.client.membershipIdentity.updateMany({ + where: { id: membership.id, revision: existing.revision }, data: { roleId: membership.roleId, status: membership.status, revision: membership.revision, }, }); + if (updated.count !== 1) throw new Error('IAM_REVISION_CONFLICT'); } } diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index 935ed118..78edb94a 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -66,8 +66,10 @@ function row( function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { readonly client: IamDatabaseClientV1; readonly memberships: Map; + readonly forceUpdateConflict: { value: boolean }; } { const memberships = new Map(rows.map((value) => [value.id, value])); + const forceUpdateConflict = { value: false }; const client = { membershipIdentity: { findUnique: async ({ where }: { readonly where: { readonly id: string } }) => @@ -82,18 +84,19 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { memberships.set(data.id, data); return data; }, - update: async ({ + updateMany: async ({ where, data, }: { - readonly where: { readonly id: string }; + readonly where: { readonly id: string; readonly revision: number }; readonly data: Partial; }) => { + if (forceUpdateConflict.value) return { count: 0 }; const current = memberships.get(where.id); - if (!current) throw new Error('MEMBERSHIP_NOT_FOUND'); + if (!current || current.revision !== where.revision) return { count: 0 }; const updated = { ...current, ...data }; memberships.set(where.id, updated); - return updated; + return { count: 1 }; }, }, $transaction: async (work: (transaction: IamDatabaseClientV1) => Promise) => { @@ -107,7 +110,7 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { } }, } as unknown as IamDatabaseClientV1; - return { client, memberships }; + return { client, memberships, forceUpdateConflict }; } void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and hide siblings', async () => { @@ -131,7 +134,7 @@ void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and }); void test('[IAM-009, IAM-019] Prisma IAM writes require narrowing and enforce optimistic revisions', async () => { - const { client, memberships } = createDatabase(); + const { client, memberships, forceUpdateConflict } = createDatabase(); const repository = new PrismaIamRepositoryAdapter(client); const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; await assert.rejects( @@ -165,6 +168,19 @@ void test('[IAM-009, IAM-019] Prisma IAM writes require narrowing and enforce op /IAM_REVISION_CONFLICT/u, ); assert.equal(memberships.get(id('21'))?.roleId, 'viewer'); + forceUpdateConflict.value = true; + await assert.rejects( + repository.saveMembership(context(workspaceScope, 1), { + id: stable('21'), + principalId, + scope: workspaceScope, + roleId: 'operator', + status: 'ACTIVE', + revision: 2, + }), + /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 () => { From bc069e2ecc404890aa847be0a9bca32439d30691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:23:33 +0700 Subject: [PATCH 123/177] fix(iae): guard placement updates by revision --- .../prisma-artifact-repository.adapter.ts | 9 ++- .../iae/prisma-artifact-repository.test.ts | 61 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts index 05e5bbf0..ded8bdbb 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts @@ -103,6 +103,10 @@ export interface ArtifactDatabaseClientV1 { readonly where: { readonly id: string }; readonly data: { readonly available: boolean; readonly revision: number }; }): Promise; + updateMany(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: { readonly available: boolean; readonly revision: number }; + }): Promise<{ readonly count: number }>; }; readonly evidenceReference: { create(input: { readonly data: EvidenceCreateDataV1 }): Promise; @@ -341,10 +345,11 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { current.contentSha256 !== placement.contentSha256 ) throw new Error('IAE_IMMUTABLE_PLACEMENT'); - await this.client.contentPlacement.update({ - where: { id: placement.placementId }, + const result = await this.client.contentPlacement.updateMany({ + where: { id: placement.placementId, revision: current.revision }, data: { available: placement.available, revision: placement.revision }, }); + if (result.count !== 1) throw new Error('IAE_REVISION_CONFLICT'); } public async saveEvidence( diff --git a/services/api/test/features/iae/prisma-artifact-repository.test.ts b/services/api/test/features/iae/prisma-artifact-repository.test.ts index d503684f..15b2ec56 100644 --- a/services/api/test/features/iae/prisma-artifact-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-repository.test.ts @@ -96,6 +96,13 @@ function client( placements[placements.indexOf(current)] = next; return Promise.resolve(next); }, + updateMany(input) { + const current = placements.find((candidate) => candidate.id === input.where.id); + if (!current || current.revision !== input.where.revision) return Promise.resolve({ count: 0 }); + const next = { ...current, ...input.data }; + placements[placements.indexOf(current)] = next; + return Promise.resolve({ count: 1 }); + }, }, evidenceReference: { create(input) { @@ -186,3 +193,57 @@ void test('[IAE-003, IAE-004, IAE-005, IAM-009] Prisma artifact adapter keeps pl assert.equal((await repository.listPlacements(context('list-placement'), versionId)).length, 1); assert.equal((await repository.listEvidence(context('list-evidence'), versionId)).length, 1); }); + +void test('[IAE-020, DSO-006] Prisma placement adapter rejects a stale revision after a concurrent update', async () => { + const createdAt = parseStrictUtcTimestampV1('2026-01-01T00:00:00.000Z'); + assert.equal(createdAt.accepted, true); + if (!createdAt.accepted) throw new Error('fixture timestamp rejected'); + const artifact = createArtifactVersionV1({ + artifactId, + versionId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'f'.repeat(64), + byteSize: 8, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: createdAt.value, + }); + assert.equal(artifact.accepted, true); + if (!artifact.accepted) throw new Error('fixture artifact rejected'); + const placement = createContentPlacementV1({ + placementId, + artifactVersion: artifact.value, + tenantScope: artifact.value.tenantScope, + kind: 'CLOUD', + opaqueReference: 'opaque-reference-1234', + contentSha256: artifact.value.contentSha256, + }); + assert.equal(placement.accepted, true); + if (!placement.accepted) throw new Error('fixture placement rejected'); + const placements: ContentPlacementDatabaseRowV1[] = []; + const repository = new PrismaArtifactRepositoryAdapter( + client([], placements, []), + ); + await repository.saveVersion(context('stale-version'), artifact.value); + await repository.savePlacement(context('stale-placement'), placement.value); + + const updated = { + ...placement.value, + available: false, + revision: placement.value.revision + 1, + }; + await repository.updatePlacement(context('first-update'), updated); + + await assert.rejects( + repository.updatePlacement(context('stale-update'), { + ...placement.value, + available: true, + revision: placement.value.revision + 1, + }), + /IAE_REVISION_CONFLICT/u, + ); + assert.equal(placements[0]?.available, false); + assert.equal(placements[0]?.revision, 2); +}); From 68e69df8b83e90b07a0684df08bec89c09b95671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:25:56 +0700 Subject: [PATCH 124/177] fix(iae): use unique lookup for derived lineage --- .../prisma-artifact-lineage-repository.adapter.ts | 7 ++----- .../prisma-artifact-lineage-repository.test.ts | 15 ++++++++------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts index 0bd74f25..1338e4d7 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts @@ -33,10 +33,7 @@ export interface ArtifactLineageDatabaseClientV1 { readonly data: ArtifactLineageDatabaseRowV1; }): Promise; findUnique(input: { - readonly where: { readonly id: string }; - }): Promise; - findFirst(input: { - readonly where: { readonly derivedArtifactVersionId: string }; + readonly where: { readonly id: string } | { readonly derivedArtifactVersionId: string }; }): Promise; findMany(input: { readonly where: { readonly sourceVersionIds: { readonly array_contains: string } }; @@ -120,7 +117,7 @@ class PrismaArtifactLineageTransactionAdapter implements ArtifactLineageTransact context: IamTenantContextV1, derivedArtifactVersionId: ArtifactLineageV1['derivedArtifactVersionId'], ): Promise { - const row = await this.client.artifactLineageRecord.findFirst({ + const row = await this.client.artifactLineageRecord.findUnique({ where: { derivedArtifactVersionId }, }); return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; diff --git a/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts b/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts index 3605a398..37622836 100644 --- a/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts @@ -41,13 +41,14 @@ function client(rows: ArtifactLineageDatabaseRowV1[]): ArtifactLineageDatabaseCl return Promise.resolve({ ...data }); }, findUnique({ where }) { - return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); - }, - findFirst({ where }) { - return Promise.resolve( - rows.find((row) => row.derivedArtifactVersionId === where.derivedArtifactVersionId) ?? - null, - ); + const row = + 'id' in where + ? rows.find((candidate) => candidate.id === where.id) + : rows.find( + (candidate) => + candidate.derivedArtifactVersionId === where.derivedArtifactVersionId, + ); + return Promise.resolve(row ?? null); }, findMany({ where }) { return Promise.resolve( From e2ccd90cd2ada4f1ddea3432fced727387b94802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:28:03 +0700 Subject: [PATCH 125/177] fix(iae): bind retention requests to actor context --- .../iae/api/artifact-retention.controller.ts | 2 +- .../iae/application/artifact-retention.service.ts | 13 +++++++++++-- .../features/iae/artifact-retention.service.test.ts | 9 +++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/services/api/src/features/iae/api/artifact-retention.controller.ts b/services/api/src/features/iae/api/artifact-retention.controller.ts index 4b031c49..32edc6c3 100644 --- a/services/api/src/features/iae/api/artifact-retention.controller.ts +++ b/services/api/src/features/iae/api/artifact-retention.controller.ts @@ -53,7 +53,7 @@ export class ArtifactRetentionController { requestId: input.requestId, artifactVersionId: versionId, tenantScope: context.tenantScope, - requestedBy: input.requestedBy, + requestedBy: context.actorId, requestedAt: input.requestedAt, retention: input, }); diff --git a/services/api/src/features/iae/application/artifact-retention.service.ts b/services/api/src/features/iae/application/artifact-retention.service.ts index eba3231f..766fb2f6 100644 --- a/services/api/src/features/iae/application/artifact-retention.service.ts +++ b/services/api/src/features/iae/application/artifact-retention.service.ts @@ -12,7 +12,10 @@ import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js import type { ArtifactRepositoryPortV1 } from './artifact-repository.port.js'; import type { ArtifactRetentionRepositoryPortV1 } from './artifact-retention-repository.port.js'; -export type ArtifactRetentionServiceErrorV1 = 'ARTIFACT_NOT_FOUND' | 'REQUEST_NOT_FOUND'; +export type ArtifactRetentionServiceErrorV1 = + | 'ACTOR_MISMATCH' + | 'ARTIFACT_NOT_FOUND' + | 'REQUEST_NOT_FOUND'; export type ArtifactRetentionServiceResultV1 = | ArtifactRetentionResultV1 | { readonly accepted: false; readonly code: ArtifactRetentionServiceErrorV1 }; @@ -30,7 +33,13 @@ export class ArtifactRetentionService { readonly retention: Parameters[0]; }, ): Promise> { - const created = createArtifactDeletionRequestV1(input); + const requestedBy = parseStableIdentifierV1(input.requestedBy); + if (!requestedBy.accepted || requestedBy.value !== context.actorId) + return Object.freeze({ accepted: false, code: 'ACTOR_MISMATCH' as const }); + const created = createArtifactDeletionRequestV1({ + ...input, + requestedBy: context.actorId, + }); if (!created.accepted) return created; const artifactVersionId = parseStableIdentifierV1(input.artifactVersionId); if (!artifactVersionId.accepted) diff --git a/services/api/test/features/iae/artifact-retention.service.test.ts b/services/api/test/features/iae/artifact-retention.service.test.ts index 059c3aaf..621f40f4 100644 --- a/services/api/test/features/iae/artifact-retention.service.test.ts +++ b/services/api/test/features/iae/artifact-retention.service.test.ts @@ -77,6 +77,15 @@ void test('[IAE-016, IAE-021] retention service preserves blocked requests and a assert.equal(request.accepted, true); if (!request.accepted) return; assert.equal(request.value.state, 'BLOCKED'); + const forged = await service.request(tenantContext, { + requestId: '00000000-0000-4000-8000-000000000719', + artifactVersionId: versionId, + tenantScope: tenantContext.tenantScope, + requestedBy: '00000000-0000-4000-8000-000000000720', + requestedAt: '2026-01-03T00:00:00.000Z', + retention: retention(false), + }); + assert.deepEqual(forged, { accepted: false, code: 'ACTOR_MISMATCH' }); const stale = await service.authorize(tenantContext, { requestId: request.value.requestId, retention: retention(true), From 221c3c691179d0e66b43cc662ff107fb1d8bf29d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:29:52 +0700 Subject: [PATCH 126/177] fix(iae): guard retention transitions by revision --- ...a-artifact-retention-repository.adapter.ts | 14 ++++++- ...isma-artifact-retention-repository.test.ts | 41 ++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts index 7948a67b..f3c1ac99 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts @@ -52,6 +52,15 @@ export interface ArtifactRetentionDatabaseClientV1 { readonly revision: number; }; }): Promise; + updateMany(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: { + readonly state: string; + readonly blockers: unknown; + readonly authorizedAt: Date | null; + readonly revision: number; + }; + }): Promise<{ readonly count: number }>; }; $transaction( work: (transaction: ArtifactRetentionDatabaseClientV1) => Promise, @@ -150,8 +159,8 @@ class PrismaArtifactRetentionTransactionAdapter implements ArtifactRetentionTran current.requestedAt !== request.requestedAt ) throw new Error('IAE_IMMUTABLE_DELETION_REQUEST'); - await this.client.artifactDeletionRequestRecord.update({ - where: { id: request.requestId }, + const result = await this.client.artifactDeletionRequestRecord.updateMany({ + where: { id: request.requestId, revision: current.revision }, data: { state: request.state, blockers: request.blockers, @@ -159,6 +168,7 @@ class PrismaArtifactRetentionTransactionAdapter implements ArtifactRetentionTran revision: request.revision, }, }); + if (result.count !== 1) throw new Error('IAE_REVISION_CONFLICT'); } public async find( diff --git a/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts b/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts index 01cfae2c..f76c929f 100644 --- a/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts @@ -37,7 +37,10 @@ function context() { return result.value; } -function client(rows: ArtifactRetentionDatabaseRowV1[]): ArtifactRetentionDatabaseClientV1 { +function client( + rows: ArtifactRetentionDatabaseRowV1[], + options: { readonly forceRevisionConflict?: boolean } = {}, +): ArtifactRetentionDatabaseClientV1 { return { artifactDeletionRequestRecord: { create({ data }) { @@ -55,6 +58,14 @@ function client(rows: ArtifactRetentionDatabaseRowV1[]): ArtifactRetentionDataba rows[rows.indexOf(current)] = next; return Promise.resolve(next); }, + updateMany({ where, data }) { + const current = rows.find((row) => row.id === where.id); + if (options.forceRevisionConflict || !current || current.revision !== where.revision) + return Promise.resolve({ count: 0 }); + const next = { ...current, ...data }; + rows[rows.indexOf(current)] = next; + return Promise.resolve({ count: 1 }); + }, }, $transaction(work) { return work(this); @@ -80,3 +91,31 @@ void test('[IAE-016, IAE-021, IAM-009] Prisma retention adapter preserves immuta assert.deepEqual(await repository.find(tenantContext, requestId), created.value); assert.equal(rows.length, 1); }); + +void test('[IAE-016] Prisma retention adapter rejects a database revision race', async () => { + const rows: ArtifactRetentionDatabaseRowV1[] = []; + const repository = new PrismaArtifactRetentionRepositoryAdapter( + client(rows, { forceRevisionConflict: true }), + ); + const tenantContext = context(); + const created = createArtifactDeletionRequestV1({ + requestId, + artifactVersionId: '00000000-0000-4000-8000-000000000826', + tenantScope: tenantContext.tenantScope, + requestedBy: tenantContext.actorId, + requestedAt: '2026-01-03T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + await repository.save(tenantContext, created.value); + await assert.rejects( + repository.save(tenantContext, { + ...created.value, + state: 'BLOCKED', + blockers: ['LEGAL_HOLD'], + revision: 2, + }), + /IAE_REVISION_CONFLICT/u, + ); + assert.equal(rows[0]?.revision, 1); +}); From 536a2d137dc0f9082b9471bcdfff5fe5e66d6cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:31:35 +0700 Subject: [PATCH 127/177] fix(iae): guard artifact status transitions by scan state --- .../prisma-artifact-repository.adapter.ts | 11 +++- .../iae/prisma-artifact-repository.test.ts | 51 +++++++++++++++++-- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts index ded8bdbb..fa87c2c5 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts @@ -88,6 +88,10 @@ export interface ArtifactDatabaseClientV1 { readonly where: { readonly id: string }; readonly data: { readonly status: string; readonly scanState: ArtifactScanStateV1 }; }): Promise; + updateMany(input: { + readonly where: { readonly id: string; readonly status: string; readonly scanState: string }; + readonly data: { readonly status: string; readonly scanState: ArtifactScanStateV1 }; + }): Promise<{ readonly count: number }>; }; readonly contentPlacement: { create(input: { @@ -265,10 +269,13 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { throw new Error('IAE_INVALID_STATUS'); if (current.status === 'DELETED' && status !== 'DELETED') throw new Error('IAE_TERMINAL_STATUS'); - const updated = await this.client.artifactVersion.update({ - where: { id: versionId }, + const result = await this.client.artifactVersion.updateMany({ + where: { id: versionId, status: current.status, scanState: current.scanState }, data: { status, scanState: scanState ?? current.scanState }, }); + if (result.count !== 1) throw new Error('IAE_REVISION_CONFLICT'); + const updated = await this.client.artifactVersion.findUnique({ where: { id: versionId } }); + if (updated === null) throw new Error('IAE_VERSION_NOT_FOUND'); return rowToVersion(updated); } diff --git a/services/api/test/features/iae/prisma-artifact-repository.test.ts b/services/api/test/features/iae/prisma-artifact-repository.test.ts index 15b2ec56..05fbd6a7 100644 --- a/services/api/test/features/iae/prisma-artifact-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-repository.test.ts @@ -50,6 +50,7 @@ function client( versions: ArtifactVersionDatabaseRowV1[], placements: ContentPlacementDatabaseRowV1[], evidence: EvidenceDatabaseRowV1[], + options: { readonly forceVersionConflict?: boolean } = {}, ): ArtifactDatabaseClientV1 { return { artifactVersion: { @@ -70,6 +71,19 @@ function client( versions[versions.indexOf(current)] = next; return Promise.resolve(next); }, + updateMany(input) { + const current = versions.find((candidate) => candidate.id === input.where.id); + if ( + options.forceVersionConflict || + !current || + current.status !== input.where.status || + current.scanState !== input.where.scanState + ) + return Promise.resolve({ count: 0 }); + const next = { ...current, ...input.data }; + versions[versions.indexOf(current)] = next; + return Promise.resolve({ count: 1 }); + }, }, contentPlacement: { create(input) { @@ -98,7 +112,8 @@ function client( }, updateMany(input) { const current = placements.find((candidate) => candidate.id === input.where.id); - if (!current || current.revision !== input.where.revision) return Promise.resolve({ count: 0 }); + if (!current || current.revision !== input.where.revision) + return Promise.resolve({ count: 0 }); const next = { ...current, ...input.data }; placements[placements.indexOf(current)] = next; return Promise.resolve({ count: 1 }); @@ -194,6 +209,36 @@ void test('[IAE-003, IAE-004, IAE-005, IAM-009] Prisma artifact adapter keeps pl assert.equal((await repository.listEvidence(context('list-evidence'), versionId)).length, 1); }); +void test('[IAE-009, IAE-010] Prisma artifact status transitions reject a scan-state race', async () => { + const createdAt = parseStrictUtcTimestampV1('2026-01-01T00:00:00.000Z'); + assert.equal(createdAt.accepted, true); + if (!createdAt.accepted) throw new Error('fixture timestamp rejected'); + const artifact = createArtifactVersionV1({ + artifactId, + versionId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'a'.repeat(64), + byteSize: 8, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: createdAt.value, + }); + assert.equal(artifact.accepted, true); + if (!artifact.accepted) throw new Error('fixture artifact rejected'); + const versions: ArtifactVersionDatabaseRowV1[] = []; + const repository = new PrismaArtifactRepositoryAdapter( + client(versions, [], [], { forceVersionConflict: true }), + ); + await repository.saveVersion(context('status-race-version'), artifact.value); + await assert.rejects( + repository.updateVersionStatus(context('status-race-update'), versionId, 'QUARANTINED'), + /IAE_REVISION_CONFLICT/u, + ); + assert.equal(versions[0]?.status, 'ACTIVE'); +}); + void test('[IAE-020, DSO-006] Prisma placement adapter rejects a stale revision after a concurrent update', async () => { const createdAt = parseStrictUtcTimestampV1('2026-01-01T00:00:00.000Z'); assert.equal(createdAt.accepted, true); @@ -223,9 +268,7 @@ void test('[IAE-020, DSO-006] Prisma placement adapter rejects a stale revision assert.equal(placement.accepted, true); if (!placement.accepted) throw new Error('fixture placement rejected'); const placements: ContentPlacementDatabaseRowV1[] = []; - const repository = new PrismaArtifactRepositoryAdapter( - client([], placements, []), - ); + const repository = new PrismaArtifactRepositoryAdapter(client([], placements, [])); await repository.saveVersion(context('stale-version'), artifact.value); await repository.savePlacement(context('stale-placement'), placement.value); From 91b63ba1c3042b684571b35bab8ececa92ef7d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:33:37 +0700 Subject: [PATCH 128/177] test(iae): cover retention HTTP actor binding --- .../iae/artifact-retention.controller.test.ts | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 services/api/test/features/iae/artifact-retention.controller.test.ts diff --git a/services/api/test/features/iae/artifact-retention.controller.test.ts b/services/api/test/features/iae/artifact-retention.controller.test.ts new file mode 100644 index 00000000..e243331b --- /dev/null +++ b/services/api/test/features/iae/artifact-retention.controller.test.ts @@ -0,0 +1,97 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { InMemoryArtifactRetentionRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.js'; +import { ArtifactService } from '../../../src/features/iae/application/artifact.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000731'; +const workspaceId = '00000000-0000-4000-8000-000000000732'; +const actorId = '00000000-0000-4000-8000-000000000733'; +const artifactId = '00000000-0000-4000-8000-000000000734'; +const versionId = '00000000-0000-4000-8000-000000000735'; +const placementId = '00000000-0000-4000-8000-000000000736'; +const requestId = '00000000-0000-4000-8000-000000000737'; + +void test('[IAE-016, IAM-009] retention HTTP binds requester to the authenticated actor and supports reads', async () => { + const contextResult = createIamTenantContextV1({ + actorId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000738', + idempotencyKey: 'retention-http', + }); + assert.equal(contextResult.accepted, true); + if (!contextResult.accepted) throw new Error('fixture context rejected'); + const context = contextResult.value; + const artifacts = new InMemoryArtifactRepositoryAdapter(); + await new ArtifactService(artifacts).register(context, { + version: { + artifactId, + versionId, + tenantScope: context.tenantScope, + sourceKind: 'FILE', + dataMode: 'Local', + contentSha256: 'b'.repeat(64), + byteSize: 1, + mediaType: 'text/plain', + displayName: 'retention.txt', + createdAt: '2026-08-02T00:00:00.000Z', + }, + placement: { + placementId, + tenantScope: context.tenantScope, + kind: 'LOCAL', + opaqueReference: 'local-retention-placement', + contentSha256: 'b'.repeat(64), + }, + }); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(context), + }; + const retention = new InMemoryArtifactRetentionRepositoryAdapter(); + const { app } = await createApiApplication({ + artifactRepository: artifacts, + artifactRetentionRepository: retention, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: `/v1/artifact-versions/${versionId}/deletion-requests`, + payload: { + requestId, + requestedBy: '00000000-0000-4000-8000-000000000739', + requestedAt: '2026-08-02T01:00:00.000Z', + evaluatedAt: '2026-08-02T01:00:00.000Z', + workspaceRetentionUntil: '2026-07-01T00:00:00.000Z', + resourceRetentionUntil: '2026-07-01T00:00:00.000Z', + auditRetentionUntil: '2026-07-01T00:00:00.000Z', + recoveryWindowUntil: '2026-07-01T00:00:00.000Z', + activeApproval: false, + legalHold: false, + }, + }); + assert.equal(response.statusCode, 201); + const created = response.json() as { + readonly accepted: boolean; + readonly value?: { readonly requestedBy?: string; readonly requestId?: string }; + }; + assert.equal(created.accepted, true); + assert.equal(created.value?.requestId, requestId); + assert.equal(created.value?.requestedBy, actorId); + + const read = await app.inject({ + method: 'GET', + url: `/v1/artifact-deletion-requests/${requestId}`, + }); + assert.equal(read.statusCode, 200); + assert.deepEqual(read.json(), created); + assert.doesNotMatch(read.body, /path|bytes|excerpt|opaqueReference/iu); + } finally { + await app.close(); + } +}); From 9bbcb5324ae7d227d58cd0001ab2b6e39ca12622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:37:33 +0700 Subject: [PATCH 129/177] fix(iam): reject changed stale MFA revisions --- .../in-memory-mfa-repository.adapter.ts | 12 +++++++-- .../adapter/prisma-mfa-repository.adapter.ts | 12 +++++++-- .../iam/prisma-mfa-repository.test.ts | 27 +++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts index e96ff924..7ec25f9b 100644 --- a/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts @@ -23,13 +23,21 @@ function immutableState(existing: MfaStateV1, next: MfaStateV1): boolean { (prior.userId !== factor.userId || prior.secretReference !== factor.secretReference) ) return false; - if (prior && factor.revision !== prior.revision && factor.revision !== prior.revision + 1) + if ( + prior && + JSON.stringify(prior) !== JSON.stringify(factor) && + factor.revision !== prior.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) + if ( + prior && + JSON.stringify(prior) !== JSON.stringify(code) && + code.revision !== prior.revision + 1 + ) return false; } return true; 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 index 199897cc..748723d9 100644 --- a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts @@ -192,14 +192,22 @@ function immutableState(existing: MfaStateV1, next: MfaStateV1): boolean { (prior.userId !== factor.userId || prior.secretReference !== factor.secretReference) ) return false; - if (prior && factor.revision !== prior.revision && factor.revision !== prior.revision + 1) + if ( + prior && + JSON.stringify(prior) !== JSON.stringify(factor) && + 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) + if ( + prior && + JSON.stringify(prior) !== JSON.stringify(code) && + code.revision !== prior.revision + 1 + ) return false; if (!prior && code.revision !== 1) return false; } diff --git a/services/api/test/features/iam/prisma-mfa-repository.test.ts b/services/api/test/features/iam/prisma-mfa-repository.test.ts index 05c5a0c6..9d9994f0 100644 --- a/services/api/test/features/iam/prisma-mfa-repository.test.ts +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -173,3 +173,30 @@ void test('[IAM-009, IAM-012] MFA state cannot cross users and failed transactio ); assert.equal(factors.size, 0); }); + +void test('[IAM-012, IAM-014] Prisma MFA persistence rejects a changed stale revision', 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 adapter.saveState(factor.userId, input); + const persisted = factors.get(factor.id); + if (!persisted) throw new Error('fixture factor was not persisted'); + factors.set(factor.id, { + ...persisted, + status: 'ACTIVE', + verifiedAt: new Date('2026-01-01T00:02:00.000Z'), + revision: 2, + }); + const stale = transitionMfaFactorV1(factor, 'VERIFY', '2026-01-01T00:01:00.000Z'); + assert.equal(stale.accepted, true); + if (!stale.accepted) return; + await assert.rejects( + adapter.saveState(factor.userId, { + factors: [stale.value], + recoveryCodes: input.recoveryCodes, + }), + /IAM_MFA_REVISION_CONFLICT/u, + ); +}); From 068286b45a092e75f957fc6e51cf57c89c38bd93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:39:30 +0700 Subject: [PATCH 130/177] fix(iam): guard device transitions by revision --- ...isma-device-identity-repository.adapter.ts | 18 ++++--- .../prisma-device-identity-repository.test.ts | 48 +++++++++++++++++-- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts index 76f60ffc..3e8025e2 100644 --- a/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts @@ -56,6 +56,10 @@ interface DelegateV1 { readonly where: { readonly id: string }; readonly data: TUpdate; }): Promise; + updateMany?(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: TUpdate; + }): Promise<{ readonly count: number }>; } export interface DeviceIdentityDatabaseClientV1 { @@ -245,11 +249,12 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio challenge.revision !== current.revision + 1 ) throw new Error('IMMUTABLE_CHALLENGE'); - if (!this.client.deviceEnrollmentChallenge.update) throw new Error('UPDATE_UNAVAILABLE'); - await this.client.deviceEnrollmentChallenge.update({ - where: { id: challenge.id }, + if (!this.client.deviceEnrollmentChallenge.updateMany) throw new Error('UPDATE_UNAVAILABLE'); + const result = await this.client.deviceEnrollmentChallenge.updateMany({ + where: { id: challenge.id, revision: current.revision }, data: { status: challenge.status, revision: challenge.revision }, }); + if (result.count !== 1) throw new Error('REVISION_CONFLICT'); } public async findChallenge( @@ -301,9 +306,9 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio if (!current) throw new Error('DEVICE_NOT_FOUND'); if (current.revision !== expectedRevision) throw new Error('REVISION_CONFLICT'); if (device.revision !== expectedRevision + 1) throw new Error('INVALID_REVISION'); - if (!this.client.deviceIdentity.update) throw new Error('UPDATE_UNAVAILABLE'); - await this.client.deviceIdentity.update({ - where: { id: device.id }, + if (!this.client.deviceIdentity.updateMany) throw new Error('UPDATE_UNAVAILABLE'); + const result = await this.client.deviceIdentity.updateMany({ + where: { id: device.id, revision: expectedRevision }, data: { publicKey: device.publicKey, status: device.status, @@ -313,6 +318,7 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio revokedAt: device.revokedAt ? new Date(device.revokedAt) : null, }, }); + if (result.count !== 1) throw new Error('REVISION_CONFLICT'); } } diff --git a/services/api/test/features/iam/prisma-device-identity-repository.test.ts b/services/api/test/features/iam/prisma-device-identity-repository.test.ts index 0d876d1d..475a3901 100644 --- a/services/api/test/features/iam/prisma-device-identity-repository.test.ts +++ b/services/api/test/features/iam/prisma-device-identity-repository.test.ts @@ -82,7 +82,7 @@ function device(): DeviceIdentityV1 { return result.value; } -function delegate(rows: Record[]) { +function delegate(rows: Record[], forceRevisionConflict = false) { return { create({ data }: { readonly data: Record }) { const persisted = { ...data }; @@ -109,15 +109,32 @@ function delegate(rows: Record[]) { rows[index] = { ...rows[index], ...data }; return Promise.resolve(rows[index]); }, + updateMany({ + where, + data, + }: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: Record; + }) { + if (forceRevisionConflict) return Promise.resolve({ count: 0 }); + const index = rows.findIndex( + (row) => row['id'] === where.id && row['revision'] === where.revision, + ); + if (index < 0) return Promise.resolve({ count: 0 }); + rows[index] = { ...rows[index], ...data }; + return Promise.resolve({ count: 1 }); + }, }; } -function client(): DeviceIdentityDatabaseClientV1 { +function client( + options: { readonly forceRevisionConflict?: boolean } = {}, +): DeviceIdentityDatabaseClientV1 { const challengeRows: Record[] = []; const deviceRows: Record[] = []; const database = { - deviceEnrollmentChallenge: delegate(challengeRows), - deviceIdentity: delegate(deviceRows), + deviceEnrollmentChallenge: delegate(challengeRows, options.forceRevisionConflict), + deviceIdentity: delegate(deviceRows, options.forceRevisionConflict), async $transaction( work: (transaction: DeviceIdentityDatabaseClientV1) => Promise, ) { @@ -157,3 +174,26 @@ void test('[IAM-007, IAM-009, IAM-021] Prisma device identity adapter persists c 'ACTIVE', ); }); + +void test('[IAM-007, IAM-021] Prisma device identity transitions reject database revision races', async () => { + const repository = new PrismaDeviceIdentityRepositoryAdapter( + client({ forceRevisionConflict: true }), + ); + await repository.saveChallenge(context(), challenge()); + const used = { + ...challenge(), + status: 'USED' as const, + revision: 2, + }; + await assert.rejects(repository.saveChallenge(context(), used), /REVISION_CONFLICT/u); + + await repository.saveDevice(context(), device()); + const active = { + ...device(), + status: 'ACTIVE' as const, + securityEpoch: 2, + revision: 2, + activatedAt: timestamp('2026-01-01T00:01:00.000Z'), + }; + await assert.rejects(repository.replaceDevice(context(), active, 1), /REVISION_CONFLICT/u); +}); From 216f4a197cca2c24b1617f16cb0cd522d5a84e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:41:39 +0700 Subject: [PATCH 131/177] fix(bua): guard reservation settlement by revision --- .../prisma-entitlement-repository.adapter.ts | 11 ++-- .../bua/prisma-entitlement-repository.test.ts | 51 +++++++++++++++++-- 2 files changed, 56 insertions(+), 6 deletions(-) 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 index 13d5019f..b73bc1b5 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -121,6 +121,10 @@ interface DelegateV1 { readonly where: { readonly id: string }; readonly data: Readonly>; }): Promise; + updateMany?(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: Readonly>; + }): Promise<{ readonly count: number }>; } export interface EntitlementDatabaseClientV1 { @@ -562,11 +566,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV 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 }, + if (!this.client.usageReservationRecord.updateMany) throw new Error('BUA_UPDATE_UNAVAILABLE'); + const result = await this.client.usageReservationRecord.updateMany({ + where: { id: reservation.reservationId, revision: current.revision }, data: { status: reservation.status, revision: reservation.revision, updatedAt: new Date() }, }); + if (result.count !== 1) throw new Error('BUA_RESERVATION_CONFLICT'); } } } diff --git a/services/api/test/features/bua/prisma-entitlement-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-repository.test.ts index de4527ec..c13d7eb1 100644 --- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -72,7 +72,10 @@ function snapshot(): EntitlementSnapshotV1 { }; } -function delegate>(rows: TRow[]) { +function delegate>( + rows: TRow[], + forceRevisionConflict = false, +) { return { create({ data }: { readonly data: TRow }) { const persisted = { ...data }; @@ -121,10 +124,27 @@ function delegate>(rows: TRow[]) { rows[index] = { ...rows[index], ...data } as TRow; return Promise.resolve(rows[index]); }, + updateMany({ + where, + data, + }: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: Record; + }) { + if (forceRevisionConflict) return Promise.resolve({ count: 0 }); + const index = rows.findIndex( + (row) => row['id'] === where.id && row['revision'] === where.revision, + ); + if (index < 0) return Promise.resolve({ count: 0 }); + rows[index] = { ...rows[index], ...data } as TRow; + return Promise.resolve({ count: 1 }); + }, }; } -function client(): EntitlementDatabaseClientV1 { +function client( + options: { readonly forceRevisionConflict?: boolean } = {}, +): EntitlementDatabaseClientV1 { const planRows: Record[] = []; const snapshotRows: Record[] = []; const entryRows: Record[] = []; @@ -133,7 +153,7 @@ function client(): EntitlementDatabaseClientV1 { entitlementPlanRecord: delegate(planRows), entitlementSnapshotRecord: delegate(snapshotRows), usageLedgerEntryRecord: delegate(entryRows), - usageReservationRecord: delegate(reservationRows), + usageReservationRecord: delegate(reservationRows, options.forceRevisionConflict), async $transaction( work: (transaction: EntitlementDatabaseClientV1) => Promise, ): Promise { @@ -224,3 +244,28 @@ void test('[BUA-012] Prisma entitlement adapter applies reservation status revis finalized, ); }); + +void test('[BUA-012] Prisma entitlement adapter rejects a reservation settlement race', async () => { + const repository = new PrismaEntitlementRepositoryAdapter( + client({ forceRevisionConflict: true }), + ); + await repository.saveSnapshot(context(workspaceId, 'seed-race'), snapshot()); + const service = new EntitlementAdmissionService(repository); + const admitted = await service.admit( + context(workspaceId, 'admit-race'), + admissionInput('admit-race', '1'), + ); + assert.equal(admitted.accepted, true); + if (!admitted.accepted) return; + await assert.rejects( + service.finalize(context(workspaceId, 'finish-race'), { + reservationId: admitted.value.reservation.reservationId, + releaseEntryId: stable('00000000-0000-4000-8000-000000000241'), + commitEntryId: stable('00000000-0000-4000-8000-000000000242'), + committedUnits: 1, + now: '2026-01-01T00:02:00.000Z', + idempotencyKey: 'finish-race', + }), + /BUA_RESERVATION_CONFLICT/u, + ); +}); From 735c1f309d142c09d8561e8c34f830d3771876b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:43:30 +0700 Subject: [PATCH 132/177] fix(dso): guard capability replacement by revision --- ...ma-device-capability-repository.adapter.ts | 19 ++++--- ...risma-device-capability-repository.test.ts | 49 +++++++++++++++++-- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/services/api/src/features/dso/adapter/prisma-device-capability-repository.adapter.ts b/services/api/src/features/dso/adapter/prisma-device-capability-repository.adapter.ts index 9bb87906..13bbfdd3 100644 --- a/services/api/src/features/dso/adapter/prisma-device-capability-repository.adapter.ts +++ b/services/api/src/features/dso/adapter/prisma-device-capability-repository.adapter.ts @@ -50,6 +50,10 @@ interface DelegateV1 { readonly where: { readonly id: string }; readonly data: TUpdate; }): Promise; + updateMany?(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: TUpdate; + }): Promise<{ readonly count: number }>; } export interface DeviceCapabilityDatabaseCreateDataV1 { @@ -302,15 +306,16 @@ class PrismaDeviceCapabilityTransactionAdapter implements DeviceCapabilityTransa current.opaqueLocalHandle !== capability.opaqueLocalHandle ) throw new Error('DSO_IMMUTABLE_CAPABILITY'); - if (!this.client.deviceCapabilityRecord.update) throw new Error('DSO_UPDATE_UNAVAILABLE'); - await this.client.deviceCapabilityRecord.update({ - where: { id: capability.capabilityId }, + if (!this.client.deviceCapabilityRecord.updateMany) throw new Error('DSO_UPDATE_UNAVAILABLE'); + const result = await this.client.deviceCapabilityRecord.updateMany({ + where: { id: capability.capabilityId, revision: expectedRevision }, data: { status: capability.status, reportedAt: new Date(capability.reportedAt), revision: capability.revision, }, }); + if (result.count !== 1) throw new Error('DSO_REVISION_CONFLICT'); } public async replaceGrant( @@ -329,11 +334,13 @@ class PrismaDeviceCapabilityTransactionAdapter implements DeviceCapabilityTransa current.authorizationEpoch !== grant.authorizationEpoch ) throw new Error('DSO_IMMUTABLE_GRANT'); - if (!this.client.deviceOperationalGrantRecord.update) throw new Error('DSO_UPDATE_UNAVAILABLE'); - await this.client.deviceOperationalGrantRecord.update({ - where: { id: grant.grantId }, + if (!this.client.deviceOperationalGrantRecord.updateMany) + throw new Error('DSO_UPDATE_UNAVAILABLE'); + const result = await this.client.deviceOperationalGrantRecord.updateMany({ + where: { id: grant.grantId, revision: expectedRevision }, data: { status: grant.status, revision: grant.revision }, }); + if (result.count !== 1) throw new Error('DSO_REVISION_CONFLICT'); } } diff --git a/services/api/test/features/dso/prisma-device-capability-repository.test.ts b/services/api/test/features/dso/prisma-device-capability-repository.test.ts index dafa52ff..6b1f0967 100644 --- a/services/api/test/features/dso/prisma-device-capability-repository.test.ts +++ b/services/api/test/features/dso/prisma-device-capability-repository.test.ts @@ -77,7 +77,7 @@ function grant(): DeviceGrantV1 { return result.value; } -function delegate(rows: Record[]) { +function delegate(rows: Record[], forceRevisionConflict = false) { return { create({ data }: { readonly data: Record }) { const persisted = { ...data }; @@ -116,15 +116,32 @@ function delegate(rows: Record[]) { rows[index] = { ...rows[index], ...data }; return Promise.resolve(rows[index]); }, + updateMany({ + where, + data, + }: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: Record; + }) { + if (forceRevisionConflict) return Promise.resolve({ count: 0 }); + const index = rows.findIndex( + (row) => row['id'] === where.id && row['revision'] === where.revision, + ); + if (index < 0) return Promise.resolve({ count: 0 }); + rows[index] = { ...rows[index], ...data }; + return Promise.resolve({ count: 1 }); + }, }; } -function client(): DeviceCapabilityDatabaseClientV1 { +function client( + options: { readonly forceRevisionConflict?: boolean } = {}, +): DeviceCapabilityDatabaseClientV1 { const capabilityRows: Record[] = []; const grantRows: Record[] = []; const database = { - deviceCapabilityRecord: delegate(capabilityRows), - deviceOperationalGrantRecord: delegate(grantRows), + deviceCapabilityRecord: delegate(capabilityRows, options.forceRevisionConflict), + deviceOperationalGrantRecord: delegate(grantRows, options.forceRevisionConflict), async $transaction( work: (transaction: DeviceCapabilityDatabaseClientV1) => Promise, ) { @@ -174,3 +191,27 @@ void test('[DSO-005, DSO-016] Prisma grant adapter hides sibling workspaces and /DSO_REVISION_CONFLICT/, ); }); + +void test('[DSO-005, DSO-016] Prisma capability and grant replacements reject database races', async () => { + const repository = new PrismaDeviceCapabilityRepositoryAdapter( + client({ forceRevisionConflict: true }), + ); + await repository.saveCapability(context(workspaceId, 'cap-race-save'), capability()); + await assert.rejects( + repository.replaceCapability( + context(workspaceId, 'cap-race-replace'), + { ...capability(), status: 'PAUSED', revision: 2 }, + 1, + ), + /DSO_REVISION_CONFLICT/u, + ); + await repository.saveGrant(context(workspaceId, 'grant-race-save'), grant()); + await assert.rejects( + repository.replaceGrant( + context(workspaceId, 'grant-race-replace'), + { ...grant(), status: 'REVOKED', revision: 2 }, + 1, + ), + /DSO_REVISION_CONFLICT/u, + ); +}); From 98f7f75baf0a43eaf046fcbbb0b0681c95144b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:45:14 +0700 Subject: [PATCH 133/177] fix(dso): guard sync transitions by revision --- .../prisma-device-sync-repository.adapter.ts | 12 ++++-- .../dso/prisma-device-sync-repository.test.ts | 38 +++++++++++++++++-- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/services/api/src/features/dso/adapter/prisma-device-sync-repository.adapter.ts b/services/api/src/features/dso/adapter/prisma-device-sync-repository.adapter.ts index 2e5c655d..d0eb8219 100644 --- a/services/api/src/features/dso/adapter/prisma-device-sync-repository.adapter.ts +++ b/services/api/src/features/dso/adapter/prisma-device-sync-repository.adapter.ts @@ -104,6 +104,10 @@ interface DelegateV1 { readonly where: { readonly id: string }; readonly data: TUpdate; }): Promise; + updateMany?(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: TUpdate; + }): Promise<{ readonly count: number }>; } export interface DeviceSyncDatabaseClientV1 { @@ -487,15 +491,17 @@ class PrismaDeviceSyncTransactionAdapter implements DeviceSyncTransactionPortV1 throw new Error('DSO_IMMUTABLE_RECORD'); if (JSON.stringify(current) === JSON.stringify(operation)) return; if (operation.revision !== current.revision + 1) throw new Error('DSO_REVISION_CONFLICT'); - if (!this.client.deviceSyncOperationRecord.update) throw new Error('DSO_UPDATE_UNAVAILABLE'); - await this.client.deviceSyncOperationRecord.update({ - where: { id: operation.operationId }, + if (!this.client.deviceSyncOperationRecord.updateMany) + throw new Error('DSO_UPDATE_UNAVAILABLE'); + const result = await this.client.deviceSyncOperationRecord.updateMany({ + where: { id: operation.operationId, revision: current.revision }, data: { status: operation.status, revision: operation.revision, acknowledgedAt: operation.acknowledgedAt ? new Date(operation.acknowledgedAt) : null, }, }); + if (result.count !== 1) throw new Error('DSO_REVISION_CONFLICT'); } public async findOperation( diff --git a/services/api/test/features/dso/prisma-device-sync-repository.test.ts b/services/api/test/features/dso/prisma-device-sync-repository.test.ts index d4a731e8..db3fbd02 100644 --- a/services/api/test/features/dso/prisma-device-sync-repository.test.ts +++ b/services/api/test/features/dso/prisma-device-sync-repository.test.ts @@ -132,7 +132,7 @@ function matches(row: Record, where: Readonly row[key] === value); } -function delegate(rows: Record[]) { +function delegate(rows: Record[], forceRevisionConflict = false) { return { create({ data }: { readonly data: Record }) { const persisted = { ...data }; @@ -180,16 +180,33 @@ function delegate(rows: Record[]) { rows[index] = { ...rows[index], ...data }; return Promise.resolve(rows[index]); }, + updateMany({ + where, + data, + }: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: Record; + }) { + if (forceRevisionConflict) return Promise.resolve({ count: 0 }); + const index = rows.findIndex( + (row) => row['id'] === where.id && row['revision'] === where.revision, + ); + if (index < 0) return Promise.resolve({ count: 0 }); + rows[index] = { ...rows[index], ...data }; + return Promise.resolve({ count: 1 }); + }, }; } -function client(): DeviceSyncDatabaseClientV1 { +function client( + options: { readonly forceRevisionConflict?: boolean } = {}, +): DeviceSyncDatabaseClientV1 { const operationRows: Record[] = []; const conflictRows: Record[] = []; const packageRows: Record[] = []; const receiptRows: Record[] = []; const database = { - deviceSyncOperationRecord: delegate(operationRows), + deviceSyncOperationRecord: delegate(operationRows, options.forceRevisionConflict), deviceSyncConflictRecord: delegate(conflictRows), strictLocalPackageManifestRecord: delegate(packageRows), deviceTransferReceiptRecord: delegate(receiptRows), @@ -241,3 +258,18 @@ void test('[DSO-018, DSO-019, DSO-021] Prisma DSO adapter keeps conflict and tra /DSO_SCOPE_NARROWING_REQUIRED/u, ); }); + +void test('[DSO-011, DSO-016] Prisma sync-operation transitions reject a database revision race', async () => { + const repository = new PrismaDeviceSyncRepositoryAdapter(client({ forceRevisionConflict: true })); + const first = operation(); + await repository.saveOperation(context(workspaceId, 'race-save'), first); + const accepted = transitionDeviceSyncOperationV1(first, 'ACCEPT', '2026-01-01T00:00:01.000Z'); + assert.equal(accepted.accepted, true); + if (!accepted.accepted) return; + await assert.rejects( + repository.saveOperation(context(workspaceId, 'race-accept', 1), accepted.value, { + expectedRevision: 1, + }), + /DSO_REVISION_CONFLICT/u, + ); +}); From 49e7426f74904ff926094d21882bc9d69c4ae1e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:47:02 +0700 Subject: [PATCH 134/177] fix(dso): guard grant revocation by revision --- ...device-authorization-repository.adapter.ts | 11 ++++-- ...ma-device-authorization-repository.test.ts | 34 +++++++++++++++++-- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/services/api/src/features/dso/adapter/prisma-device-authorization-repository.adapter.ts b/services/api/src/features/dso/adapter/prisma-device-authorization-repository.adapter.ts index 8602dd37..f5666131 100644 --- a/services/api/src/features/dso/adapter/prisma-device-authorization-repository.adapter.ts +++ b/services/api/src/features/dso/adapter/prisma-device-authorization-repository.adapter.ts @@ -62,6 +62,10 @@ interface DelegateV1 { readonly where: { readonly id: string }; readonly data: TUpdate; }): Promise; + updateMany?(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: TUpdate; + }): Promise<{ readonly count: number }>; } export interface DeviceAuthorizationDatabaseClientV1 { @@ -292,16 +296,17 @@ class PrismaDeviceAuthorizationTransactionAdapter implements DeviceAuthorization if (!tenantScopeContainsV1(context.tenantScope, current.tenantScope)) throw new Error('DSO_SCOPE_NARROWING_REQUIRED'); if (current.revision !== expectedRevision) throw new Error('DSO_REVISION_CONFLICT'); - if (!this.client.deviceGrantRecord.update) throw new Error('DSO_UPDATE_UNAVAILABLE'); const next = Object.freeze({ ...current, status: current.status === 'ACTIVE' ? ('REVOKED' as const) : current.status, revision: current.status === 'ACTIVE' ? current.revision + 1 : current.revision, }); - await this.client.deviceGrantRecord.update({ - where: { id: grantId }, + if (!this.client.deviceGrantRecord.updateMany) throw new Error('DSO_UPDATE_UNAVAILABLE'); + const result = await this.client.deviceGrantRecord.updateMany({ + where: { id: grantId, revision: expectedRevision }, data: { status: next.status, revision: next.revision }, }); + if (result.count !== 1) throw new Error('DSO_REVISION_CONFLICT'); return next; } } diff --git a/services/api/test/features/dso/prisma-device-authorization-repository.test.ts b/services/api/test/features/dso/prisma-device-authorization-repository.test.ts index 32673e54..6604c68d 100644 --- a/services/api/test/features/dso/prisma-device-authorization-repository.test.ts +++ b/services/api/test/features/dso/prisma-device-authorization-repository.test.ts @@ -83,7 +83,7 @@ function grant(): OpaqueDeviceGrantV1 { return created.value; } -function delegate(rows: Record[]) { +function delegate(rows: Record[], forceRevisionConflict = false) { return { create({ data }: { readonly data: Record }) { const persisted = { ...data }; @@ -123,15 +123,32 @@ function delegate(rows: Record[]) { rows[index] = { ...rows[index], ...data }; return Promise.resolve(rows[index]); }, + updateMany({ + where, + data, + }: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: Record; + }) { + if (forceRevisionConflict) return Promise.resolve({ count: 0 }); + const index = rows.findIndex( + (row) => row['id'] === where.id && row['revision'] === where.revision, + ); + if (index < 0) return Promise.resolve({ count: 0 }); + rows[index] = { ...rows[index], ...data }; + return Promise.resolve({ count: 1 }); + }, }; } -function client(): DeviceAuthorizationDatabaseClientV1 { +function client( + options: { readonly forceRevisionConflict?: boolean } = {}, +): DeviceAuthorizationDatabaseClientV1 { const snapshotRows: Record[] = []; const grantRows: Record[] = []; const database = { authorizationSnapshot: delegate(snapshotRows), - deviceGrantRecord: delegate(grantRows), + deviceGrantRecord: delegate(grantRows, options.forceRevisionConflict), async $transaction( work: (transaction: DeviceAuthorizationDatabaseClientV1) => Promise, ) { @@ -165,3 +182,14 @@ void test('[IAM-020, DSO-005, IAM-009] Prisma device authorization adapter persi 2, ); }); + +void test('[IAM-020, DSO-005] Prisma device grant revocation rejects a database race', async () => { + const repository = new PrismaDeviceAuthorizationRepositoryAdapter( + client({ forceRevisionConflict: true }), + ); + await repository.saveGrant(context(workspaceId, 'grant-race-save'), grant()); + await assert.rejects( + repository.revokeGrant(context(workspaceId, 'grant-race-revoke'), id(grantId), 1), + /DSO_REVISION_CONFLICT/u, + ); +}); From ebf76c52320e45ab438059c1debb63f889fea151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:49:02 +0700 Subject: [PATCH 135/177] fix(dsm): validate immutable dataset revisions --- .../prisma-governed-dataset-repository.adapter.ts | 2 ++ .../dsm/prisma-governed-dataset-repository.test.ts | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/services/api/src/features/dsm/adapter/prisma-governed-dataset-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-governed-dataset-repository.adapter.ts index 3bd2c6d7..87cd4d3e 100644 --- a/services/api/src/features/dsm/adapter/prisma-governed-dataset-repository.adapter.ts +++ b/services/api/src/features/dsm/adapter/prisma-governed-dataset-repository.adapter.ts @@ -27,6 +27,7 @@ export interface GovernedDatasetDatabaseRowV1 { readonly status: string; readonly createdAt: Date; readonly publishedAt: Date | null; + readonly revision: number; readonly canonicalHash: string; } @@ -88,6 +89,7 @@ function domainScope(row: GovernedDatasetDatabaseRowV1): TenantScopeV1 { } function rowToDomain(row: GovernedDatasetDatabaseRowV1): GovernedDatasetDefinitionV1 { + if (row.revision !== 1) throw new Error('DSM_PERSISTED_REVISION_INVALID'); const created = createGovernedDatasetDefinitionV1({ datasetId: row.datasetId, versionId: row.id, diff --git a/services/api/test/features/dsm/prisma-governed-dataset-repository.test.ts b/services/api/test/features/dsm/prisma-governed-dataset-repository.test.ts index 89b14f93..7264362d 100644 --- a/services/api/test/features/dsm/prisma-governed-dataset-repository.test.ts +++ b/services/api/test/features/dsm/prisma-governed-dataset-repository.test.ts @@ -66,6 +66,7 @@ function row(id: string, candidateWorkspaceId: string): GovernedDatasetDatabaseR status: 'DRAFT', createdAt: new Date('2026-01-01T00:00:00.000Z'), publishedAt: null, + revision: 1, canonicalHash: 'a'.repeat(64), }; } @@ -153,3 +154,13 @@ void test('[DSM-001] Prisma dataset adapter persists a replay exactly once', asy await repository.save(context(workspaceId, 'replay'), definition); assert.equal(rows.length, 1); }); + +void test('[DSM-001] Prisma dataset adapter rejects a non-initial immutable revision', async () => { + const repository = new PrismaGovernedDatasetRepositoryAdapter( + client([{ ...row(versionId, workspaceId), revision: 2 }]), + ); + await assert.rejects( + repository.find(context(workspaceId, 'invalid-revision'), versionId), + /DSM_PERSISTED_REVISION_INVALID/u, + ); +}); From 9d3f82e326e71b8c92bc02cbaad3142cd85a6da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:50:39 +0700 Subject: [PATCH 136/177] fix(dsm): validate immutable mapping revisions --- .../dsm/adapter/prisma-mapping-repository.adapter.ts | 2 ++ .../test/features/dsm/prisma-mapping-repository.test.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/services/api/src/features/dsm/adapter/prisma-mapping-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-mapping-repository.adapter.ts index 3b0b7bb3..3c7b880c 100644 --- a/services/api/src/features/dsm/adapter/prisma-mapping-repository.adapter.ts +++ b/services/api/src/features/dsm/adapter/prisma-mapping-repository.adapter.ts @@ -24,6 +24,7 @@ export interface MappingDatabaseRowV1 { readonly status: string; readonly createdAt: Date; readonly publishedAt: Date | null; + readonly revision: number; readonly canonicalHash: string; } @@ -72,6 +73,7 @@ function databaseScope(scope: TenantScopeV1) { } function rowToDomain(row: MappingDatabaseRowV1): MappingDefinitionV1 { + if (row.revision !== 1) throw new Error('DSM_PERSISTED_REVISION_INVALID'); const parsed = createMappingDefinitionV1({ datasetId: row.datasetId, versionId: row.id, diff --git a/services/api/test/features/dsm/prisma-mapping-repository.test.ts b/services/api/test/features/dsm/prisma-mapping-repository.test.ts index 5002b8f1..6382c82d 100644 --- a/services/api/test/features/dsm/prisma-mapping-repository.test.ts +++ b/services/api/test/features/dsm/prisma-mapping-repository.test.ts @@ -91,4 +91,11 @@ void test('[DSM-007, IAM-009] Prisma mapping adapter persists and lists typed ma (await repository.list(context('list'), datasetId)).map((item) => item.versionId), [versionId], ); + const persisted = rows[0]; + if (!persisted) throw new Error('fixture mapping was not persisted'); + rows[0] = { ...persisted, revision: 2 }; + await assert.rejects( + repository.find(context('invalid-revision'), versionId), + /DSM_PERSISTED_REVISION_INVALID/u, + ); }); From 21be92ef9f811bc7b9b6ebe25e1f678e7f3905b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 04:51:43 +0700 Subject: [PATCH 137/177] fix(style): format artifact domain tests --- packages/domain/test/artifact-governance-v1.test.mjs | 8 ++++---- packages/domain/test/artifact-v1.test.mjs | 5 +---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/domain/test/artifact-governance-v1.test.mjs b/packages/domain/test/artifact-governance-v1.test.mjs index 8f35696f..0f2481b2 100644 --- a/packages/domain/test/artifact-governance-v1.test.mjs +++ b/packages/domain/test/artifact-governance-v1.test.mjs @@ -56,10 +56,10 @@ void test('[IAE-007] lineage rejects self-references, scope cardinality drift, a }), { accepted: false, code: 'DUPLICATE_IDENTIFIER' }, ); - assert.deepEqual( - createArtifactLineageV1({ ...base, sourceTenantScopes: [] }), - { accepted: false, code: 'INVALID_SCOPE' }, - ); + assert.deepEqual(createArtifactLineageV1({ ...base, sourceTenantScopes: [] }), { + accepted: false, + code: 'INVALID_SCOPE', + }); assert.deepEqual( createArtifactLineageV1({ ...base, diff --git a/packages/domain/test/artifact-v1.test.mjs b/packages/domain/test/artifact-v1.test.mjs index 0ad377a9..aac412b5 100644 --- a/packages/domain/test/artifact-v1.test.mjs +++ b/packages/domain/test/artifact-v1.test.mjs @@ -133,10 +133,7 @@ void test('[IAE-006] evidence coordinates are validated against exact source geo { accepted: false, code: 'INVALID_COORDINATE' }, ); assert.deepEqual( - validateEvidenceCoordinateV1( - { kind: 'PAGE', page: 1 }, - { kind: 'PAGED', maxPage: '3' }, - ), + validateEvidenceCoordinateV1({ kind: 'PAGE', page: 1 }, { kind: 'PAGED', maxPage: '3' }), { accepted: false, code: 'INVALID_COORDINATE' }, ); }); From 1336ddc7c4429dfd6c0c89115a7ae83915becb9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:06:05 +0700 Subject: [PATCH 138/177] fix(tooling): ignore worktrees in lint --- eslint.config.mjs | 1 + .../test/features/iae/artifact-retention.controller.test.ts | 4 ++-- .../api/test/features/sa/spreadsheet-audit.controller.test.ts | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index d1aa84ad..b5ccad48 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,7 @@ export default tseslint.config( '**/out/**', 'packages/contracts/generated/**', 'tools/repo-cli/test/fixtures/**', + '.worktrees/**', ], }, eslint.configs.recommended, diff --git a/services/api/test/features/iae/artifact-retention.controller.test.ts b/services/api/test/features/iae/artifact-retention.controller.test.ts index e243331b..7492de79 100644 --- a/services/api/test/features/iae/artifact-retention.controller.test.ts +++ b/services/api/test/features/iae/artifact-retention.controller.test.ts @@ -76,10 +76,10 @@ void test('[IAE-016, IAM-009] retention HTTP binds requester to the authenticate }, }); assert.equal(response.statusCode, 201); - const created = response.json() as { + const created = response.json<{ readonly accepted: boolean; readonly value?: { readonly requestedBy?: string; readonly requestId?: string }; - }; + }>(); assert.equal(created.accepted, true); assert.equal(created.value?.requestId, requestId); assert.equal(created.value?.requestedBy, actorId); diff --git a/services/api/test/features/sa/spreadsheet-audit.controller.test.ts b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts index 0c1f6995..6ddc38ac 100644 --- a/services/api/test/features/sa/spreadsheet-audit.controller.test.ts +++ b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts @@ -64,7 +64,8 @@ void test('SA-001/SA-004 HTTP stores value-free audit results and rejects source }); assert.equal(rejected.statusCode, 400); const rejectedBody = JSON.parse(rejected.body) as Record; - const { correlationId: _correlationId, ...rejectedWithoutCorrelation } = rejectedBody; + const rejectedWithoutCorrelation = { ...rejectedBody }; + delete rejectedWithoutCorrelation.correlationId; assert.doesNotMatch(JSON.stringify(rejectedWithoutCorrelation), /SUM|42|sourceValue/iu); const created = await app.inject({ From 48ede830cf6f8a18b68f9f142886968992e3e58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:19:11 +0700 Subject: [PATCH 139/177] fix(engine): satisfy ruff pairwise checks --- .../databreeze_engine/processors/spreadsheet_auditor.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py index 5887f246..afaa4270 100644 --- a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py +++ b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py @@ -4,6 +4,7 @@ import hashlib import io +import itertools import posixpath import re import zipfile @@ -251,7 +252,7 @@ def audit_workbook( gap_keys: set[tuple[str, str]] = set() for column, rows in cells_by_column.items(): formula_rows = sorted(row for row, formula in rows.items() if formula is not None) - for previous_row, next_row in zip(formula_rows, formula_rows[1:]): + for previous_row, next_row in itertools.pairwise(formula_rows): if next_row - previous_row <= 1: continue previous_formula = rows[previous_row] @@ -261,9 +262,7 @@ def audit_workbook( previous_family = _normalized_formula(previous_formula) if previous_family != _normalized_formula(next_formula): continue - populated_rows = sorted( - row for row in rows if previous_row < row < next_row - ) + populated_rows = sorted(row for row in rows if previous_row < row < next_row) for row in populated_rows: address = f"{_column_name(column)}{row}" key = (address, previous_family) From c9cac6bb2339770be146a34b0e7eafee011f6870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:19:30 +0700 Subject: [PATCH 140/177] fix(api): satisfy strict correlation redaction typing --- .../api/test/features/sa/spreadsheet-audit.controller.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/api/test/features/sa/spreadsheet-audit.controller.test.ts b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts index 6ddc38ac..5f0dca75 100644 --- a/services/api/test/features/sa/spreadsheet-audit.controller.test.ts +++ b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts @@ -65,7 +65,7 @@ void test('SA-001/SA-004 HTTP stores value-free audit results and rejects source assert.equal(rejected.statusCode, 400); const rejectedBody = JSON.parse(rejected.body) as Record; const rejectedWithoutCorrelation = { ...rejectedBody }; - delete rejectedWithoutCorrelation.correlationId; + delete rejectedWithoutCorrelation['correlationId']; assert.doesNotMatch(JSON.stringify(rejectedWithoutCorrelation), /SUM|42|sourceValue/iu); const created = await app.inject({ From 8f500399512bc294464199781654fdea29fe9fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:20:59 +0700 Subject: [PATCH 141/177] fix(engine): format spreadsheet auditor fixtures --- services/engine/tests/test_spreadsheet_auditor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/engine/tests/test_spreadsheet_auditor.py b/services/engine/tests/test_spreadsheet_auditor.py index ec562873..ffc8e7c9 100644 --- a/services/engine/tests/test_spreadsheet_auditor.py +++ b/services/engine/tests/test_spreadsheet_auditor.py @@ -48,7 +48,7 @@ def _workbook( ) sheet = ( b'' - b'' + sheet_rows + b'' + b"" + sheet_rows + b"" ) output = io.BytesIO() with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: From e6800dbab7e873eaf7b26dfe8282067eaa37dae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:32:05 +0700 Subject: [PATCH 142/177] fix(iam): compare bootstrap rows by owned fields --- ...a-identity-bootstrap-repository.adapter.ts | 20 +++++++++++++++++-- ...isma-identity-bootstrap-repository.test.ts | 9 ++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) 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 index 03c7d76f..6a35b8c3 100644 --- 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 @@ -88,6 +88,22 @@ interface MembershipDelegateV1 extends IdentityDelegateV1; } +function valuesEqual(left: unknown, right: unknown): boolean { + if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime(); + return left === right; +} + +function ownedFieldsMatch( + existing: TRow, + expected: TRow, +): boolean { + const existingRecord = existing as Record; + const expectedRecord = expected as Record; + return Object.keys(expectedRecord).every((key) => + valuesEqual(existingRecord[key], expectedRecord[key]), + ); +} + export interface IdentityBootstrapDatabaseClientV1 { readonly userIdentity: UserDelegateV1; readonly organizationIdentity: IdentityDelegateV1; @@ -237,7 +253,7 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran 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)) + if (!ownedFieldsMatch(userFromRow(userRow), bootstrap.user)) throw new Error('IAM_BOOTSTRAP_CONFLICT'); const organizationData: OrganizationIdentityDatabaseRowV1 = { id: bootstrap.organization.id, @@ -289,7 +305,7 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran ): Promise { const existing = await delegate.findUnique({ where: { id: expected.id } }); if (existing) { - if (JSON.stringify(existing) !== JSON.stringify(expected)) + if (!ownedFieldsMatch(existing, expected)) throw new Error('IAM_BOOTSTRAP_CONFLICT'); return; } 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 index ccdbe89b..6af14a07 100644 --- a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts +++ b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts @@ -162,13 +162,20 @@ void test('[IAM-001, IAM-009, IAM-011] Prisma bootstrap persists and reconstruct }); void test('[IAM-011] repeated bootstrap is immutable and conflicting hierarchy is rejected', async () => { - const { client } = createDatabase(); + const { client, organizations } = createDatabase(); const adapter = new PrismaIdentityBootstrapRepositoryAdapter(client); const validated = bootstrapPersonalOrganizationV1(input); assert.equal(validated.accepted, true); if (!validated.accepted) return; await adapter.save(validated.value); + const organization = organizations.get(organizationId); + assert.ok(organization); + organizations.set(organizationId, { + ...organization, + updatedAt: new Date('2026-01-01T00:00:01.000Z'), + createdAt: organization.createdAt, + } as typeof organization); await assert.doesNotReject(() => adapter.save(validated.value)); await assert.rejects( adapter.save({ From b7ee10abc89849b63b70aef196b47bd1f39789da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:33:45 +0700 Subject: [PATCH 143/177] perf(iam): bound membership reads to organization scope --- .../iam/adapter/prisma-iam-repository.adapter.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 index 995fe68c..7c163734 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -35,6 +35,7 @@ interface IamMembershipDelegateV1 { }): Promise; findMany(input: { readonly where: Readonly>; + readonly orderBy?: Readonly>; }): Promise; create(input: { readonly data: IamMembershipDatabaseRowV1 }): Promise; updateMany(input: { @@ -123,7 +124,14 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { context: IamTenantContextV1, principalId: StableIdentifierV1, ): Promise { - const rows = await this.client.membershipIdentity.findMany({ where: { principalId } }); + const rows = await this.client.membershipIdentity.findMany({ + where: { + organizationId: context.tenantScope.organizationId, + principalId, + status: 'ACTIVE', + }, + orderBy: { id: 'asc' }, + }); return rows .map(membershipFromRow) .find( @@ -137,7 +145,10 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { public async listMemberships( context: IamTenantContextV1, ): Promise { - const rows = await this.client.membershipIdentity.findMany({ where: {} }); + const rows = await this.client.membershipIdentity.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); return rows .map(membershipFromRow) .filter((membership) => visibleInScope(context.tenantScope, membership.scope)); From 26ff403e453a6dc1121ddc8f09ed755d7a9a9b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:35:33 +0700 Subject: [PATCH 144/177] fix(iam): make membership selection deterministic --- .../prisma-credential-lookup.adapter.ts | 10 +++++- .../iam/prisma-credential-lookup.test.ts | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) 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 index 4830cfc5..bdae6fcc 100644 --- a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts @@ -52,6 +52,7 @@ interface UniqueDelegateV1 { interface WorkspaceLookupDelegateV1 extends UniqueDelegateV1 { readonly findMany?: (input: { readonly where: Readonly>; + readonly orderBy?: Readonly>; }) => Promise; } @@ -140,7 +141,13 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { const selected = memberships .map((membership) => activeMembership(membership, userId)) - .find((membership): membership is ActiveMembershipV1 => membership !== undefined); + .filter((membership): membership is ActiveMembershipV1 => membership !== undefined) + .sort((left, right) => + `${left.organizationId}:${left.workspaceId ?? ''}`.localeCompare( + `${right.organizationId}:${right.workspaceId ?? ''}`, + ), + ) + .at(0); if (!selected) return undefined; const [organization, factors] = await Promise.all([ @@ -152,6 +159,7 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { if (!this.client.workspaceIdentity.findMany) return undefined; const workspaces = await this.client.workspaceIdentity.findMany({ where: { organizationId: selected.organizationId, status: 'ACTIVE' }, + orderBy: { id: 'asc' }, }); const workspace = workspaces.find( (candidate) => diff --git a/services/api/test/features/iam/prisma-credential-lookup.test.ts b/services/api/test/features/iam/prisma-credential-lookup.test.ts index 2636c30a..c82b5805 100644 --- a/services/api/test/features/iam/prisma-credential-lookup.test.ts +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -141,3 +141,36 @@ void test('[IAM-001, IAM-009] an organization owner resolves the canonical activ assert.equal(result?.principal.organizationId, organizationId); assert.equal(result?.principal.workspaceId, workspaceId); }); + +void test('[IAM-002] workspace membership selection is deterministic', async () => { + const secondWorkspaceId = '00000000-0000-4000-8000-000000000007'; + const adapter = new PrismaCredentialLookupAdapter( + database({ + membershipIdentity: { + findMany: async () => [ + { + id: '00000000-0000-4000-8000-000000000008', + principalId: userId, + organizationId, + workspaceId: secondWorkspaceId, + projectId: null, + scopeType: 'WORKSPACE', + status: 'ACTIVE', + }, + { + id: membershipId, + principalId: userId, + organizationId, + workspaceId, + projectId: null, + scopeType: 'WORKSPACE', + status: 'ACTIVE', + }, + ], + }, + }), + ); + + const result = await adapter.findCredential('user@example.com'); + assert.equal(result?.principal.workspaceId, workspaceId); +}); From 587eebeead46efa7b2290ecfd99e45aa711dd869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:36:14 +0700 Subject: [PATCH 145/177] fix(iam): expire in-memory access tokens --- .../adapter/in-memory-session-lifecycle.adapter.ts | 8 +++++++- .../api/test/features/iam/session-lifecycle.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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 d70f8378..1030359a 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 @@ -201,7 +201,13 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { 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); + if (sessionId === undefined) return undefined; + const session = this.sessions.get(sessionId); + if (!session || Date.parse(session.record.accessExpiresAt) <= this.clock().getTime()) { + this.accessTokens.delete(digestToken(accessTokenInput)); + return undefined; + } + return this.findPrincipal(sessionId); } private revokeFamily(familyId: StableIdentifierV1): void { diff --git a/services/api/test/features/iam/session-lifecycle.test.ts b/services/api/test/features/iam/session-lifecycle.test.ts index 396edc60..d6dcccbc 100644 --- a/services/api/test/features/iam/session-lifecycle.test.ts +++ b/services/api/test/features/iam/session-lifecycle.test.ts @@ -53,3 +53,15 @@ void test('[IAM-005] expired and malformed refresh tokens fail without token dis code: 'INVALID_REFRESH_TOKEN', }); }); + +void test('[IAM-005] access-token lookup fails closed at expiry', async () => { + let now = new Date('2026-01-01T00:00:00.000Z'); + const adapter = new InMemorySessionLifecycleAdapter({ clock: () => new Date(now) }); + const session = await adapter.issue(principal, 'web'); + assert.equal( + (await adapter.findPrincipalByAccessToken(session.accessToken))?.userId, + principal.userId, + ); + now = new Date('2026-01-01T00:15:00.000Z'); + assert.equal(await adapter.findPrincipalByAccessToken(session.accessToken), undefined); +}); From e668bd4b34e8438a2c3bec8146d17cf1486319e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:41:11 +0700 Subject: [PATCH 146/177] fix(iam): compare-and-set MFA revisions --- .../adapter/prisma-mfa-repository.adapter.ts | 18 ++++-- .../iam/prisma-mfa-repository.test.ts | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) 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 index 748723d9..4af5d094 100644 --- a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts @@ -50,6 +50,10 @@ interface MfaFactorDelegateV1 { readonly where: { readonly id: string }; readonly data: Partial; }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Partial; + }): Promise<{ readonly count: number }>; } interface MfaRecoveryCodeDelegateV1 { @@ -66,6 +70,10 @@ interface MfaRecoveryCodeDelegateV1 { readonly where: { readonly id: string }; readonly data: Partial; }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Partial; + }): Promise<{ readonly count: number }>; } export interface MfaDatabaseClientV1 { @@ -244,8 +252,8 @@ class PrismaMfaTransactionAdapter implements MfaTransactionPortV1 { continue; } if (JSON.stringify(prior) === JSON.stringify(factor)) continue; - await this.client.mfaFactor.update({ - where: { id: factor.id }, + const updated = await this.client.mfaFactor.updateMany({ + where: { id: factor.id, revision: prior.revision }, data: { status: factor.status, verifiedAt: factor.verifiedAt ? new Date(factor.verifiedAt) : null, @@ -253,6 +261,7 @@ class PrismaMfaTransactionAdapter implements MfaTransactionPortV1 { revision: factor.revision, }, }); + if (updated.count !== 1) throw new Error('IAM_MFA_REVISION_CONFLICT'); } for (const code of state.recoveryCodes) { const prior = existing.recoveryCodes.find((candidate) => candidate.id === code.id); @@ -261,14 +270,15 @@ class PrismaMfaTransactionAdapter implements MfaTransactionPortV1 { continue; } if (JSON.stringify(prior) === JSON.stringify(code)) continue; - await this.client.mfaRecoveryCode.update({ - where: { id: code.id }, + const updated = await this.client.mfaRecoveryCode.updateMany({ + where: { id: code.id, revision: prior.revision }, data: { status: code.status, usedAt: code.usedAt ? new Date(code.usedAt) : null, revision: code.revision, }, }); + if (updated.count !== 1) throw new Error('IAM_MFA_REVISION_CONFLICT'); } } } diff --git a/services/api/test/features/iam/prisma-mfa-repository.test.ts b/services/api/test/features/iam/prisma-mfa-repository.test.ts index 9d9994f0..daa55d3b 100644 --- a/services/api/test/features/iam/prisma-mfa-repository.test.ts +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -55,6 +55,19 @@ function createDatabase(): { factors.set(where.id, updated); return updated; }, + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Partial; + }) => { + const current = factors.get(String(where['id'])); + if (!current || (where['revision'] !== undefined && current.revision !== where['revision'])) + return { count: 0 }; + factors.set(current.id, { ...current, ...data }); + return { count: 1 }; + }, }, mfaRecoveryCode: { findMany: async ({ where }: { readonly where: Readonly> }) => @@ -82,6 +95,19 @@ function createDatabase(): { recoveryCodes.set(where.id, updated); return updated; }, + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Partial; + }) => { + const current = recoveryCodes.get(String(where['id'])); + if (!current || (where['revision'] !== undefined && current.revision !== where['revision'])) + return { count: 0 }; + recoveryCodes.set(current.id, { ...current, ...data }); + return { count: 1 }; + }, }, $transaction: async (work: (transaction: MfaDatabaseClientV1) => Promise) => { const beforeFactors = new Map(factors); @@ -200,3 +226,32 @@ void test('[IAM-012, IAM-014] Prisma MFA persistence rejects a changed stale rev /IAM_MFA_REVISION_CONFLICT/u, ); }); + +void test('[IAM-012, IAM-016] Prisma MFA persistence rejects a redemption race with compare-and-set', async () => { + const { client } = createDatabase(); + const adapter = new PrismaMfaRepositoryAdapter(client); + const input = state(); + const factor = input.factors[0]; + const code = input.recoveryCodes[0]; + if (!factor || !code) throw new Error('fixture missing MFA state'); + await adapter.saveState(factor.userId, input); + const recoveryDelegate = client.mfaRecoveryCode as MfaDatabaseClientV1['mfaRecoveryCode'] & { + updateMany: MfaDatabaseClientV1['mfaRecoveryCode']['updateMany']; + }; + recoveryDelegate.updateMany = async () => ({ count: 0 }); + const racedAdapter = new PrismaMfaRepositoryAdapter(client); + await assert.rejects( + racedAdapter.saveState(factor.userId, { + factors: input.factors, + recoveryCodes: [ + { + ...code, + status: 'USED', + usedAt: '2026-01-01T00:01:00.000Z' as typeof code.createdAt, + revision: 2, + }, + ], + }), + /IAM_MFA_REVISION_CONFLICT/, + ); +}); From 6d26ec7a63ce4cf2157a68bee2233d44fe10dfd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:43:05 +0700 Subject: [PATCH 147/177] fix(iam): use constant-time recovery matching --- services/api/src/features/iam/iam.module.ts | 21 ++++++++++++------- .../api/test/features/iam/mfa.service.test.ts | 7 +++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 6bf6257e..fac825fc 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from 'node:crypto'; import { type DynamicModule, Module } from '@nestjs/common'; import { AuthenticationController } from './api/authentication.controller.js'; @@ -94,6 +95,17 @@ export interface IamModuleOptions { readonly requestTenantContext?: RequestTenantContextPortV1; } +/** Compare already-normalized recovery-code digests without data-dependent byte comparisons. */ +export function constantTimeRecoveryCodeMatchV1( + presentedDigest: string, + storedDigest: string, +): boolean { + const presented = Buffer.from(presentedDigest, 'utf8'); + const stored = Buffer.from(storedDigest, 'utf8'); + if (presented.length !== stored.length) return false; + return timingSafeEqual(presented, stored); +} + export function composeAuthenticationUseCase(options: IamModuleOptions): AuthenticationUseCaseV1 { if (options.authentication) return options.authentication; if (options.credentials && options.passwordCredentials && options.sessions) { @@ -136,14 +148,7 @@ export class IamModule { : 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; - }, + matches: constantTimeRecoveryCodeMatchV1, }, )); const iamRepository = diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts index 6bb92c05..97e5213d 100644 --- a/services/api/test/features/iam/mfa.service.test.ts +++ b/services/api/test/features/iam/mfa.service.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import { createRecoveryCodeV1 } from '@databreeze/domain/mfa/v1'; import { InMemoryMfaRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-mfa-repository.adapter.js'; +import { constantTimeRecoveryCodeMatchV1 } from '../../../src/features/iam/iam.module.js'; import { MfaService } from '../../../src/features/iam/application/mfa.service.js'; const userId = '00000000-0000-4000-8000-000000000001'; @@ -70,3 +71,9 @@ void test('[IAM-012] high-risk operations require a fresh step-up assertion', () true, ); }); + +void test('[IAM-015] default recovery-code matching compares normalized bytes safely', () => { + assert.equal(constantTimeRecoveryCodeMatchV1('digest-1', 'digest-1'), true); + assert.equal(constantTimeRecoveryCodeMatchV1('digest-1', 'digest-2'), false); + assert.equal(constantTimeRecoveryCodeMatchV1('digest-1', 'digest-10'), false); +}); From a62e515621cc4714dd29f49b5aaa3dcfeb0059b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:46:15 +0700 Subject: [PATCH 148/177] fix(iam): map session persistence failures to unavailable --- .../iam/api/authentication.controller.ts | 13 +++++- services/api/test/http-contract.test.ts | 41 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 594f5d44..3b94c7fc 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -133,7 +133,12 @@ export class AuthenticationController { ) { throw new SessionProblemError('SESSION_INVALID'); } - const result = await this.sessions.refresh(refreshToken, input.clientPlatform); + let result: Awaited>; + try { + result = await this.sessions.refresh(refreshToken, input.clientPlatform); + } catch { + throw new SessionProblemError('SESSION_UNAVAILABLE'); + } if (!result.accepted) throw new SessionProblemError('SESSION_INVALID'); if (input.clientPlatform === 'web') { const csrfToken = randomBytes(32).toString('base64url'); @@ -167,7 +172,11 @@ export class AuthenticationController { @Res({ passthrough: true }) reply: FastifyReply, ): Promise { if (this.sessions === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); - await this.sessions.revoke(input.sessionId); + try { + await this.sessions.revoke(input.sessionId); + } catch { + throw new SessionProblemError('SESSION_UNAVAILABLE'); + } if (input.clientPlatform === 'web') { reply.header('Set-Cookie', [ clearCookieV1(REFRESH_COOKIE_NAME_V1, { httpOnly: true }), diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index c2e39d03..b04ed4b4 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -439,6 +439,25 @@ void test('refresh rotates Web cookies without returning the refresh token and p assert.doesNotMatch(response.body, /REUSE_DETECTED/); }, ); + + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('database unavailable')), + 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: 'database-refresh-token' }, + }); + assertProblem(response, 503, 'SESSION_UNAVAILABLE'); + }, + ); }); void test('sign-out revokes idempotently and clears browser credentials', async () => { @@ -495,6 +514,28 @@ void test('sign-out revokes idempotently and clears browser credentials', async ]); }, ); + + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: () => Promise.reject(new Error('database unavailable')), + findPrincipal: () => Promise.resolve(undefined), + }, + }, + async (app) => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/sign-out', + payload: { + clientPlatform: 'android', + sessionId: '00000000-0000-4000-8000-000000000011', + }, + }); + assertProblem(response, 503, 'SESSION_UNAVAILABLE'); + }, + ); }); void test('protected artifact reads derive tenant scope from an authenticated access token', async () => { From 8ea5ec9099bf3e194a7e7637d77853d6b55cd5fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:48:07 +0700 Subject: [PATCH 149/177] fix(http): require production CSRF origins --- .../api/src/platform/http/request-context.ts | 22 +++++++++++++++++++ .../platform/http/csrf-protection.test.ts | 20 +++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/services/api/src/platform/http/request-context.ts b/services/api/src/platform/http/request-context.ts index 3b9068d2..20f5f512 100644 --- a/services/api/src/platform/http/request-context.ts +++ b/services/api/src/platform/http/request-context.ts @@ -25,6 +25,27 @@ export interface RequestContextOptions { readonly csrf?: Partial; } +/** Production must explicitly declare browser origins; development defaults are not deployable. */ +export function validateRequestContextOptionsV1( + options: RequestContextOptions = {}, + environment = process.env['NODE_ENV'], +): void { + if (environment !== 'production') return; + const origins = options.csrf?.allowedOrigins; + if (!origins || origins.length === 0) throw new Error('CSRF_ALLOWED_ORIGINS_REQUIRED'); + if ( + origins.some((origin) => { + try { + const parsed = new URL(origin); + return parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== ''; + } catch { + return true; + } + }) + ) + throw new Error('CSRF_ALLOWED_ORIGINS_INVALID'); +} + export function parseCorrelationHeader( values: readonly string[], requestId: string, @@ -48,6 +69,7 @@ export function installRequestContext( fastify: FastifyInstance, options: RequestContextOptions = {}, ): void { + validateRequestContextOptionsV1(options); fastify.addHook('onRequest', (request, reply, done) => { const requestId = randomUUID(); const context: RequestContext = { correlationId: requestId, requestId }; diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts index ebf3cd85..37adeaea 100644 --- a/services/api/test/platform/http/csrf-protection.test.ts +++ b/services/api/test/platform/http/csrf-protection.test.ts @@ -1,12 +1,32 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { validateRequestContextOptionsV1 } from '../../../src/platform/http/request-context.js'; + import { evaluateCsrfRequestV1 } from '../../../src/platform/http/csrf-protection.js'; const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; const allowedOrigins = ['https://app.databreeze.example']; +void test('production request context requires explicit HTTPS browser origins', () => { + assert.throws( + () => validateRequestContextOptionsV1({}, 'production'), + /CSRF_ALLOWED_ORIGINS_REQUIRED/, + ); + assert.throws( + () => + validateRequestContextOptionsV1( + { csrf: { allowedOrigins: ['http://localhost:3000'] } }, + 'production', + ), + /CSRF_ALLOWED_ORIGINS_INVALID/, + ); + assert.doesNotThrow(() => + validateRequestContextOptionsV1({ csrf: { allowedOrigins } }, 'production'), + ); +}); + void test('allows safe methods and non-cookie clients without a CSRF token', () => { assert.deepEqual(evaluateCsrfRequestV1({ method: 'GET', headers: {} }, { allowedOrigins }), { accepted: true, From 8874b36d9719c281fcc13890cd62cef5dcdd0b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:50:09 +0700 Subject: [PATCH 150/177] perf(aud): scope chain reads to the target ledger --- .../in-memory-audit-repository.adapter.ts | 17 +++++++++++ .../prisma-audit-repository.adapter.ts | 26 +++++++++++++++++ .../aud/application/audit-ledger.service.ts | 4 +-- .../aud/application/audit-repository.port.ts | 5 ++++ .../features/aud/audit-ledger.service.test.ts | 28 +++++++++++++++++++ 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts index 2c02f27b..b3c1a177 100644 --- a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts @@ -76,6 +76,22 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { .map(cloneEvent); } + async listEventsForScope( + context: IamTenantContextV1, + scope: TenantScopeV1, + ): Promise { + await Promise.resolve(); + if (!scopeAllowsMutation(context, scope)) throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); + return [...this.events.values()] + .filter( + (event) => + tenantScopeContainsV1(event.tenantScope, scope) && + tenantScopeContainsV1(scope, event.tenantScope), + ) + .sort((left, right) => left.sequence - right.sequence) + .map(cloneEvent); + } + async saveSeal(context: IamTenantContextV1, seal: AuditSealV1): Promise { await Promise.resolve(); if (!scopeAllowsMutation(context, seal.tenantScope)) @@ -116,6 +132,7 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { return await work({ appendEvent: this.appendEvent.bind(this), listEvents: this.listEvents.bind(this), + listEventsForScope: this.listEventsForScope.bind(this), saveSeal: this.saveSeal.bind(this), listSeals: this.listSeals.bind(this), }); 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 index 202c5b25..a08d0b8f 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -317,6 +317,22 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { return events; } + public async listEventsForScope( + context: IamTenantContextV1, + scope: TenantScopeV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, scope)) + throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); + const rows = await this.client.auditEventRecord.findMany({ + where: { scopeKey: scopeKey(scope) }, + orderBy: { sequence: 'asc' }, + }); + const events = rows.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'); @@ -372,6 +388,16 @@ export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 { return new PrismaAuditTransactionAdapter(this.client, this.digestPort).listEvents(context); } + public listEventsForScope( + context: IamTenantContextV1, + scope: TenantScopeV1, + ): Promise { + return new PrismaAuditTransactionAdapter(this.client, this.digestPort).listEventsForScope( + context, + scope, + ); + } + public saveSeal(context: IamTenantContextV1, seal: AuditSealV1): Promise { return new PrismaAuditTransactionAdapter(this.client, this.digestPort).saveSeal(context, seal); } diff --git a/services/api/src/features/aud/application/audit-ledger.service.ts b/services/api/src/features/aud/application/audit-ledger.service.ts index ab74a29c..1db6be63 100644 --- a/services/api/src/features/aud/application/audit-ledger.service.ts +++ b/services/api/src/features/aud/application/audit-ledger.service.ts @@ -33,7 +33,7 @@ export class AuditLedgerService { input: AuditLedgerInputV1, ): Promise> { return this.repository.withTransaction(context, async (transaction) => { - const existing = await transaction.listEvents(context); + const existing = await transaction.listEventsForScope(context, context.tenantScope); const appended = appendAuditEventV1( { events: existing }, { @@ -62,7 +62,7 @@ export class AuditLedgerService { sealedAt: unknown, ): Promise> { return this.repository.withTransaction(context, async (transaction) => { - const events = await transaction.listEvents(context); + const events = await transaction.listEventsForScope(context, context.tenantScope); const created = createAuditSealV1(events, context.tenantScope, sealedAt, this.digestPort); if (!created.accepted) return created; await transaction.saveSeal(context, created.value); diff --git a/services/api/src/features/aud/application/audit-repository.port.ts b/services/api/src/features/aud/application/audit-repository.port.ts index 3554d55c..b513ed64 100644 --- a/services/api/src/features/aud/application/audit-repository.port.ts +++ b/services/api/src/features/aud/application/audit-repository.port.ts @@ -1,4 +1,5 @@ import type { AuditEventV1, AuditSealV1 } from '@databreeze/domain/audit/v1'; +import type { TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; @@ -7,6 +8,10 @@ export const AUDIT_REPOSITORY_PORT = Symbol('AUDIT_REPOSITORY_PORT'); export interface AuditTransactionPortV1 { appendEvent(context: IamTenantContextV1, event: AuditEventV1): Promise; listEvents(context: IamTenantContextV1): Promise; + listEventsForScope( + context: IamTenantContextV1, + scope: TenantScopeV1, + ): Promise; saveSeal(context: IamTenantContextV1, seal: AuditSealV1): Promise; listSeals(context: IamTenantContextV1): Promise; } diff --git a/services/api/test/features/aud/audit-ledger.service.test.ts b/services/api/test/features/aud/audit-ledger.service.test.ts index f48329f8..95c989ff 100644 --- a/services/api/test/features/aud/audit-ledger.service.test.ts +++ b/services/api/test/features/aud/audit-ledger.service.test.ts @@ -39,6 +39,12 @@ function input(eventId: string) { void test('[AUD-001, AUD-003, AUD-005, IAM-009] service binds audit identity to the authorized context', async () => { const repository = new InMemoryAuditRepositoryAdapter(); + let broadReads = 0; + const broadList = repository.listEvents.bind(repository); + repository.listEvents = async (...args) => { + broadReads += 1; + return broadList(...args); + }; const service = new AuditLedgerService(repository, { digest: (value) => createHash('sha256').update(value).digest('base64url'), }); @@ -61,6 +67,28 @@ void test('[AUD-001, AUD-003, AUD-005, IAM-009] service binds audit identity to ); assert.deepEqual(repeated, first); assert.equal((await repository.listEvents(context('read'))).length, 1); + assert.equal(broadReads, 1); +}); + +void test('[AUD-001, AUD-015] append and seal use the exact scope chain instead of loading visible tenant descendants', async () => { + const repository = new InMemoryAuditRepositoryAdapter(); + let scopedReads = 0; + const scopedList = repository.listEventsForScope.bind(repository); + repository.listEventsForScope = async (...args) => { + scopedReads += 1; + return scopedList(...args); + }; + const service = new AuditLedgerService(repository, { + digest: (value) => createHash('sha256').update(value).digest('base64url'), + }); + const appended = await service.append( + context('scoped-1'), + input('00000000-0000-4000-8000-000000000024'), + ); + assert.equal(appended.accepted, true); + const sealed = await service.seal(context('scoped-seal'), '2026-01-01T00:05:00.000Z'); + assert.equal(sealed.accepted, true); + assert.equal(scopedReads, 2); }); void test('[AUD-003, AUD-005] invalid actor and unsafe summary fail before persistence', async () => { From 463abbb393eaa35be68dce47482a284ce19313ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:52:44 +0700 Subject: [PATCH 151/177] fix(aud): compare immutable records by owned fields --- .../in-memory-audit-repository.adapter.ts | 7 +-- .../prisma-audit-repository.adapter.ts | 6 +- .../aud/application/audit-equality.ts | 56 +++++++++++++++++++ .../features/aud/audit-repository.test.ts | 9 ++- 4 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 services/api/src/features/aud/application/audit-equality.ts diff --git a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts index b3c1a177..ed1584fa 100644 --- a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts @@ -9,6 +9,7 @@ import type { AuditRepositoryPortV1, AuditTransactionPortV1, } from '../application/audit-repository.port.js'; +import { sameAuditEventV1, sameAuditSealV1 } from '../application/audit-equality.js'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; function visibleInScope(context: TenantScopeV1, record: TenantScopeV1): boolean { @@ -44,8 +45,7 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); const existing = this.events.get(event.eventId); if (existing) { - if (JSON.stringify(existing) !== JSON.stringify(event)) - throw new Error('AUD_IMMUTABLE_EVENT'); + if (!sameAuditEventV1(existing, event)) throw new Error('AUD_IMMUTABLE_EVENT'); return cloneEvent(existing); } const scopedEvents = [...this.events.values()] @@ -103,8 +103,7 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { tenantScopeContainsV1(item.tenantScope, seal.tenantScope) && tenantScopeContainsV1(seal.tenantScope, item.tenantScope), ); - if (existing && JSON.stringify(existing) !== JSON.stringify(seal)) - throw new Error('AUD_IMMUTABLE_SEAL'); + if (existing && !sameAuditSealV1(existing, seal)) throw new Error('AUD_IMMUTABLE_SEAL'); this.seals.set(seal.rootDigest, cloneSeal(seal)); } 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 index a08d0b8f..5cec0777 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -21,6 +21,7 @@ import type { AuditRepositoryPortV1, AuditTransactionPortV1, } from '../application/audit-repository.port.js'; +import { sameAuditEventV1, sameAuditSealV1 } from '../application/audit-equality.js'; export interface AuditEventDatabaseRowV1 { readonly id: string; @@ -284,7 +285,7 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { }); if (existing !== null) { const current = persistedEvent(existing); - if (JSON.stringify(current) !== JSON.stringify(event)) throw new Error('AUD_IMMUTABLE_EVENT'); + if (!sameAuditEventV1(current, event)) throw new Error('AUD_IMMUTABLE_EVENT'); return current; } const siblings = await this.client.auditEventRecord.findMany({ @@ -344,8 +345,7 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { }, }); if (existing !== null) { - if (JSON.stringify(persistedSeal(existing)) !== JSON.stringify(seal)) - throw new Error('AUD_IMMUTABLE_SEAL'); + if (!sameAuditSealV1(persistedSeal(existing), seal)) throw new Error('AUD_IMMUTABLE_SEAL'); return; } await this.client.auditSealRecord.create({ data: sealCreateData(seal) }); diff --git a/services/api/src/features/aud/application/audit-equality.ts b/services/api/src/features/aud/application/audit-equality.ts new file mode 100644 index 00000000..a2429f6a --- /dev/null +++ b/services/api/src/features/aud/application/audit-equality.ts @@ -0,0 +1,56 @@ +import type { AuditEventV1, AuditSealV1, AuditSummaryV1 } from '@databreeze/domain/audit/v1'; +import type { TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; + +function sameScope(left: TenantScopeV1, right: TenantScopeV1): boolean { + return ( + left.scopeType === right.scopeType && + left.organizationId === right.organizationId && + ('workspaceId' in left ? left.workspaceId : undefined) === + ('workspaceId' in right ? right.workspaceId : undefined) && + ('projectId' in left ? left.projectId : undefined) === + ('projectId' in right ? right.projectId : undefined) + ); +} + +function sameSummary(left: AuditSummaryV1, right: AuditSummaryV1): boolean { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every((key, index) => { + const rightKey = rightKeys[index]; + return rightKey === key && left[key] === right[key]; + }); +} + +export function sameAuditEventV1(left: AuditEventV1, right: AuditEventV1): boolean { + return ( + left.schemaVersion === right.schemaVersion && + left.eventId === right.eventId && + left.action === right.action && + sameScope(left.tenantScope, right.tenantScope) && + left.actor.actorType === right.actor.actorType && + left.actor.actorId === right.actor.actorId && + left.entityType === right.entityType && + left.entityId === right.entityId && + left.entityRevision === right.entityRevision && + left.sequence === right.sequence && + left.occurredAt === right.occurredAt && + left.correlationId === right.correlationId && + left.idempotencyKey === right.idempotencyKey && + sameSummary(left.summary, right.summary) && + left.previousDigest === right.previousDigest && + left.digest === right.digest + ); +} + +export function sameAuditSealV1(left: AuditSealV1, right: AuditSealV1): boolean { + return ( + left.schemaVersion === right.schemaVersion && + sameScope(left.tenantScope, right.tenantScope) && + left.firstSequence === right.firstSequence && + left.lastSequence === right.lastSequence && + left.eventCount === right.eventCount && + left.rootDigest === right.rootDigest && + left.sealedAt === right.sealedAt + ); +} diff --git a/services/api/test/features/aud/audit-repository.test.ts b/services/api/test/features/aud/audit-repository.test.ts index 3faed9ee..02d87cce 100644 --- a/services/api/test/features/aud/audit-repository.test.ts +++ b/services/api/test/features/aud/audit-repository.test.ts @@ -48,7 +48,7 @@ function event(eventId: string, idempotencyKey: string, workspace = workspaceId) occurredAt: '2026-01-01T00:00:00.000Z', correlationId, idempotencyKey, - summary: { outcome: 'accepted' }, + summary: { status: 'accepted', outcome: 'accepted' }, }, digestPort, ); @@ -62,6 +62,13 @@ void test('[AUD-001, AUD-004, AUD-006, IAM-009] audit events are append-only and const stored = event('00000000-0000-4000-8000-000000000021', 'invite-1'); await repository.appendEvent(context(workspaceId), stored); assert.deepEqual(await repository.listEvents(context(workspaceId)), [stored]); + assert.deepEqual( + await repository.appendEvent(context(workspaceId), { + ...stored, + summary: { outcome: 'accepted', status: 'accepted' }, + }), + stored, + ); assert.deepEqual(await repository.listEvents(context(siblingWorkspaceId)), []); assert.deepEqual(await repository.appendEvent(context(workspaceId), stored), stored); await assert.rejects( From eb020778aa7ed4794217ef940cc95ff7e15e80f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:42:47 +0700 Subject: [PATCH 152/177] fix(bua): compare immutable records by owned fields --- ...n-memory-entitlement-repository.adapter.ts | 23 ++-- .../prisma-entitlement-repository.adapter.ts | 23 ++-- .../bua/application/entitlement-equality.ts | 116 ++++++++++++++++++ .../bua/entitlement-repository.test.ts | 12 ++ 4 files changed, 152 insertions(+), 22 deletions(-) create mode 100644 services/api/src/features/bua/application/entitlement-equality.ts diff --git a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts index f52acfca..402dbdf2 100644 --- a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts @@ -13,6 +13,13 @@ import type { EntitlementTransactionPortV1, } from '../application/entitlement-repository.port.js'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import { + sameEntitlementPlanV1, + sameEntitlementSnapshotV1, + sameUsageEntryV1, + sameUsageReservationExceptStatusV1, + sameUsageReservationV1, +} from '../application/entitlement-equality.js'; function visibleInScope(context: TenantScopeV1, record: TenantScopeV1): boolean { return tenantScopeContainsV1(context, record) || tenantScopeContainsV1(record, context); @@ -67,11 +74,7 @@ function cloneState(state: UsageLedgerStateV1): UsageLedgerStateV1 { } function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReservationV1): boolean { - return ( - left.reservationId === right.reservationId && - JSON.stringify({ ...left, status: undefined, revision: undefined }) === - JSON.stringify({ ...right, status: undefined, revision: undefined }) - ); + return sameUsageReservationExceptStatusV1(left, right); } /** In-memory adapter with append-only usage and immutable plan/snapshot semantics. */ @@ -85,8 +88,7 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito async savePlan(plan: EntitlementPlanV1): Promise { await Promise.resolve(); const existing = this.plans.get(plan.planCode); - if (existing && JSON.stringify(existing) !== JSON.stringify(plan)) - throw new Error('BUA_IMMUTABLE_PLAN'); + if (existing && !sameEntitlementPlanV1(existing, plan)) throw new Error('BUA_IMMUTABLE_PLAN'); this.plans.set(plan.planCode, clonePlan(plan)); } @@ -101,7 +103,7 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito if (!scopeAllowsMutation(context, snapshotScope(snapshot))) throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); const existing = this.snapshots.get(snapshot.snapshotId); - if (existing && JSON.stringify(existing) !== JSON.stringify(snapshot)) + if (existing && !sameEntitlementSnapshotV1(existing, snapshot)) throw new Error('BUA_IMMUTABLE_SNAPSHOT'); this.snapshots.set(snapshot.snapshotId, cloneSnapshot(snapshot)); } @@ -134,8 +136,7 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito for (const entry of state.entries) { const existing = this.entries.get(entry.entryId); if (existing) { - if (JSON.stringify(existing) !== JSON.stringify(entry)) - throw new Error('BUA_IMMUTABLE_USAGE_ENTRY'); + if (!sameUsageEntryV1(existing, entry)) throw new Error('BUA_IMMUTABLE_USAGE_ENTRY'); continue; } if (!scopeAllowsMutation(context, entry.tenantScope)) @@ -160,7 +161,7 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito this.reservations.set(reservation.reservationId, cloneReservation(reservation)); continue; } - if (JSON.stringify(existing) === JSON.stringify(reservation)) continue; + if (sameUsageReservationV1(existing, reservation)) continue; if ( existing.revision + 1 !== reservation.revision || !sameReservationExceptStatus(existing, reservation) 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 index b73bc1b5..f335fa66 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -22,6 +22,13 @@ import type { EntitlementRepositoryPortV1, EntitlementTransactionPortV1, } from '../application/entitlement-repository.port.js'; +import { + sameEntitlementPlanV1, + sameEntitlementSnapshotV1, + sameUsageEntryV1, + sameUsageReservationExceptStatusV1, + sameUsageReservationV1, +} from '../application/entitlement-equality.js'; const planCodes = new Set(['free', 'development', 'admin_granted']); const statuses = new Set(['ACTIVE', 'SUSPENDED', 'EXPIRED']); @@ -427,13 +434,7 @@ function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { } 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 - ); + return sameUsageReservationExceptStatusV1(left, right); } class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV1 { @@ -444,7 +445,7 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV where: { planCode: plan.planCode }, }); if (existing !== null) { - if (JSON.stringify(persistedPlan(existing)) !== JSON.stringify(plan)) + if (!sameEntitlementPlanV1(persistedPlan(existing), plan)) throw new Error('BUA_IMMUTABLE_PLAN'); return; } @@ -475,7 +476,7 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV where: { id: snapshot.snapshotId }, }); if (existing !== null) { - if (JSON.stringify(persistedSnapshot(existing)) !== JSON.stringify(snapshot)) + if (!sameEntitlementSnapshotV1(persistedSnapshot(existing), snapshot)) throw new Error('BUA_IMMUTABLE_SNAPSHOT'); return; } @@ -541,7 +542,7 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV where: { id: entry.entryId }, }); if (existing !== null) { - if (JSON.stringify(persistedEntry(existing)) !== JSON.stringify(entry)) + if (!sameUsageEntryV1(persistedEntry(existing), entry)) throw new Error('BUA_IMMUTABLE_USAGE_ENTRY'); continue; } @@ -560,7 +561,7 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV continue; } const current = persistedReservation(existing); - if (JSON.stringify(current) === JSON.stringify(reservation)) continue; + if (sameUsageReservationV1(current, reservation)) continue; if ( !sameReservationExceptStatus(current, reservation) || reservation.revision !== current.revision + 1 diff --git a/services/api/src/features/bua/application/entitlement-equality.ts b/services/api/src/features/bua/application/entitlement-equality.ts new file mode 100644 index 00000000..d77e472e --- /dev/null +++ b/services/api/src/features/bua/application/entitlement-equality.ts @@ -0,0 +1,116 @@ +import type { + EntitlementPlanV1, + EntitlementQuotaV1, + EntitlementSnapshotV1, + UsageLedgerEntryV1, + UsageReservationV1, +} from '@databreeze/domain/entitlements/v1'; +import type { TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; + +function sameScope(left: TenantScopeV1, right: TenantScopeV1): boolean { + return ( + left.scopeType === right.scopeType && + left.organizationId === right.organizationId && + ('workspaceId' in left ? left.workspaceId : undefined) === + ('workspaceId' in right ? right.workspaceId : undefined) && + ('projectId' in left ? left.projectId : undefined) === + ('projectId' in right ? right.projectId : undefined) + ); +} + +function sameQuotas( + left: readonly EntitlementQuotaV1[], + right: readonly EntitlementQuotaV1[], +): boolean { + if (left.length !== right.length) return false; + const normalize = (quotas: readonly EntitlementQuotaV1[]) => + [...quotas].sort((a, b) => a.metric.localeCompare(b.metric)); + const normalizedLeft = normalize(left); + const normalizedRight = normalize(right); + return normalizedLeft.every( + (quota, index) => + quota.metric === normalizedRight[index]?.metric && + quota.limit === normalizedRight[index]?.limit, + ); +} + +function sameFeatures(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) return false; + const normalizedLeft = [...left].sort(); + const normalizedRight = [...right].sort(); + return normalizedLeft.every((feature, index) => feature === normalizedRight[index]); +} + +export function sameEntitlementPlanV1(left: EntitlementPlanV1, right: EntitlementPlanV1): boolean { + return ( + left.schemaVersion === right.schemaVersion && + left.planCode === right.planCode && + left.displayNameKey === right.displayNameKey && + left.providerIndependent === right.providerIndependent && + sameFeatures(left.features, right.features) && + sameQuotas(left.quotas, right.quotas) + ); +} + +export function sameEntitlementSnapshotV1( + left: EntitlementSnapshotV1, + right: EntitlementSnapshotV1, +): boolean { + return ( + left.schemaVersion === right.schemaVersion && + left.snapshotId === right.snapshotId && + left.organizationId === right.organizationId && + left.workspaceId === right.workspaceId && + left.planCode === right.planCode && + left.status === right.status && + left.revision === right.revision && + left.securityEpoch === right.securityEpoch && + left.effectiveAt === right.effectiveAt && + left.expiresAt === right.expiresAt && + sameFeatures(left.features, right.features) && + sameQuotas(left.quotas, right.quotas) + ); +} + +export function sameUsageEntryV1(left: UsageLedgerEntryV1, right: UsageLedgerEntryV1): boolean { + return ( + left.schemaVersion === right.schemaVersion && + left.entryId === right.entryId && + sameScope(left.tenantScope, right.tenantScope) && + left.metric === right.metric && + left.bucket === right.bucket && + left.deltaUnits === right.deltaUnits && + left.sequence === right.sequence && + left.reservationId === right.reservationId && + left.idempotencyKey === right.idempotencyKey && + left.occurredAt === right.occurredAt + ); +} + +export function sameUsageReservationV1( + left: UsageReservationV1, + right: UsageReservationV1, +): boolean { + return ( + left.reservationId === right.reservationId && + sameScope(left.tenantScope, right.tenantScope) && + left.metric === right.metric && + left.reservedUnits === right.reservedUnits && + left.status === right.status && + left.createdAt === right.createdAt && + left.revision === right.revision + ); +} + +export function sameUsageReservationExceptStatusV1( + left: UsageReservationV1, + right: UsageReservationV1, +): boolean { + return ( + left.reservationId === right.reservationId && + sameScope(left.tenantScope, right.tenantScope) && + left.metric === right.metric && + left.reservedUnits === right.reservedUnits && + left.createdAt === right.createdAt + ); +} diff --git a/services/api/test/features/bua/entitlement-repository.test.ts b/services/api/test/features/bua/entitlement-repository.test.ts index 2daf9bdf..2878f559 100644 --- a/services/api/test/features/bua/entitlement-repository.test.ts +++ b/services/api/test/features/bua/entitlement-repository.test.ts @@ -86,7 +86,19 @@ void test('[BUA-001, BUA-002, BUA-003] plans and snapshots are immutable and sco const repository = new InMemoryEntitlementRepositoryAdapter(); await repository.savePlan(plan()); assert.deepEqual(await repository.findPlan('development'), plan()); + const reorderedPlan = { + ...plan(), + features: ['job.execute', 'artifact.register'], + }; + await repository.savePlan({ + ...reorderedPlan, + }); + assert.deepEqual(await repository.findPlan('development'), reorderedPlan); await repository.saveSnapshot(context(workspaceId), snapshot()); + await repository.saveSnapshot(context(workspaceId), { + ...snapshot(), + features: ['job.execute', 'artifact.register'], + }); assert.equal( ( await repository.findSnapshot( From ebe73cff225fb696e58f65f172fc160e55f338ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:44:35 +0700 Subject: [PATCH 153/177] perf(aud): bound append lookups to latest and replay --- .../prisma-audit-repository.adapter.ts | 24 ++++++++++++------- .../aud/prisma-audit-repository.test.ts | 22 +++++++++++++---- 2 files changed, 34 insertions(+), 12 deletions(-) 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 index 5cec0777..1eb63f71 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -76,6 +76,10 @@ interface AuditEventDelegateV1 { findUnique(input: { readonly where: { readonly id: string }; }): Promise; + findFirst(input: { + readonly where: Readonly>; + readonly orderBy?: { readonly sequence: 'asc' | 'desc' }; + }): Promise; findMany(input: { readonly where: Readonly>; readonly orderBy: { readonly sequence: 'asc' | 'desc' }; @@ -288,15 +292,19 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { if (!sameAuditEventV1(current, 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]; + const eventScopeKey = scopeKey(event.tenantScope); + const [duplicate, latest] = await Promise.all([ + this.client.auditEventRecord.findFirst({ + where: { scopeKey: eventScopeKey, idempotencyKey: event.idempotencyKey }, + }), + this.client.auditEventRecord.findFirst({ + where: { scopeKey: eventScopeKey }, + orderBy: { sequence: 'desc' }, + }), + ]); + if (duplicate !== null) throw new Error('AUD_IDEMPOTENCY_CONFLICT'); if ( - latest !== undefined && + latest !== null && (event.sequence !== latest.sequence + 1 || event.previousDigest !== latest.digest) ) { throw new Error('AUD_SEQUENCE_CONFLICT'); diff --git a/services/api/test/features/aud/prisma-audit-repository.test.ts b/services/api/test/features/aud/prisma-audit-repository.test.ts index dd5e3d83..40d3006e 100644 --- a/services/api/test/features/aud/prisma-audit-repository.test.ts +++ b/services/api/test/features/aud/prisma-audit-repository.test.ts @@ -39,11 +39,25 @@ function delegate>(rows: TRow[]) { 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, + findFirst({ + where, + orderBy, + }: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }) { + const matching = rows.filter((row) => + Object.entries(where).every(([key, value]) => row[key] === value), ); + const [field, direction] = Object.entries(orderBy ?? {})[0] ?? []; + if (field) { + matching.sort((left, right) => { + if (left[field] === right[field]) return 0; + const comparison = left[field]! < right[field]! ? -1 : 1; + return direction === 'desc' ? -comparison : comparison; + }); + } + return Promise.resolve(matching[0] ?? null); }, findMany({ where, From bb8a11f2c3ca0ea9b0544ba776f434189b9185cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:45:30 +0700 Subject: [PATCH 154/177] perf(bua): scope usage reads to tenant ancestry --- .../prisma-entitlement-repository.adapter.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) 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 index f335fa66..8d414e88 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -433,6 +433,14 @@ function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); } +function inheritedUsageScopeKeys(scope: TenantScopeV1): readonly string[] | undefined { + if (scope.scopeType === 'organization') return undefined; + return Object.freeze([ + `organization:${scope.organizationId}`, + `workspace:${scope.organizationId}:${scope.workspaceId}`, + ]); +} + function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReservationV1): boolean { return sameUsageReservationExceptStatusV1(left, right); } @@ -503,16 +511,31 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV } public async listUsageState(context: IamTenantContextV1): Promise { - const [entryRows, reservationRows] = await Promise.all([ + const scopeKeys = inheritedUsageScopeKeys(context.tenantScope); + const entryQueries = (scopeKeys ?? [undefined]).map((key) => this.client.usageLedgerEntryRecord.findMany({ - where: { organizationId: context.tenantScope.organizationId }, + where: + key === undefined + ? { organizationId: context.tenantScope.organizationId } + : { scopeKey: key }, orderBy: { sequence: 'asc' }, }), + ); + const reservationQueries = (scopeKeys ?? [undefined]).map((key) => this.client.usageReservationRecord.findMany({ - where: { organizationId: context.tenantScope.organizationId }, + where: + key === undefined + ? { organizationId: context.tenantScope.organizationId } + : { scopeKey: key }, orderBy: { createdAt: 'asc' }, }), + ); + const [entryGroups, reservationGroups] = await Promise.all([ + Promise.all(entryQueries), + Promise.all(reservationQueries), ]); + const entryRows = entryGroups.flat(); + const reservationRows = reservationGroups.flat(); return Object.freeze({ entries: Object.freeze( entryRows From 50e325d0118da8fbdc615afc158a4b1d2f6ec660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:46:08 +0700 Subject: [PATCH 155/177] fix(bua): enforce terminal reservation transitions --- .../in-memory-entitlement-repository.adapter.ts | 10 +++++++++- .../adapter/prisma-entitlement-repository.adapter.ts | 10 +++++++++- .../test/features/bua/entitlement-repository.test.ts | 9 +++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts index 402dbdf2..d5364b93 100644 --- a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts @@ -77,6 +77,13 @@ function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReser return sameUsageReservationExceptStatusV1(left, right); } +function validReservationTransition( + current: UsageReservationV1, + next: UsageReservationV1, +): boolean { + return current.status === 'ACTIVE' && (next.status === 'FINALIZED' || next.status === 'RELEASED'); +} + /** In-memory adapter with append-only usage and immutable plan/snapshot semantics. */ export class InMemoryEntitlementRepositoryAdapter implements EntitlementRepositoryPortV1 { private plans = new Map(); @@ -164,7 +171,8 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito if (sameUsageReservationV1(existing, reservation)) continue; if ( existing.revision + 1 !== reservation.revision || - !sameReservationExceptStatus(existing, reservation) + !sameReservationExceptStatus(existing, reservation) || + !validReservationTransition(existing, reservation) ) throw new Error('BUA_RESERVATION_CONFLICT'); this.reservations.set(reservation.reservationId, cloneReservation(reservation)); 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 index 8d414e88..b61f13f3 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -445,6 +445,13 @@ function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReser return sameUsageReservationExceptStatusV1(left, right); } +function validReservationTransition( + current: UsageReservationV1, + next: UsageReservationV1, +): boolean { + return current.status === 'ACTIVE' && (next.status === 'FINALIZED' || next.status === 'RELEASED'); +} + class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV1 { public constructor(private readonly client: EntitlementDatabaseClientV1) {} @@ -587,7 +594,8 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV if (sameUsageReservationV1(current, reservation)) continue; if ( !sameReservationExceptStatus(current, reservation) || - reservation.revision !== current.revision + 1 + reservation.revision !== current.revision + 1 || + !validReservationTransition(current, reservation) ) throw new Error('BUA_RESERVATION_CONFLICT'); if (!this.client.usageReservationRecord.updateMany) throw new Error('BUA_UPDATE_UNAVAILABLE'); diff --git a/services/api/test/features/bua/entitlement-repository.test.ts b/services/api/test/features/bua/entitlement-repository.test.ts index 2878f559..451fc208 100644 --- a/services/api/test/features/bua/entitlement-repository.test.ts +++ b/services/api/test/features/bua/entitlement-repository.test.ts @@ -145,6 +145,15 @@ void test('[BUA-008, BUA-009, BUA-010, BUA-011] usage state persists append-only if (!reserved.accepted) return; await repository.persistUsageState(context(workspaceId), reserved.value.state); assert.equal((await repository.listUsageState(context(workspaceId))).entries.length, 1); + const activeReservation = reserved.value.state.reservations[0]; + if (!activeReservation) throw new Error('fixture reservation missing'); + await assert.rejects( + repository.persistUsageState(context(workspaceId), { + entries: reserved.value.state.entries, + reservations: [{ ...activeReservation, revision: 2 }], + }), + /BUA_RESERVATION_CONFLICT/, + ); await assert.rejects( repository.persistUsageState(context(workspaceId), { ...reserved.value.state, From 2328dd410bc55171b8f9aca5a0c53575cc3240ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:47:55 +0700 Subject: [PATCH 156/177] fix(bua): return entitlement problems with HTTP status --- services/api/openapi/v1.json | 48 +++++++++++-------- .../bua/api/entitlement.controller.ts | 44 ++++++++++------- .../application/entitlement-problem.error.ts | 10 ++++ .../platform/http/problem-details.filter.ts | 20 ++++++++ services/api/test/http-contract.test.ts | 12 +---- 5 files changed, 88 insertions(+), 46 deletions(-) create mode 100644 services/api/src/features/bua/application/entitlement-problem.error.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 87e58e83..bedee5ec 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -6891,8 +6891,8 @@ } ], "responses": { - "200": { - "description": "", + "400": { + "description": "The snapshot identifier is invalid.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -6904,13 +6904,8 @@ } } }, - "400": { - "description": "The request was malformed or failed closed validation.", - "content": { - "application/problem+json": { - "schema": { "$ref": "#/components/schemas/ProblemDetails" } - } - }, + "404": { + "description": "The entitlement snapshot is not visible.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -6939,6 +6934,19 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Entitlement 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" } + } + } } }, "security": [{ "bearer": [] }], @@ -6959,8 +6967,13 @@ } ], "responses": { - "200": { - "description": "", + "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.", @@ -6972,8 +6985,8 @@ } } }, - "400": { - "description": "The request was malformed or failed closed validation.", + "500": { + "description": "An unexpected failure was safely mapped.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } @@ -6990,13 +7003,8 @@ } } }, - "500": { - "description": "An unexpected failure was safely mapped.", - "content": { - "application/problem+json": { - "schema": { "$ref": "#/components/schemas/ProblemDetails" } - } - }, + "503": { + "description": "Usage persistence is unavailable.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", diff --git a/services/api/src/features/bua/api/entitlement.controller.ts b/services/api/src/features/bua/api/entitlement.controller.ts index 0813c5ea..0b242308 100644 --- a/services/api/src/features/bua/api/entitlement.controller.ts +++ b/services/api/src/features/bua/api/entitlement.controller.ts @@ -1,5 +1,12 @@ import { Controller, Get, Inject, Param, Req } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiNotFoundResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import type { EntitlementSnapshotV1, UsageLedgerStateV1 } from '@databreeze/domain/entitlements/v1'; @@ -11,8 +18,7 @@ import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; - -type EntitlementNotFoundV1 = { readonly accepted: false; readonly code: 'ENTITLEMENT_NOT_FOUND' }; +import { EntitlementProblemError } from '../application/entitlement-problem.error.js'; @ApiTags('entitlements') @ApiBearerAuth() @@ -27,29 +33,35 @@ export class EntitlementController { @Get('snapshots/:snapshotId') @ApiOperation({ summary: 'Read one immutable entitlement snapshot in the caller scope' }) + @ApiBadRequestResponse({ description: 'The snapshot identifier is invalid.' }) + @ApiNotFoundResponse({ description: 'The entitlement snapshot is not visible.' }) + @ApiServiceUnavailableResponse({ description: 'Entitlement persistence is unavailable.' }) async snapshot( @Req() request: unknown, @Param('snapshotId') snapshotIdInput: string, - ): Promise< - | EntitlementSnapshotV1 - | EntitlementNotFoundV1 - | { readonly accepted: false; readonly code: 'INVALID_IDENTIFIER' } - > { + ): Promise { 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', - } - ); + if (!parsed.accepted) throw new EntitlementProblemError('ENTITLEMENT_REQUEST_INVALID'); + try { + const snapshot = await this.repository.findSnapshot(context, parsed.value); + if (!snapshot) throw new EntitlementProblemError('ENTITLEMENT_NOT_FOUND'); + return snapshot; + } catch (error) { + if (error instanceof EntitlementProblemError) throw error; + throw new EntitlementProblemError('ENTITLEMENT_UNAVAILABLE'); + } } @Get('usage') @ApiOperation({ summary: 'Read the append-only usage ledger state in the caller scope' }) + @ApiServiceUnavailableResponse({ description: 'Usage persistence is unavailable.' }) async usage(@Req() request: unknown): Promise { const context = await this.requestContext.resolve(request); - return this.repository.listUsageState(context); + try { + return await this.repository.listUsageState(context); + } catch { + throw new EntitlementProblemError('ENTITLEMENT_UNAVAILABLE'); + } } } diff --git a/services/api/src/features/bua/application/entitlement-problem.error.ts b/services/api/src/features/bua/application/entitlement-problem.error.ts new file mode 100644 index 00000000..0d58eb00 --- /dev/null +++ b/services/api/src/features/bua/application/entitlement-problem.error.ts @@ -0,0 +1,10 @@ +export type EntitlementProblemCodeV1 = + | 'ENTITLEMENT_NOT_FOUND' + | 'ENTITLEMENT_REQUEST_INVALID' + | 'ENTITLEMENT_UNAVAILABLE'; + +export class EntitlementProblemError extends Error { + public constructor(readonly code: EntitlementProblemCodeV1) { + super(code); + } +} diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 75a9262b..d6df796d 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -10,6 +10,7 @@ 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 { EntitlementProblemError } from '../../features/bua/application/entitlement-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'; @@ -55,6 +56,25 @@ function describe(error: unknown, correlationId: string): ProblemInput { status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST, }; } + if (error instanceof EntitlementProblemError) { + const unavailable = error.code === 'ENTITLEMENT_UNAVAILABLE'; + const notFound = error.code === 'ENTITLEMENT_NOT_FOUND'; + return { + code: error.code, + correlationId, + messageKey: unavailable + ? 'api.error.entitlement_unavailable' + : notFound + ? 'api.error.entitlement_not_found' + : 'api.error.entitlement_request_invalid', + retryable: unavailable, + status: unavailable + ? HttpStatus.SERVICE_UNAVAILABLE + : notFound + ? HttpStatus.NOT_FOUND + : HttpStatus.BAD_REQUEST, + }; + } if (error instanceof RequestTenantContextProblemError) { const invalidContext = error.code === 'CONTEXT_INVALID'; return { diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index b04ed4b4..93fa917f 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -612,22 +612,14 @@ void test('protected artifact reads derive tenant scope from an authenticated ac 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', - }); + assertProblem(missingSnapshot, 404, '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', - }); + assertProblem(invalidSnapshot, 400, 'ENTITLEMENT_REQUEST_INVALID'); }, ); }); From 295b9111c4b9aa4d595ace3cf595f9a238e7585d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:49:35 +0700 Subject: [PATCH 157/177] fix(iam): authorize sign-out session ownership --- services/api/openapi/v1.json | 2 + .../iam/api/authentication.controller.ts | 18 ++++++++- services/api/test/http-contract.test.ts | 37 ++++++++++++++++++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index bedee5ec..3d35a340 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -418,6 +418,7 @@ } } }, + "security": [{ "bearer": [] }], "summary": "Read the redacted authenticated session identity", "tags": ["auth"] } @@ -710,6 +711,7 @@ } } }, + "security": [{ "bearer": [] }], "summary": "Revoke a session and clear browser credentials", "tags": ["auth"] } diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 3b94c7fc..08e43d4a 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -3,6 +3,7 @@ import { randomBytes } from 'node:crypto'; import { Body, Controller, Get, HttpCode, Inject, Optional, Post, Req, Res } from '@nestjs/common'; import { ApiBody, + ApiBearerAuth, ApiOkResponse, ApiOperation, ApiServiceUnavailableResponse, @@ -53,6 +54,7 @@ export class AuthenticationController { ) {} @Get('me') + @ApiBearerAuth() @ApiOperation({ summary: 'Read the redacted authenticated session identity' }) @ApiOkResponse({ type: CurrentSessionDto }) async me(@Req() request: FastifyRequest): Promise { @@ -163,18 +165,32 @@ export class AuthenticationController { @Post('sign-out') @HttpCode(204) + @ApiBearerAuth() @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, + @Req() request: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply, ): Promise { if (this.sessions === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); try { + if (this.requestContext === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); + const context = await this.requestContext.resolve(request); + const principal = await this.sessions.findPrincipal(input.sessionId); + if ( + !principal || + principal.userId !== context.actorId || + principal.organizationId !== context.tenantScope.organizationId || + (context.tenantScope.scopeType !== 'organization' && + principal.workspaceId !== context.tenantScope.workspaceId) + ) + throw new SessionProblemError('SESSION_INVALID'); await this.sessions.revoke(input.sessionId); - } catch { + } catch (error) { + if (error instanceof SessionProblemError) throw error; throw new SessionProblemError('SESSION_UNAVAILABLE'); } if (input.clientPlatform === 'web') { diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 93fa917f..4444d079 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -462,6 +462,13 @@ void test('refresh rotates Web cookies without returning the refresh token and p void test('sign-out revokes idempotently and clears browser credentials', async () => { const revoked: string[] = []; + const signOutPrincipal = { + userId: '00000000-0000-4000-8000-000000000001', + organizationId: '00000000-0000-4000-8000-000000000002', + workspaceId: '00000000-0000-4000-8000-000000000003', + securityEpoch: 1, + mfaRequired: false, + }; await withApp( { sessions: { @@ -471,7 +478,17 @@ void test('sign-out revokes idempotently and clears browser credentials', async revoked.push(String(sessionId)); return Promise.resolve(false); }, - findPrincipal: () => Promise.resolve(undefined), + findPrincipal: (sessionId) => + Promise.resolve( + sessionId === '00000000-0000-4000-8000-000000000099' + ? { + ...signOutPrincipal, + userId: '00000000-0000-4000-8000-000000000099', + } + : signOutPrincipal, + ), + findPrincipalByAccessToken: (token) => + Promise.resolve(token === 'sign-out-access-token' ? signOutPrincipal : undefined), }, }, async (app) => { @@ -482,6 +499,7 @@ void test('sign-out revokes idempotently and clears browser credentials', async cookie: `databreeze_refresh=current-refresh-token; databreeze_csrf=${csrfToken}`, 'x-csrf-token': csrfToken, origin: 'http://localhost:3000', + authorization: 'Bearer sign-out-access-token', }, payload: { clientPlatform: 'web', @@ -501,6 +519,7 @@ void test('sign-out revokes idempotently and clears browser credentials', async const native = await app.inject({ method: 'POST', url: '/v1/auth/sign-out', + headers: { authorization: 'Bearer sign-out-access-token' }, payload: { clientPlatform: 'android', sessionId: '00000000-0000-4000-8000-000000000011', @@ -512,6 +531,17 @@ void test('sign-out revokes idempotently and clears browser credentials', async '00000000-0000-4000-8000-000000000010', '00000000-0000-4000-8000-000000000011', ]); + + const crossUser = await app.inject({ + method: 'POST', + url: '/v1/auth/sign-out', + headers: { authorization: 'Bearer sign-out-access-token' }, + payload: { + clientPlatform: 'android', + sessionId: '00000000-0000-4000-8000-000000000099', + }, + }); + assertProblem(crossUser, 401, 'SESSION_INVALID'); }, ); @@ -521,13 +551,16 @@ void test('sign-out revokes idempotently and clears browser credentials', async issue: () => Promise.reject(new Error('not used')), refresh: () => Promise.reject(new Error('not used')), revoke: () => Promise.reject(new Error('database unavailable')), - findPrincipal: () => Promise.resolve(undefined), + findPrincipal: () => Promise.resolve(signOutPrincipal), + findPrincipalByAccessToken: (token) => + Promise.resolve(token === 'sign-out-access-token' ? signOutPrincipal : undefined), }, }, async (app) => { const response = await app.inject({ method: 'POST', url: '/v1/auth/sign-out', + headers: { authorization: 'Bearer sign-out-access-token' }, payload: { clientPlatform: 'android', sessionId: '00000000-0000-4000-8000-000000000011', From afdd94b8dbc56180ea76553956a0847a86685d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:51:00 +0700 Subject: [PATCH 158/177] fix(iam): map MFA persistence failures to unavailable --- .../src/features/iam/api/mfa.controller.ts | 35 ++++++++++++++----- services/api/test/http-contract.test.ts | 17 +++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/services/api/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts index df7e017d..e1ae9111 100644 --- a/services/api/src/features/iam/api/mfa.controller.ts +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -18,17 +18,32 @@ export class MfaController { @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, ) {} + private requireService(): MfaService { + if (this.mfa === undefined) throw new MfaProblemError('MFA_UNAVAILABLE'); + return this.mfa; + } + + private async execute(work: () => Promise): Promise { + try { + return await work(); + } catch { + throw new MfaProblemError('MFA_UNAVAILABLE'); + } + } + @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 mfa = this.requireService(); const context = await this.requestContext.resolve(request); - const result = await this.mfa.enroll({ - ...input, - userId: context.actorId, - }); + const result = await this.execute(() => + mfa.enroll({ + ...input, + userId: context.actorId, + }), + ); if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); return result.value; } @@ -42,9 +57,9 @@ export class MfaController { @Param('factorId') factorId: string, @Body() input: VerifyMfaFactorDto, ): Promise { - if (this.mfa === undefined) throw new MfaProblemError('MFA_UNAVAILABLE'); + const mfa = this.requireService(); const context = await this.requestContext.resolve(request); - const result = await this.mfa.verifyFactor(context.actorId, factorId, input.at); + const result = await this.execute(() => mfa.verifyFactor(context.actorId, factorId, input.at)); if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); return result.value; } @@ -57,9 +72,11 @@ export class MfaController { @Req() request: unknown, @Body() input: RedeemMfaRecoveryCodeDto, ): Promise { - if (this.mfa === undefined) throw new MfaProblemError('MFA_UNAVAILABLE'); + const mfa = this.requireService(); const context = await this.requestContext.resolve(request); - const result = await this.mfa.redeemRecovery(context.actorId, input.presentedDigest, input.at); + const result = await this.execute(() => + 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/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 4444d079..e3cf310c 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -712,4 +712,21 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con }); assertProblem(invalid, 400, 'MFA_REQUEST_REJECTED'); }); + + const unavailableMfa = { + enroll: () => Promise.reject(new Error('database unavailable')), + } as unknown as MfaService; + await withApp({ mfaService: unavailableMfa, requestTenantContext }, async (app) => { + const response = 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', + }, + }); + assertProblem(response, 503, 'MFA_UNAVAILABLE'); + }); }); From 5e67f8b2eadeec005344d776bd5eeef492174d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:52:16 +0700 Subject: [PATCH 159/177] fix(iam): fail closed at device identity API boundary --- .../iam/api/device-identity.controller.ts | 50 ++++++++++++++----- .../device-identity-problem.error.ts | 12 +++++ .../platform/http/problem-details.filter.ts | 20 ++++++++ .../iam/device-identity.controller.test.ts | 40 +++++++++++++++ 4 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 services/api/src/features/iam/application/device-identity-problem.error.ts diff --git a/services/api/src/features/iam/api/device-identity.controller.ts b/services/api/src/features/iam/api/device-identity.controller.ts index 1d573ab2..9b9383d0 100644 --- a/services/api/src/features/iam/api/device-identity.controller.ts +++ b/services/api/src/features/iam/api/device-identity.controller.ts @@ -4,8 +4,10 @@ import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import { DEVICE_IDENTITY_SERVICE, + type DeviceIdentityApplicationResultV1, type DeviceIdentityService, } from '../application/device-identity.service.js'; +import { DeviceIdentityProblemError } from '../application/device-identity-problem.error.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -28,6 +30,24 @@ export class DeviceIdentityController { private readonly requestContext: RequestTenantContextPortV1, ) {} + private async execute( + work: () => Promise>, + ): Promise> { + let result: DeviceIdentityApplicationResultV1; + try { + result = await work(); + } catch { + throw new DeviceIdentityProblemError('DEVICE_UNAVAILABLE'); + } + if (result.accepted) return result; + if (result.code === 'SCOPE_DENIED') throw new DeviceIdentityProblemError('DEVICE_SCOPE_DENIED'); + if (result.code === 'DEVICE_NOT_FOUND') + throw new DeviceIdentityProblemError('DEVICE_NOT_FOUND'); + if (result.code === 'REVISION_CONFLICT' || result.code === 'DEVICE_REVOKED') + throw new DeviceIdentityProblemError('DEVICE_REVISION_CONFLICT'); + throw new DeviceIdentityProblemError('DEVICE_REQUEST_REJECTED'); + } + @Post('devices/enrollment-challenges') @HttpCode(200) @ApiOperation({ summary: 'Issue a short-lived device proof-of-possession challenge' }) @@ -39,7 +59,7 @@ export class DeviceIdentityController { ): Promise { const context = await this.requestContext.resolve(request); void idempotencyKey; - return this.devices.issueEnrollmentChallenge(context, input); + return this.execute(() => this.devices.issueEnrollmentChallenge(context, input)); } @Post('devices/enroll') @@ -48,7 +68,7 @@ export class DeviceIdentityController { @ApiBody({ type: EnrollDeviceDto }) async enroll(@Req() request: unknown, @Body() input: EnrollDeviceDto): Promise { const context = await this.requestContext.resolve(request); - return this.devices.enroll(context, input); + return this.execute(() => this.devices.enroll(context, input)); } @Post('devices/:deviceId/activate') @@ -61,7 +81,9 @@ export class DeviceIdentityController { @Body() input: DeviceRevisionDto, ): Promise { const context = await this.requestContext.resolve(request); - return this.devices.activate(context, deviceId, input.expectedRevision, input.at); + return this.execute(() => + this.devices.activate(context, deviceId, input.expectedRevision, input.at), + ); } @Get('organizations/:organizationId/devices') @@ -77,8 +99,8 @@ export class DeviceIdentityController { context.tenantScope.scopeType !== 'organization' || parsed.value !== context.tenantScope.organizationId ) - return { accepted: false, code: 'SCOPE_DENIED' as const }; - return this.devices.list(context); + throw new DeviceIdentityProblemError('DEVICE_SCOPE_DENIED'); + return this.execute(() => this.devices.list(context)); } @Post('devices/:deviceId/revoke') @@ -91,7 +113,9 @@ export class DeviceIdentityController { @Body() input: DeviceRevisionDto, ): Promise { const context = await this.requestContext.resolve(request); - return this.devices.revoke(context, deviceId, input.expectedRevision, input.at); + return this.execute(() => + this.devices.revoke(context, deviceId, input.expectedRevision, input.at), + ); } @Post('devices/:deviceId/key') @@ -104,12 +128,14 @@ export class DeviceIdentityController { @Body() input: RotateDeviceKeyDto, ): Promise { const context = await this.requestContext.resolve(request); - return this.devices.rotateKey( - context, - deviceId, - input.expectedRevision, - input.nextPublicKey, - input.at, + return this.execute(() => + this.devices.rotateKey( + context, + deviceId, + input.expectedRevision, + input.nextPublicKey, + input.at, + ), ); } } diff --git a/services/api/src/features/iam/application/device-identity-problem.error.ts b/services/api/src/features/iam/application/device-identity-problem.error.ts new file mode 100644 index 00000000..317fadfe --- /dev/null +++ b/services/api/src/features/iam/application/device-identity-problem.error.ts @@ -0,0 +1,12 @@ +export type DeviceIdentityProblemCodeV1 = + | 'DEVICE_NOT_FOUND' + | 'DEVICE_REQUEST_REJECTED' + | 'DEVICE_REVISION_CONFLICT' + | 'DEVICE_SCOPE_DENIED' + | 'DEVICE_UNAVAILABLE'; + +export class DeviceIdentityProblemError extends Error { + public constructor(readonly code: DeviceIdentityProblemCodeV1) { + super(code); + } +} diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index d6df796d..21c50a64 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -11,6 +11,7 @@ import { AuthenticationProblemError } from '../../features/iam/application/authe import { SessionProblemError } from '../../features/iam/application/session-problem.error.js'; import { MfaProblemError } from '../../features/iam/application/mfa-problem.error.js'; import { EntitlementProblemError } from '../../features/bua/application/entitlement-problem.error.js'; +import { DeviceIdentityProblemError } from '../../features/iam/application/device-identity-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'; @@ -75,6 +76,25 @@ function describe(error: unknown, correlationId: string): ProblemInput { : HttpStatus.BAD_REQUEST, }; } + if (error instanceof DeviceIdentityProblemError) { + const status = + error.code === 'DEVICE_UNAVAILABLE' + ? HttpStatus.SERVICE_UNAVAILABLE + : error.code === 'DEVICE_NOT_FOUND' + ? HttpStatus.NOT_FOUND + : error.code === 'DEVICE_SCOPE_DENIED' + ? HttpStatus.FORBIDDEN + : error.code === 'DEVICE_REVISION_CONFLICT' + ? HttpStatus.CONFLICT + : HttpStatus.BAD_REQUEST; + return { + code: error.code, + correlationId, + messageKey: `api.error.${error.code.toLowerCase()}`, + retryable: error.code === 'DEVICE_UNAVAILABLE', + status, + }; + } if (error instanceof RequestTenantContextProblemError) { const invalidContext = error.code === 'CONTEXT_INVALID'; return { diff --git a/services/api/test/features/iam/device-identity.controller.test.ts b/services/api/test/features/iam/device-identity.controller.test.ts index 515777e6..8da3a32d 100644 --- a/services/api/test/features/iam/device-identity.controller.test.ts +++ b/services/api/test/features/iam/device-identity.controller.test.ts @@ -85,6 +85,46 @@ void test('[IAM-007, IAM-021] device identity HTTP endpoints use the authenticat const devicesValue = jsonObject(devices)['value']; assert.ok(Array.isArray(devicesValue)); assert.equal(devicesValue.length, 1); + + const denied = await app.inject({ + method: 'GET', + url: '/v1/organizations/00000000-0000-4000-8000-000000000699/devices', + }); + assert.equal(denied.statusCode, 403); + assert.equal(jsonObject(denied)['code'], 'DEVICE_SCOPE_DENIED'); + } finally { + await app.close(); + } +}); + +void test('[IAM-007] device identity persistence failures return a retryable unavailable problem', async () => { + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(context()), + }; + const unavailableService = { + issueEnrollmentChallenge: () => Promise.reject(new Error('database unavailable')), + } as unknown as DeviceIdentityService; + const { app } = await createApiApplication({ + deviceIdentityService: unavailableService, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/devices/enrollment-challenges', + payload: { + challengeId, + platform: 'WINDOWS', + installationIdHash: 'a'.repeat(64), + challengeDigest: 'b'.repeat(64), + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T00:05:00.000Z', + }, + }); + assert.equal(response.statusCode, 503); + const problem = jsonObject(response); + assert.equal(problem['code'], 'DEVICE_UNAVAILABLE'); + assert.equal(problem['retryable'], true); } finally { await app.close(); } From 7c94a11ccca7b1db460d8270b0872af4c734a441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:54:57 +0700 Subject: [PATCH 160/177] fix(iam): distinguish session authority outages --- .../platform/http/problem-details.filter.ts | 21 +++++++++++++++---- .../http/session-tenant-context.adapter.ts | 7 +++++-- services/api/test/http-contract.test.ts | 20 ++++++++++++++++++ .../http/session-tenant-context.test.ts | 15 +++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 21c50a64..d4f77a47 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -97,12 +97,25 @@ function describe(error: unknown, correlationId: string): ProblemInput { } if (error instanceof RequestTenantContextProblemError) { const invalidContext = error.code === 'CONTEXT_INVALID'; + const unavailable = error.code === 'AUTHENTICATION_UNAVAILABLE'; return { - code: invalidContext ? 'CONTEXT_INVALID' : 'AUTHENTICATION_FAILED', + code: invalidContext + ? 'CONTEXT_INVALID' + : unavailable + ? 'AUTHENTICATION_UNAVAILABLE' + : 'AUTHENTICATION_FAILED', correlationId, - messageKey: invalidContext ? 'api.error.context_invalid' : 'api.error.authentication_failed', - retryable: false, - status: invalidContext ? HttpStatus.BAD_REQUEST : HttpStatus.UNAUTHORIZED, + messageKey: invalidContext + ? 'api.error.context_invalid' + : unavailable + ? 'api.error.authentication_unavailable' + : 'api.error.authentication_failed', + retryable: unavailable, + status: invalidContext + ? HttpStatus.BAD_REQUEST + : unavailable + ? HttpStatus.SERVICE_UNAVAILABLE + : HttpStatus.UNAUTHORIZED, }; } if (error instanceof InputValidationException) { diff --git a/services/api/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts index c7d2f4ef..4fc088aa 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -5,7 +5,10 @@ import { createIamTenantContextV1 } from '../../features/iam/application/tenant- import type { RequestTenantContextPortV1 } from './request-tenant-context.port.js'; import { getRequestContext } from './request-context.js'; -export type RequestTenantContextProblemCodeV1 = 'AUTHENTICATION_FAILED' | 'CONTEXT_INVALID'; +export type RequestTenantContextProblemCodeV1 = + | 'AUTHENTICATION_FAILED' + | 'AUTHENTICATION_UNAVAILABLE' + | 'CONTEXT_INVALID'; export class RequestTenantContextProblemError extends Error { constructor(readonly code: RequestTenantContextProblemCodeV1) { @@ -80,7 +83,7 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP try { principal = await this.sessions.findPrincipalByAccessToken(token); } catch { - throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + throw new RequestTenantContextProblemError('AUTHENTICATION_UNAVAILABLE'); } if (principal === undefined) throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index e3cf310c..b1c75488 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -655,6 +655,26 @@ void test('protected artifact reads derive tenant scope from an authenticated ac assertProblem(invalidSnapshot, 400, 'ENTITLEMENT_REQUEST_INVALID'); }, ); + + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(undefined), + findPrincipalByAccessToken: () => Promise.reject(new Error('database unavailable')), + }, + }, + async (app) => { + const response = await app.inject({ + method: 'GET', + url: '/v1/artifacts/inbox', + headers: { authorization: 'Bearer unavailable-access-token-12345' }, + }); + assertProblem(response, 503, 'AUTHENTICATION_UNAVAILABLE'); + }, + ); }); void test('MFA HTTP lifecycle derives the user from the authenticated tenant context and returns redacted state', async () => { diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts index 34b0a886..e4a56213 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -78,3 +78,18 @@ void test('uses the request id for read-only calls and rejects unsafe principal }, ); }); + +void test('reports session authority outages separately from rejected bearer credentials', async () => { + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: () => Promise.reject(new Error('database unavailable')), + }); + await assert.rejects( + adapter.resolve({ + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }), + (error: unknown) => { + assert.equal((error as { code?: unknown }).code, 'AUTHENTICATION_UNAVAILABLE'); + return true; + }, + ); +}); From 44c1fae36be7e9df40ff042b5b0fd95d1c1f92fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:55:34 +0700 Subject: [PATCH 161/177] test(iam): exercise unsafe principal rejection --- .../api/test/platform/http/session-tenant-context.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts index e4a56213..cb8a6eaf 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -71,9 +71,12 @@ void test('uses the request id for read-only calls and rejects unsafe principal findPrincipalByAccessToken: () => Promise.resolve({ ...principal, securityEpoch: 0 }), }); await assert.rejects( - adapter.resolve({ id: 'request-read-001', headers: { authorization: 'Bearer token' } }), + adapter.resolve({ + id: 'request-read-001', + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }), (error: unknown) => { - assert.equal((error as { code?: unknown }).code, 'AUTHENTICATION_FAILED'); + assert.equal((error as { code?: unknown }).code, 'CONTEXT_INVALID'); return true; }, ); From 868267dee0653048bce986502a2e561dbe2bf500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:56:55 +0700 Subject: [PATCH 162/177] fix(iam): bound session cookie parsing --- .../src/features/iam/api/session-cookies.ts | 20 ++++++++--- .../test/features/iam/session-cookies.test.ts | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/services/api/src/features/iam/api/session-cookies.ts b/services/api/src/features/iam/api/session-cookies.ts index 5feab2a3..b00aed6f 100644 --- a/services/api/src/features/iam/api/session-cookies.ts +++ b/services/api/src/features/iam/api/session-cookies.ts @@ -1,5 +1,9 @@ const COOKIE_NAME_PATTERN_V1 = /^[A-Za-z0-9_]+$/u; const COOKIE_VALUE_PATTERN_V1 = /^[A-Za-z0-9._~-]+$/u; +const MAX_COOKIE_HEADER_LENGTH_V1 = 8_192; +const MAX_COOKIE_NAME_LENGTH_V1 = 64; +const MAX_COOKIE_VALUE_LENGTH_V1 = 4_096; +const MAX_COOKIE_SEGMENTS_V1 = 64; export const REFRESH_COOKIE_NAME_V1 = 'databreeze_refresh'; export const CSRF_COOKIE_NAME_V1 = 'databreeze_csrf'; @@ -10,11 +14,11 @@ export interface CookieOptionsV1 { } function validCookieNameV1(name: string): boolean { - return COOKIE_NAME_PATTERN_V1.test(name); + return name.length <= MAX_COOKIE_NAME_LENGTH_V1 && COOKIE_NAME_PATTERN_V1.test(name); } function validCookieValueV1(value: string): boolean { - return COOKIE_VALUE_PATTERN_V1.test(value); + return value.length <= MAX_COOKIE_VALUE_LENGTH_V1 && COOKIE_VALUE_PATTERN_V1.test(value); } export function serializeCookieV1(name: string, value: string, options: CookieOptionsV1): string { @@ -52,9 +56,17 @@ export function clearCookieV1(name: string, options: Pick MAX_COOKIE_HEADER_LENGTH_V1 || + !validCookieNameV1(name) + ) { + return undefined; + } + const segments = rawCookie.split(';'); + if (segments.length > MAX_COOKIE_SEGMENTS_V1) return undefined; let found: string | undefined; - for (const segment of rawCookie.split(';')) { + for (const segment of segments) { const trimmed = segment.trim(); if (trimmed.length === 0) continue; const equals = trimmed.indexOf('='); diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts index 8b686e8f..9061e8c4 100644 --- a/services/api/test/features/iam/session-cookies.test.ts +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -49,6 +49,39 @@ void test('reads one exact cookie value and fails closed for ambiguity or malfor assert.equal(readCookieValueV1(undefined, REFRESH_COOKIE_NAME_V1), undefined); }); +void test('rejects cookie headers and fields beyond parser resource bounds', () => { + assert.equal( + readCookieValueV1( + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; padding=${'a'.repeat(8_192)}`, + REFRESH_COOKIE_NAME_V1, + ), + undefined, + ); + assert.equal( + readCookieValueV1( + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; ${Array.from({ length: 64 }, (_, index) => `c${index}=v`).join('; ')}`, + REFRESH_COOKIE_NAME_V1, + ), + undefined, + ); + assert.equal( + readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${'a'.repeat(4_097)}`, REFRESH_COOKIE_NAME_V1), + undefined, + ); + assert.throws( + () => serializeCookieV1('a'.repeat(65), token, { httpOnly: true, maxAgeSeconds: 1 }), + /Cookie name or value is invalid/, + ); + assert.throws( + () => + serializeCookieV1(REFRESH_COOKIE_NAME_V1, 'a'.repeat(4_097), { + httpOnly: true, + maxAgeSeconds: 1, + }), + /Cookie name or value is invalid/, + ); +}); + void test('creates deletion cookies without weakening the original security attributes', () => { assert.equal( clearCookieV1(REFRESH_COOKIE_NAME_V1, { httpOnly: true }), From 447b029adcb653a02a1c591f38254071a8294c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:57:49 +0700 Subject: [PATCH 163/177] fix(http): bound CSRF cookie parsing --- .../api/src/platform/http/csrf-protection.ts | 27 ++++++++++++++++-- .../platform/http/csrf-protection.test.ts | 28 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/services/api/src/platform/http/csrf-protection.ts b/services/api/src/platform/http/csrf-protection.ts index af209493..54b6d38b 100644 --- a/services/api/src/platform/http/csrf-protection.ts +++ b/services/api/src/platform/http/csrf-protection.ts @@ -28,6 +28,10 @@ const COOKIE_AUTH_NAMES = new Set([ 'databreeze_session', ]); const CSRF_COOKIE_NAME = 'databreeze_csrf'; +const MAX_COOKIE_HEADER_LENGTH = 8_192; +const MAX_COOKIE_NAME_LENGTH = 64; +const MAX_COOKIE_VALUE_LENGTH = 4_096; +const MAX_COOKIE_SEGMENTS = 64; function oneHeader( headers: CsrfRequestV1['headers'], @@ -51,11 +55,19 @@ function parseCookies(raw: string): { readonly values: ReadonlyMap; readonly duplicateNames: ReadonlySet; readonly malformed: boolean; + readonly resourceLimitExceeded: boolean; } { const values = new Map(); const duplicateNames = new Set(); let malformed = false; - for (const segment of raw.split(';')) { + if (raw.length > MAX_COOKIE_HEADER_LENGTH) { + return { values, duplicateNames, malformed, resourceLimitExceeded: true }; + } + const segments = raw.split(';'); + if (segments.length > MAX_COOKIE_SEGMENTS) { + return { values, duplicateNames, malformed, resourceLimitExceeded: true }; + } + for (const segment of segments) { const trimmed = segment.trim(); if (trimmed.length === 0) continue; const equals = trimmed.indexOf('='); @@ -65,14 +77,20 @@ function parseCookies(raw: string): { } 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')) { + if ( + name.length > MAX_COOKIE_NAME_LENGTH || + value.length > MAX_COOKIE_VALUE_LENGTH || + !/^[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 }; + return { values, duplicateNames, malformed, resourceLimitExceeded: false }; } function hasCookieAuth(cookies: ReturnType): boolean { @@ -113,6 +131,9 @@ export function evaluateCsrfRequestV1( return Object.freeze({ accepted: false as const, code: 'CSRF_INVALID' as const }); const cookies = parseCookies(cookie.value); + if (cookies.resourceLimitExceeded) { + return Object.freeze({ accepted: false as const, code: 'CSRF_INVALID' as const }); + } 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 }); diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts index 37adeaea..54c483bb 100644 --- a/services/api/test/platform/http/csrf-protection.test.ts +++ b/services/api/test/platform/http/csrf-protection.test.ts @@ -138,3 +138,31 @@ void test('fails closed for duplicate cookies and duplicate token headers', () = { accepted: false, code: 'CSRF_INVALID' }, ); }); + +void test('fails closed when cookie parsing exceeds resource bounds', () => { + const request = (cookie: string) => ({ + method: 'POST', + headers: { + cookie, + origin: 'https://app.databreeze.example', + 'x-csrf-token': token, + }, + }); + + assert.deepEqual( + evaluateCsrfRequestV1( + request(`databreeze_refresh=session-value; padding=${'a'.repeat(8_192)}`), + { allowedOrigins }, + ), + { accepted: false, code: 'CSRF_INVALID' }, + ); + assert.deepEqual( + evaluateCsrfRequestV1( + request( + `databreeze_refresh=session-value; ${Array.from({ length: 64 }, (_, index) => `c${index}=v`).join('; ')}`, + ), + { allowedOrigins }, + ), + { accepted: false, code: 'CSRF_INVALID' }, + ); +}); From 048d5bd5dcb67d586c4bd7d4ae1d5146cdbf5461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:59:56 +0700 Subject: [PATCH 164/177] fix(iam): enforce narrowest membership authority --- .../in-memory-iam-repository.adapter.ts | 24 ++++++-- .../adapter/prisma-iam-repository.adapter.ts | 17 +++++- .../iam/prisma-iam-repository.test.ts | 35 ++++++++++- .../features/iam/scoped-repository.test.ts | 59 +++++++++++++++++++ 4 files changed, 124 insertions(+), 11 deletions(-) diff --git a/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts index 9c58604c..3e5c0650 100644 --- a/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts @@ -15,6 +15,12 @@ function visibleInScope(context: TenantScopeV1, membership: TenantScopeV1): bool return tenantScopeContainsV1(context, membership) || tenantScopeContainsV1(membership, context); } +function scopeSpecificity(scope: TenantScopeV1): number { + if (scope.scopeType === 'project') return 3; + if (scope.scopeType === 'workspace') return 2; + return 1; +} + function cloneMemberships(source: readonly IamMembershipRecordV1[]): IamMembershipRecordV1[] { return source.map((membership) => Object.freeze({ ...membership, scope: { ...membership.scope } }), @@ -35,12 +41,18 @@ export class InMemoryIamRepositoryAdapter implements IamRepositoryPortV1 { principalId: StableIdentifierV1, ): Promise { await Promise.resolve(); - return this.memberships.find( - (membership) => - membership.principalId === principalId && - membership.status === 'ACTIVE' && - visibleInScope(context.tenantScope, membership.scope), - ); + return this.memberships + .filter( + (membership) => + membership.principalId === principalId && + membership.status === 'ACTIVE' && + tenantScopeContainsV1(membership.scope, context.tenantScope), + ) + .sort( + (left, right) => + scopeSpecificity(right.scope) - scopeSpecificity(left.scope) || + left.id.localeCompare(right.id), + )[0]; } async listMemberships(context: IamTenantContextV1): Promise { 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 index 7c163734..bc1b5268 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -117,6 +117,12 @@ function visibleInScope(context: TenantScopeV1, membership: TenantScopeV1): bool return tenantScopeContainsV1(context, membership) || tenantScopeContainsV1(membership, context); } +function scopeSpecificity(scope: TenantScopeV1): number { + if (scope.scopeType === 'project') return 3; + if (scope.scopeType === 'workspace') return 2; + return 1; +} + class PrismaIamTransactionAdapter implements IamTransactionPortV1 { public constructor(private readonly client: IamDatabaseClientV1) {} @@ -134,12 +140,17 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { }); return rows .map(membershipFromRow) - .find( + .filter( (membership) => membership.principalId === principalId && membership.status === 'ACTIVE' && - visibleInScope(context.tenantScope, membership.scope), - ); + tenantScopeContainsV1(membership.scope, context.tenantScope), + ) + .sort( + (left, right) => + scopeSpecificity(right.scope) - scopeSpecificity(left.scope) || + left.id.localeCompare(right.id), + )[0]; } public async listMemberships( diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index 78edb94a..0f360e1f 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -26,6 +26,7 @@ const organizationId = stable('1'); const workspaceId = stable('2'); const siblingWorkspaceId = stable('3'); const principalId = stable('4'); +const projectId = stable('6'); function context(scope: TenantScopeV1, expectedRevision?: number) { const result = createIamTenantContextV1({ @@ -43,9 +44,10 @@ function context(scope: TenantScopeV1, expectedRevision?: number) { function row( idValue: string, - scope: 'WORKSPACE' | 'ORGANIZATION', + scope: 'PROJECT' | 'WORKSPACE' | 'ORGANIZATION', workspace: string | null, roleId: string, + project: string | null = null, ): IamMembershipDatabaseRowV1 { return { id: idValue, @@ -54,7 +56,7 @@ function row( scopeType: scope, organizationId, workspaceId: workspace, - projectId: null, + projectId: project, roleId, status: 'ACTIVE', startsAt: null, @@ -133,6 +135,35 @@ void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and ); }); +void test('[IAM-003, IAM-014] Prisma membership authority chooses the narrowest containing scope', async () => { + const projectScope = { + scopeType: 'project', + organizationId, + workspaceId, + projectId, + } as const; + const { client } = createDatabase([ + row(id('09'), 'ORGANIZATION', null, 'owner'), + row(id('10'), 'WORKSPACE', workspaceId, 'viewer'), + row(id('11'), 'PROJECT', workspaceId, 'operator', projectId), + ]); + const repository = new PrismaIamRepositoryAdapter(client); + + assert.equal( + (await repository.findMembership(context(projectScope), principalId))?.roleId, + 'operator', + ); + + const descendantOnly = createDatabase([row(id('12'), 'WORKSPACE', workspaceId, 'owner')]); + assert.equal( + await new PrismaIamRepositoryAdapter(descendantOnly.client).findMembership( + context({ scopeType: 'organization', organizationId }), + principalId, + ), + undefined, + ); +}); + void test('[IAM-009, IAM-019] Prisma IAM writes require narrowing and enforce optimistic revisions', async () => { const { client, memberships, forceUpdateConflict } = createDatabase(); const repository = new PrismaIamRepositoryAdapter(client); diff --git a/services/api/test/features/iam/scoped-repository.test.ts b/services/api/test/features/iam/scoped-repository.test.ts index 86e036e6..a7dbaf1f 100644 --- a/services/api/test/features/iam/scoped-repository.test.ts +++ b/services/api/test/features/iam/scoped-repository.test.ts @@ -21,6 +21,7 @@ const organizationId = stable('1'); const workspaceId = stable('2'); const siblingWorkspaceId = stable('3'); const principalId = stable('4'); +const projectId = stable('6'); function context(scope: unknown, expectedRevision?: number) { const result = createIamTenantContextV1({ @@ -100,6 +101,64 @@ void test('[IAM-009, IAM-019] repository reads never cross sibling workspace sco ); }); +void test('[IAM-003, IAM-014] membership authority only flows downward and the narrowest role wins', async () => { + const repository = new InMemoryIamRepositoryAdapter(); + const organizationScope: TenantScopeV1 = { scopeType: 'organization', organizationId }; + const projectScope: TenantScopeV1 = { + scopeType: 'project', + organizationId, + workspaceId, + projectId, + }; + repository.seed([ + { + id: stable('30'), + principalId, + scope: organizationScope, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }, + { + id: stable('31'), + principalId, + scope: workspaceScope, + roleId: 'viewer', + status: 'ACTIVE', + revision: 1, + }, + { + id: stable('32'), + principalId, + scope: projectScope, + roleId: 'operator', + status: 'ACTIVE', + revision: 1, + }, + ]); + + assert.equal( + (await repository.findMembership(context(projectScope), principalId))?.roleId, + 'operator', + ); + + const descendantOnly = new InMemoryIamRepositoryAdapter(); + descendantOnly.seed([ + { + id: stable('33'), + principalId, + scope: workspaceScope, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }, + ]); + assert.equal( + await descendantOnly.findMembership(context(organizationScope), principalId), + undefined, + ); +}); + void test('[IAM-009, IAM-019] writes cannot broaden a scoped context and transactions roll back', async () => { const repository = new InMemoryIamRepositoryAdapter(); repository.seed([]); From c48aa8b453bc8279a4e1cad80eaaa170a64a8ce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:00:59 +0700 Subject: [PATCH 165/177] fix(iam): scope membership mutation lookup --- .../adapter/prisma-iam-repository.adapter.ts | 11 ++++--- .../iam/prisma-iam-repository.test.ts | 33 +++++++++++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) 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 index bc1b5268..3c906e16 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -30,8 +30,8 @@ export interface IamMembershipDatabaseRowV1 { } interface IamMembershipDelegateV1 { - findUnique(input: { - readonly where: { readonly id: string }; + findFirst(input: { + readonly where: Readonly>; }): Promise; findMany(input: { readonly where: Readonly>; @@ -173,8 +173,11 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { 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 }, + const existingRow = await this.client.membershipIdentity.findFirst({ + where: { + id: membership.id, + organizationId: context.tenantScope.organizationId, + }, }); if (!existingRow) { if (context.expectedRevision !== undefined) throw new Error('IAM_REVISION_CONFLICT'); diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index 0f360e1f..64527aeb 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -69,13 +69,23 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { readonly client: IamDatabaseClientV1; readonly memberships: Map; readonly forceUpdateConflict: { value: boolean }; + readonly firstQueries: ReadonlyArray>>; } { const memberships = new Map(rows.map((value) => [value.id, value])); const forceUpdateConflict = { value: false }; + const firstQueries: Array>> = []; const client = { membershipIdentity: { - findUnique: async ({ where }: { readonly where: { readonly id: string } }) => - memberships.get(where.id) ?? null, + findFirst: async ({ where }: { readonly where: Readonly> }) => { + firstQueries.push(where); + return ( + [...memberships.values()].find((candidate) => + Object.entries(where).every( + ([key, value]) => candidate[key as keyof IamMembershipDatabaseRowV1] === value, + ), + ) ?? null + ); + }, findMany: async ({ where }: { readonly where: Readonly> }) => [...memberships.values()].filter((candidate) => Object.entries(where).every( @@ -112,7 +122,7 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { } }, } as unknown as IamDatabaseClientV1; - return { client, memberships, forceUpdateConflict }; + return { client, memberships, forceUpdateConflict, firstQueries }; } void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and hide siblings', async () => { @@ -234,3 +244,20 @@ void test('[IAM-009] Prisma IAM transaction rollback leaves no staged membership ); assert.equal(memberships.size, 0); }); + +void test('[IAM-009, IAM-019] Prisma membership mutation lookup includes tenant ancestry', async () => { + const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; + const { client, firstQueries } = createDatabase(); + const repository = new PrismaIamRepositoryAdapter(client); + + await repository.saveMembership(context(workspaceScope), { + id: stable('23'), + principalId, + scope: workspaceScope, + roleId: 'viewer', + status: 'ACTIVE', + revision: 1, + }); + + assert.deepEqual(firstQueries, [{ id: stable('23'), organizationId }]); +}); From 4f1b555827e0cc8048da3a143dab6da6de8ec354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:01:51 +0700 Subject: [PATCH 166/177] fix(iam): scope device identity lookups --- ...isma-device-identity-repository.adapter.ts | 41 ++++++++++++----- .../prisma-device-identity-repository.test.ts | 45 +++++++++++++++---- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts index 3e8025e2..8b16377e 100644 --- a/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts @@ -47,7 +47,7 @@ export interface DeviceEnrollmentChallengeDatabaseRowV1 { interface DelegateV1 { create(input: { readonly data: TCreate }): Promise; - findUnique(input: { readonly where: { readonly id: string } }): Promise; + findFirst(input: { readonly where: Readonly> }): Promise; findMany(input: { readonly where: Readonly>; readonly orderBy?: Readonly>; @@ -57,7 +57,7 @@ interface DelegateV1 { readonly data: TUpdate; }): Promise; updateMany?(input: { - readonly where: { readonly id: string; readonly revision: number }; + readonly where: Readonly>; readonly data: TUpdate; }): Promise<{ readonly count: number }>; } @@ -234,8 +234,8 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio challenge: DeviceEnrollmentChallengeV1, ): Promise { if (!organizationScope(context, challenge.organizationId)) throw new Error('SCOPE_DENIED'); - const existing = await this.client.deviceEnrollmentChallenge.findUnique({ - where: { id: challenge.id }, + const existing = await this.client.deviceEnrollmentChallenge.findFirst({ + where: { id: challenge.id, organizationId: context.tenantScope.organizationId }, }); if (!existing) { await this.client.deviceEnrollmentChallenge.create({ data: challengeData(challenge) }); @@ -251,7 +251,11 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio throw new Error('IMMUTABLE_CHALLENGE'); if (!this.client.deviceEnrollmentChallenge.updateMany) throw new Error('UPDATE_UNAVAILABLE'); const result = await this.client.deviceEnrollmentChallenge.updateMany({ - where: { id: challenge.id, revision: current.revision }, + where: { + id: challenge.id, + organizationId: context.tenantScope.organizationId, + revision: current.revision, + }, data: { status: challenge.status, revision: challenge.revision }, }); if (result.count !== 1) throw new Error('REVISION_CONFLICT'); @@ -261,16 +265,22 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio context: IamTenantContextV1, challengeId: StableIdentifierV1, ): Promise { - const row = await this.client.deviceEnrollmentChallenge.findUnique({ - where: { id: challengeId }, + if (context.tenantScope.scopeType !== 'organization') return undefined; + const row = await this.client.deviceEnrollmentChallenge.findFirst({ + where: { + id: challengeId, + organizationId: context.tenantScope.organizationId, + }, }); - if (!row || !organizationScope(context, row.organizationId)) return undefined; + if (!row) return undefined; return challengeFromRow(row); } public async saveDevice(context: IamTenantContextV1, device: DeviceIdentityV1): Promise { if (!organizationScope(context, device.organizationId)) throw new Error('SCOPE_DENIED'); - const existing = await this.client.deviceIdentity.findUnique({ where: { id: device.id } }); + const existing = await this.client.deviceIdentity.findFirst({ + where: { id: device.id, organizationId: context.tenantScope.organizationId }, + }); if (existing) { if (JSON.stringify(deviceFromRow(existing)) !== JSON.stringify(device)) throw new Error('IMMUTABLE_DEVICE'); @@ -283,8 +293,11 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio context: IamTenantContextV1, deviceId: StableIdentifierV1, ): Promise { - const row = await this.client.deviceIdentity.findUnique({ where: { id: deviceId } }); - if (!row || !organizationScope(context, row.organizationId)) return undefined; + if (context.tenantScope.scopeType !== 'organization') return undefined; + const row = await this.client.deviceIdentity.findFirst({ + where: { id: deviceId, organizationId: context.tenantScope.organizationId }, + }); + if (!row) return undefined; return deviceFromRow(row); } @@ -308,7 +321,11 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio if (device.revision !== expectedRevision + 1) throw new Error('INVALID_REVISION'); if (!this.client.deviceIdentity.updateMany) throw new Error('UPDATE_UNAVAILABLE'); const result = await this.client.deviceIdentity.updateMany({ - where: { id: device.id, revision: expectedRevision }, + where: { + id: device.id, + organizationId: context.tenantScope.organizationId, + revision: expectedRevision, + }, data: { publicKey: device.publicKey, status: device.status, diff --git a/services/api/test/features/iam/prisma-device-identity-repository.test.ts b/services/api/test/features/iam/prisma-device-identity-repository.test.ts index 475a3901..69761223 100644 --- a/services/api/test/features/iam/prisma-device-identity-repository.test.ts +++ b/services/api/test/features/iam/prisma-device-identity-repository.test.ts @@ -82,15 +82,23 @@ function device(): DeviceIdentityV1 { return result.value; } -function delegate(rows: Record[], forceRevisionConflict = false) { +function delegate( + rows: Record[], + forceRevisionConflict = false, + firstQueries?: Array>>, +) { return { create({ data }: { readonly data: Record }) { 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> }) { + firstQueries?.push(where); + return Promise.resolve( + rows.find((row) => Object.entries(where).every(([key, value]) => row[key] === value)) ?? + null, + ); }, findMany({ where }: { readonly where: Readonly> }) { return Promise.resolve( @@ -113,12 +121,12 @@ function delegate(rows: Record[], forceRevisionConflict = false where, data, }: { - readonly where: { readonly id: string; readonly revision: number }; + readonly where: Readonly>; readonly data: Record; }) { if (forceRevisionConflict) return Promise.resolve({ count: 0 }); - const index = rows.findIndex( - (row) => row['id'] === where.id && row['revision'] === where.revision, + const index = rows.findIndex((row) => + Object.entries(where).every(([key, value]) => row[key] === value), ); if (index < 0) return Promise.resolve({ count: 0 }); rows[index] = { ...rows[index], ...data }; @@ -128,13 +136,20 @@ function delegate(rows: Record[], forceRevisionConflict = false } function client( - options: { readonly forceRevisionConflict?: boolean } = {}, + options: { + readonly forceRevisionConflict?: boolean; + readonly firstQueries?: Array>>; + } = {}, ): DeviceIdentityDatabaseClientV1 { const challengeRows: Record[] = []; const deviceRows: Record[] = []; const database = { - deviceEnrollmentChallenge: delegate(challengeRows, options.forceRevisionConflict), - deviceIdentity: delegate(deviceRows, options.forceRevisionConflict), + deviceEnrollmentChallenge: delegate( + challengeRows, + options.forceRevisionConflict, + options.firstQueries, + ), + deviceIdentity: delegate(deviceRows, options.forceRevisionConflict, options.firstQueries), async $transaction( work: (transaction: DeviceIdentityDatabaseClientV1) => Promise, ) { @@ -197,3 +212,15 @@ void test('[IAM-007, IAM-021] Prisma device identity transitions reject database }; await assert.rejects(repository.replaceDevice(context(), active, 1), /REVISION_CONFLICT/u); }); + +void test('[IAM-009, IAM-019] Prisma device lookups bind identifiers to organization scope', async () => { + const firstQueries: Array>> = []; + const repository = new PrismaDeviceIdentityRepositoryAdapter(client({ firstQueries })); + await repository.saveChallenge(context(), challenge()); + await repository.saveDevice(context(), device()); + await repository.findChallenge(context(), stable(challengeId)); + await repository.findDevice(context(), stable(deviceId)); + + assert.equal(firstQueries.length, 4); + for (const query of firstQueries) assert.equal(query['organizationId'], organizationId); +}); From ead1d273f89b1869559a1585e0129a1805622e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:03:19 +0700 Subject: [PATCH 167/177] fix(aud): map read outages to unavailable --- .../src/features/aud/api/audit.controller.ts | 22 +++++++++-- .../aud/application/audit-problem.error.ts | 6 +++ .../platform/http/problem-details.filter.ts | 10 +++++ services/api/test/http-contract.test.ts | 38 +++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 services/api/src/features/aud/application/audit-problem.error.ts diff --git a/services/api/src/features/aud/api/audit.controller.ts b/services/api/src/features/aud/api/audit.controller.ts index 75c6b9bb..43b6269f 100644 --- a/services/api/src/features/aud/api/audit.controller.ts +++ b/services/api/src/features/aud/api/audit.controller.ts @@ -1,5 +1,10 @@ import { Controller, Get, Inject, Req } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; import { AUDIT_REPOSITORY_PORT, @@ -9,6 +14,7 @@ import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; +import { AuditProblemError } from '../application/audit-problem.error.js'; @ApiTags('audit') @ApiBearerAuth() @@ -21,15 +27,25 @@ export class AuditController { @Get('events') @ApiOperation({ summary: 'List immutable audit events visible to the caller' }) + @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' }) async events(@Req() request: unknown): Promise { const context = await this.requestContext.resolve(request); - return this.repository.listEvents(context); + try { + return await this.repository.listEvents(context); + } catch { + throw new AuditProblemError('AUDIT_UNAVAILABLE'); + } } @Get('seals') @ApiOperation({ summary: 'List verified audit seals visible to the caller' }) + @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' }) async seals(@Req() request: unknown): Promise { const context = await this.requestContext.resolve(request); - return this.repository.listSeals(context); + try { + return await this.repository.listSeals(context); + } catch { + throw new AuditProblemError('AUDIT_UNAVAILABLE'); + } } } diff --git a/services/api/src/features/aud/application/audit-problem.error.ts b/services/api/src/features/aud/application/audit-problem.error.ts new file mode 100644 index 00000000..3dc235a8 --- /dev/null +++ b/services/api/src/features/aud/application/audit-problem.error.ts @@ -0,0 +1,6 @@ +export class AuditProblemError extends Error { + public constructor(readonly code: 'AUDIT_UNAVAILABLE') { + super(code); + this.name = 'AuditProblemError'; + } +} diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index d4f77a47..41c82576 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -12,6 +12,7 @@ import { SessionProblemError } from '../../features/iam/application/session-prob import { MfaProblemError } from '../../features/iam/application/mfa-problem.error.js'; import { EntitlementProblemError } from '../../features/bua/application/entitlement-problem.error.js'; import { DeviceIdentityProblemError } from '../../features/iam/application/device-identity-problem.error.js'; +import { AuditProblemError } from '../../features/aud/application/audit-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'; @@ -95,6 +96,15 @@ function describe(error: unknown, correlationId: string): ProblemInput { status, }; } + if (error instanceof AuditProblemError) { + return { + code: error.code, + correlationId, + messageKey: 'api.error.audit_unavailable', + retryable: true, + status: HttpStatus.SERVICE_UNAVAILABLE, + }; + } if (error instanceof RequestTenantContextProblemError) { const invalidContext = error.code === 'CONTEXT_INVALID'; const unavailable = error.code === 'AUTHENTICATION_UNAVAILABLE'; diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index b1c75488..91bd14cd 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -9,6 +9,7 @@ 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'; +import { InMemoryAuditRepositoryAdapter } from '../src/features/aud/adapter/in-memory-audit-repository.adapter.js'; interface InjectResponse { readonly body: string; @@ -677,6 +678,43 @@ void test('protected artifact reads derive tenant scope from an authenticated ac ); }); +void test('audit read outages return retryable service-unavailable problems', async () => { + const auditRepository = Object.assign(new InMemoryAuditRepositoryAdapter(), { + listEvents: () => Promise.reject(new Error(`database ${leakedMarker}`)), + listSeals: () => Promise.reject(new Error(`database ${leakedMarker}`)), + }); + const principal = { + userId: '00000000-0000-4000-8000-000000000001', + organizationId: '00000000-0000-4000-8000-000000000002', + workspaceId: '00000000-0000-4000-8000-000000000003', + securityEpoch: 1, + mfaRequired: false, + } as const; + await withApp( + { + auditRepository, + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(principal), + findPrincipalByAccessToken: () => Promise.resolve(principal), + }, + }, + async (app) => { + for (const url of ['/v1/audit/events', '/v1/audit/seals']) { + const response = await app.inject({ + method: 'GET', + url, + headers: { authorization: 'Bearer audit-access-token-123456789' }, + }); + assertProblem(response, 503, 'AUDIT_UNAVAILABLE'); + assert.doesNotMatch(response.body, new RegExp(leakedMarker)); + } + }, + ); +}); + 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(), { From ab1e2a6baeed7f1f375a20bddcd6eda25953e98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:04:11 +0700 Subject: [PATCH 168/177] fix(aud): scope event identity lookup --- .../prisma-audit-repository.adapter.ts | 10 +++--- .../aud/prisma-audit-repository.test.ts | 35 +++++++++++++++---- 2 files changed, 33 insertions(+), 12 deletions(-) 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 index 1eb63f71..5a717d10 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -73,9 +73,6 @@ interface AuditSealCreateDataV1 extends Omit; - findUnique(input: { - readonly where: { readonly id: string }; - }): Promise; findFirst(input: { readonly where: Readonly>; readonly orderBy?: { readonly sequence: 'asc' | 'desc' }; @@ -284,8 +281,11 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { ): 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 }, + const existing = await this.client.auditEventRecord.findFirst({ + where: { + id: event.eventId, + organizationId: context.tenantScope.organizationId, + }, }); if (existing !== null) { const current = persistedEvent(existing); diff --git a/services/api/test/features/aud/prisma-audit-repository.test.ts b/services/api/test/features/aud/prisma-audit-repository.test.ts index 40d3006e..273ebad2 100644 --- a/services/api/test/features/aud/prisma-audit-repository.test.ts +++ b/services/api/test/features/aud/prisma-audit-repository.test.ts @@ -29,16 +29,16 @@ function context(workspace = workspaceId, idempotencyKey = 'audit') { return result.value; } -function delegate>(rows: TRow[]) { +function delegate>( + rows: TRow[], + firstQueries: Array>>, +) { 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, orderBy, @@ -46,6 +46,7 @@ function delegate>(rows: TRow[]) { readonly where: Readonly>; readonly orderBy?: Readonly>; }) { + firstQueries.push(where); const matching = rows.filter((row) => Object.entries(where).every(([key, value]) => row[key] === value), ); @@ -84,12 +85,14 @@ function delegate>(rows: TRow[]) { }; } -function client(): AuditDatabaseClientV1 { +function client( + firstQueries: Array>> = [], +): AuditDatabaseClientV1 { const eventRows: Record[] = []; const sealRows: Record[] = []; const database = { - auditEventRecord: delegate(eventRows), - auditSealRecord: delegate(sealRows), + auditEventRecord: delegate(eventRows, firstQueries), + auditSealRecord: delegate(sealRows, firstQueries), async $transaction( work: (transaction: AuditDatabaseClientV1) => Promise, ): Promise { @@ -180,3 +183,21 @@ void test('[AUD-002] Prisma audit transactions do not retain an event when the u ); assert.equal((await repository.listEvents(context(workspaceId, 'after'))).length, 0); }); + +void test('[AUD-003, IAM-009] Prisma audit event identity checks include tenant scope', async () => { + const firstQueries: Array>> = []; + const repository = new PrismaAuditRepositoryAdapter(client(firstQueries), digest); + const service = new AuditLedgerService(repository, digest); + const eventId = '00000000-0000-4000-8000-000000000125'; + + const appended = await service.append( + context(workspaceId, 'event-scoped'), + input(eventId, 'job.started'), + ); + assert.equal(appended.accepted, true); + assert.ok( + firstQueries.some( + (query) => query['id'] === eventId && query['organizationId'] === organizationId, + ), + ); +}); From e86f7ad1b8927038f2b74b01085f6a6f6dcd1f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:05:21 +0700 Subject: [PATCH 169/177] fix(bua): scope entitlement identity lookups --- .../prisma-entitlement-repository.adapter.ts | 46 ++++++++++++---- .../bua/prisma-entitlement-repository.test.ts | 53 ++++++++++++++++--- 2 files changed, 82 insertions(+), 17 deletions(-) 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 index b61f13f3..4fa88a19 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -120,6 +120,7 @@ interface DelegateV1 { findUnique(input: { readonly where: { readonly id?: string; readonly planCode?: string }; }): Promise; + findFirst(input: { readonly where: Readonly> }): Promise; findMany(input: { readonly where: Readonly>; readonly orderBy?: Readonly>; @@ -129,7 +130,7 @@ interface DelegateV1 { readonly data: Readonly>; }): Promise; updateMany?(input: { - readonly where: { readonly id: string; readonly revision: number }; + readonly where: Readonly>; readonly data: Readonly>; }): Promise<{ readonly count: number }>; } @@ -487,8 +488,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV : { 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 }, + const existing = await this.client.entitlementSnapshotRecord.findFirst({ + where: { + id: snapshot.snapshotId, + organizationId: snapshot.organizationId, + scopeKey: scopeKey(scope), + }, }); if (existing !== null) { if (!sameEntitlementSnapshotV1(persistedSnapshot(existing), snapshot)) @@ -502,8 +507,16 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV context: IamTenantContextV1, snapshotId: EntitlementSnapshotV1['snapshotId'], ): Promise { - const row = await this.client.entitlementSnapshotRecord.findUnique({ - where: { id: snapshotId }, + const workspaceId = + context.tenantScope.scopeType === 'organization' + ? undefined + : context.tenantScope.workspaceId; + const row = await this.client.entitlementSnapshotRecord.findFirst({ + where: { + id: snapshotId, + organizationId: context.tenantScope.organizationId, + ...(workspaceId === undefined ? {} : { OR: [{ workspaceId: null }, { workspaceId }] }), + }, }); if (row === null) return undefined; const snapshot = persistedSnapshot(row); @@ -568,8 +581,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV 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 }, + const existing = await this.client.usageLedgerEntryRecord.findFirst({ + where: { + id: entry.entryId, + organizationId: entry.tenantScope.organizationId, + scopeKey: scopeKey(entry.tenantScope), + }, }); if (existing !== null) { if (!sameUsageEntryV1(persistedEntry(existing), entry)) @@ -581,8 +598,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV 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 }, + const existing = await this.client.usageReservationRecord.findFirst({ + where: { + id: reservation.reservationId, + organizationId: reservation.tenantScope.organizationId, + scopeKey: scopeKey(reservation.tenantScope), + }, }); if (existing === null) { await this.client.usageReservationRecord.create({ @@ -600,7 +621,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV throw new Error('BUA_RESERVATION_CONFLICT'); if (!this.client.usageReservationRecord.updateMany) throw new Error('BUA_UPDATE_UNAVAILABLE'); const result = await this.client.usageReservationRecord.updateMany({ - where: { id: reservation.reservationId, revision: current.revision }, + where: { + id: reservation.reservationId, + organizationId: reservation.tenantScope.organizationId, + scopeKey: scopeKey(reservation.tenantScope), + revision: current.revision, + }, data: { status: reservation.status, revision: reservation.revision, updatedAt: new Date() }, }); if (result.count !== 1) throw new Error('BUA_RESERVATION_CONFLICT'); diff --git a/services/api/test/features/bua/prisma-entitlement-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-repository.test.ts index c13d7eb1..7f689d58 100644 --- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -75,7 +75,20 @@ function snapshot(): EntitlementSnapshotV1 { function delegate>( rows: TRow[], forceRevisionConflict = false, + firstQueries?: Array>>, ) { + const matches = (row: TRow, where: Readonly>): boolean => + Object.entries(where).every(([key, value]) => { + if (key === 'OR' && Array.isArray(value)) { + return value.some( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + matches(row, candidate as Readonly>), + ); + } + return row[key] === value; + }); return { create({ data }: { readonly data: TRow }) { const persisted = { ...data }; @@ -92,6 +105,10 @@ function delegate>( rows.find((row) => row['id'] === key || row['planCode'] === key) ?? null, ); }, + findFirst({ where }: { readonly where: Readonly> }) { + firstQueries?.push(where); + return Promise.resolve(rows.find((row) => matches(row, where)) ?? null); + }, findMany({ where, orderBy, @@ -132,9 +149,7 @@ function delegate>( readonly data: Record; }) { if (forceRevisionConflict) return Promise.resolve({ count: 0 }); - const index = rows.findIndex( - (row) => row['id'] === where.id && row['revision'] === where.revision, - ); + const index = rows.findIndex((row) => matches(row, where)); if (index < 0) return Promise.resolve({ count: 0 }); rows[index] = { ...rows[index], ...data } as TRow; return Promise.resolve({ count: 1 }); @@ -143,7 +158,10 @@ function delegate>( } function client( - options: { readonly forceRevisionConflict?: boolean } = {}, + options: { + readonly forceRevisionConflict?: boolean; + readonly firstQueries?: Array>>; + } = {}, ): EntitlementDatabaseClientV1 { const planRows: Record[] = []; const snapshotRows: Record[] = []; @@ -151,9 +169,13 @@ function client( const reservationRows: Record[] = []; const database = { entitlementPlanRecord: delegate(planRows), - entitlementSnapshotRecord: delegate(snapshotRows), - usageLedgerEntryRecord: delegate(entryRows), - usageReservationRecord: delegate(reservationRows, options.forceRevisionConflict), + entitlementSnapshotRecord: delegate(snapshotRows, false, options.firstQueries), + usageLedgerEntryRecord: delegate(entryRows, false, options.firstQueries), + usageReservationRecord: delegate( + reservationRows, + options.forceRevisionConflict, + options.firstQueries, + ), async $transaction( work: (transaction: EntitlementDatabaseClientV1) => Promise, ): Promise { @@ -269,3 +291,20 @@ void test('[BUA-012] Prisma entitlement adapter rejects a reservation settlement /BUA_RESERVATION_CONFLICT/u, ); }); + +void test('[BUA-003, BUA-004, IAM-009] Prisma entitlement identity lookups include tenant scope', async () => { + const firstQueries: Array>> = []; + const repository = new PrismaEntitlementRepositoryAdapter(client({ firstQueries })); + await repository.saveSnapshot(context(workspaceId, 'scope-snapshot'), snapshot()); + await repository.findSnapshot(context(workspaceId, 'scope-read'), snapshot().snapshotId); + const service = new EntitlementAdmissionService(repository); + const admitted = await service.admit( + context(workspaceId, 'scope-admit'), + admissionInput('scope-admit', '1'), + ); + assert.equal(admitted.accepted, true); + + const tenantQueries = firstQueries.filter((query) => query['id'] !== undefined); + assert.ok(tenantQueries.length >= 4); + for (const query of tenantQueries) assert.equal(query['organizationId'], organizationId); +}); From 784fa32604ccb942c6b7ab9e8653744419ae7b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:07:10 +0700 Subject: [PATCH 170/177] fix(iam): propagate session authority outages --- .../prisma-session-lifecycle.adapter.ts | 120 ++++++++---------- .../iam/prisma-session-lifecycle.test.ts | 26 ++++ 2 files changed, 82 insertions(+), 64 deletions(-) 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 index e5de3a6c..cfff05b2 100644 --- a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -444,16 +444,12 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { 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 { + 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); } public async findPrincipal( @@ -462,62 +458,58 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { 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 { + 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, + }); } } diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index f4eee384..7e71e0b8 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -211,3 +211,29 @@ void test('[IAM-005] revocation is idempotent and hides session principals after assert.equal(await adapter.findPrincipal(session.sessionId), undefined); assert.equal(await adapter.findPrincipalByAccessToken(session.accessToken), undefined); }); + +void test('[IAM-005] session authority database failures propagate to the HTTP availability boundary', async () => { + const accessDatabase = createDatabase(); + const accessAdapter = new PrismaSessionLifecycleAdapter(accessDatabase.client); + const accessSession = await accessAdapter.issue(principal, 'web'); + const accessDelegate = accessDatabase.client.accessTokenRecord as unknown as { + findUnique(input: unknown): Promise; + }; + accessDelegate.findUnique = () => Promise.reject(new Error('access database unavailable')); + await assert.rejects( + accessAdapter.findPrincipalByAccessToken(accessSession.accessToken), + /access database unavailable/u, + ); + + const sessionDatabase = createDatabase(); + const sessionAdapter = new PrismaSessionLifecycleAdapter(sessionDatabase.client); + const session = await sessionAdapter.issue(principal, 'desktop'); + const sessionDelegate = sessionDatabase.client.sessionRecord as unknown as { + findUnique(input: unknown): Promise; + }; + sessionDelegate.findUnique = () => Promise.reject(new Error('session database unavailable')); + await assert.rejects( + sessionAdapter.findPrincipal(session.sessionId), + /session database unavailable/u, + ); +}); From 7b628f21305ee8b43f94d9f55eb39bb224e4ecdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:10:10 +0700 Subject: [PATCH 171/177] fix(iam): bind sessions to tenant scope --- packages/domain/src/identity/v1.ts | 16 ++++++- packages/domain/test/identity-v1.test.mjs | 2 + .../migration.sql | 9 ++++ services/api/prisma/schema/iam.prisma | 3 ++ .../in-memory-session-lifecycle.adapter.ts | 2 + .../prisma-session-lifecycle.adapter.ts | 26 +++++++--- .../iam/prisma-session-lifecycle.test.ts | 47 ++++++++++++++----- 7 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 services/api/prisma/migrations/20260803010000_iam_session_scope_binding/migration.sql diff --git a/packages/domain/src/identity/v1.ts b/packages/domain/src/identity/v1.ts index 17fa79b9..f45324f3 100644 --- a/packages/domain/src/identity/v1.ts +++ b/packages/domain/src/identity/v1.ts @@ -95,6 +95,8 @@ export interface SessionRecordV1 { readonly schemaVersion: typeof IDENTITY_SCHEMA_VERSION_V1; readonly sessionId: StableIdentifierV1; readonly userId: StableIdentifierV1; + readonly organizationId: StableIdentifierV1; + readonly workspaceId: StableIdentifierV1; readonly familyId: StableIdentifierV1; readonly issuedAt: StrictUtcTimestampV1; readonly accessExpiresAt: StrictUtcTimestampV1; @@ -420,13 +422,21 @@ export function checkOwnerRemovalV1( export function createSessionRecordV1(input: { readonly sessionId: unknown; readonly userId: unknown; + readonly organizationId: unknown; + readonly workspaceId: unknown; readonly familyId: unknown; readonly issuedAt: unknown; readonly accessExpiresAt: unknown; readonly inactivityExpiresAt: unknown; readonly absoluteExpiresAt: unknown; }): IdentityResultV1 { - const ids = [stableId(input.sessionId), stableId(input.userId), stableId(input.familyId)]; + const ids = [ + stableId(input.sessionId), + stableId(input.userId), + stableId(input.organizationId), + stableId(input.workspaceId), + stableId(input.familyId), + ]; const times = [ timestamp(input.issuedAt), timestamp(input.accessExpiresAt), @@ -451,7 +461,9 @@ export function createSessionRecordV1(input: { schemaVersion: 1, sessionId: ids[0] as StableIdentifierV1, userId: ids[1] as StableIdentifierV1, - familyId: ids[2] as StableIdentifierV1, + organizationId: ids[2] as StableIdentifierV1, + workspaceId: ids[3] as StableIdentifierV1, + familyId: ids[4] as StableIdentifierV1, issuedAt, accessExpiresAt, inactivityExpiresAt, diff --git a/packages/domain/test/identity-v1.test.mjs b/packages/domain/test/identity-v1.test.mjs index 1c31128b..a539a164 100644 --- a/packages/domain/test/identity-v1.test.mjs +++ b/packages/domain/test/identity-v1.test.mjs @@ -123,6 +123,8 @@ test('[IAM-005, IAM-012] session access lifetime and fresh step-up are bounded', const session = createSessionRecordV1({ sessionId: id('30'), userId: id('1'), + organizationId: id('2'), + workspaceId: id('3'), familyId: id('31'), issuedAt: createdAt, accessExpiresAt: '2026-01-01T00:15:00.000Z', diff --git a/services/api/prisma/migrations/20260803010000_iam_session_scope_binding/migration.sql b/services/api/prisma/migrations/20260803010000_iam_session_scope_binding/migration.sql new file mode 100644 index 00000000..2002ffd2 --- /dev/null +++ b/services/api/prisma/migrations/20260803010000_iam_session_scope_binding/migration.sql @@ -0,0 +1,9 @@ +-- IAM-005/IAM-019: bind every new session to the exact tenant ancestry selected at sign-in. +-- This repository has no production or legacy data migration. Existing development databases +-- with active sessions must be recreated because guessing tenant scope would be unsafe. +ALTER TABLE "iam"."sessions" + ADD COLUMN "organization_id" UUID NOT NULL, + ADD COLUMN "workspace_id" UUID NOT NULL; + +CREATE INDEX "sessions_scope_user_status_idx" + ON "iam"."sessions"("organization_id", "workspace_id", "user_id", "status"); diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index 0dd1e5b9..b75cc8db 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -92,6 +92,8 @@ model MembershipIdentity { model SessionRecord { id String @id @db.Uuid userId String @map("user_id") @db.Uuid + organizationId String @map("organization_id") @db.Uuid + workspaceId String @map("workspace_id") @db.Uuid familyId String @map("family_id") @db.Uuid issuedAt DateTime @map("issued_at") @db.Timestamptz(6) accessExpiresAt DateTime @map("access_expires_at") @db.Timestamptz(6) @@ -102,6 +104,7 @@ model SessionRecord { revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) @@index([userId, status], map: "sessions_user_status_idx") + @@index([organizationId, workspaceId, userId, status], map: "sessions_scope_user_status_idx") @@index([familyId], map: "sessions_family_idx") @@map("sessions") @@schema("iam") 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 1030359a..37cad50a 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 @@ -92,6 +92,8 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { const created = createSessionRecordV1({ sessionId: sessionIdentifier, userId: principal.userId, + organizationId: principal.organizationId, + workspaceId: principal.workspaceId, familyId: familyIdentifier, issuedAt: now.toISOString(), accessExpiresAt: addSeconds(now, ACCESS_TOKEN_SECONDS_V1), 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 index cfff05b2..468cc9e1 100644 --- a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -26,6 +26,8 @@ import type { export interface SessionRecordDatabaseRowV1 { readonly id: string; readonly userId: string; + readonly organizationId: string; + readonly workspaceId: string; readonly familyId: string; readonly issuedAt: Date; readonly accessExpiresAt: Date; @@ -183,6 +185,8 @@ function sessionFromRow(row: SessionRecordDatabaseRowV1): SessionRecordV1 { const created = createSessionRecordV1({ sessionId: row.id, userId: row.userId, + organizationId: row.organizationId, + workspaceId: row.workspaceId, familyId: row.familyId, issuedAt: timestamp(row.issuedAt), accessExpiresAt: timestamp(row.accessExpiresAt), @@ -245,6 +249,8 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { const created = createSessionRecordV1({ sessionId, userId: principal.userId, + organizationId: principal.organizationId, + workspaceId: principal.workspaceId, familyId, issuedAt: now.toISOString(), accessExpiresAt: addSeconds(now, ACCESS_TOKEN_SECONDS_V1), @@ -261,6 +267,8 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { data: { id: record.sessionId, userId: record.userId, + organizationId: record.organizationId, + workspaceId: record.workspaceId, familyId: record.familyId, issuedAt: new Date(record.issuedAt), accessExpiresAt: new Date(record.accessExpiresAt), @@ -474,19 +482,23 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { 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' }, + where: { + principalId: session.userId, + organizationId: session.organizationId, + status: 'ACTIVE', + }, }); const membership = memberships.find( (candidate) => candidate.principalId === session.userId && - candidate.scopeType === 'WORKSPACE' && + candidate.organizationId === session.organizationId && candidate.projectId === null && - parseStableIdentifierV1(candidate.organizationId).accepted && - parseStableIdentifierV1(candidate.workspaceId).accepted, + ((candidate.scopeType === 'ORGANIZATION' && candidate.workspaceId === null) || + (candidate.scopeType === 'WORKSPACE' && candidate.workspaceId === session.workspaceId)), ); - if (!membership || !membership.workspaceId) return undefined; - const organizationId = parseStableIdentifierV1(membership.organizationId); - const workspaceId = parseStableIdentifierV1(membership.workspaceId); + if (!membership) return undefined; + const organizationId = parseStableIdentifierV1(session.organizationId); + const workspaceId = parseStableIdentifierV1(session.workspaceId); if (!organizationId.accepted || !workspaceId.accepted) return undefined; const [organization, workspace, factors] = await Promise.all([ this.client.organizationIdentity.findUnique({ where: { id: organizationId.value } }), diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index 7e71e0b8..42b5f762 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -26,10 +26,23 @@ function createDatabase(): { readonly sessions: Map; readonly refreshTokens: Map; readonly accessTokens: Map; + readonly membershipQueries: ReadonlyArray>>; } { const sessions = new Map(); const refreshTokens = new Map(); const accessTokens = new Map(); + const membershipQueries: Array>> = []; + const membershipRows = [ + { + id: '00000000-0000-4000-8000-000000000004', + principalId: userId, + organizationId, + workspaceId, + projectId: null, + scopeType: 'WORKSPACE', + status: 'ACTIVE', + }, + ]; const client = { sessionRecord: { create: async ({ data }: { readonly data: SessionRecordDatabaseRowV1 }) => { @@ -118,17 +131,12 @@ function createDatabase(): { 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', - }, - ], + findMany: async ({ where }: { readonly where: Readonly> }) => { + membershipQueries.push(where); + return membershipRows.filter((row) => + Object.entries(where).every(([key, value]) => row[key as keyof typeof row] === value), + ); + }, }, workspaceIdentity: { findUnique: async () => ({ id: workspaceId, organizationId, status: 'ACTIVE' }), @@ -143,7 +151,7 @@ function createDatabase(): { work: (transaction: SessionLifecycleDatabaseClientV1) => Promise, ) => work(client), } as unknown as SessionLifecycleDatabaseClientV1; - return { client, sessions, refreshTokens, accessTokens }; + return { client, sessions, refreshTokens, accessTokens, membershipQueries }; } void test('[IAM-005, IAM-006] Prisma sessions persist opaque bounded access and refresh credentials', async () => { @@ -237,3 +245,18 @@ void test('[IAM-005] session authority database failures propagate to the HTTP a /session database unavailable/u, ); }); + +void test('[IAM-005, IAM-019] persisted sessions retain the exact sign-in tenant scope', async () => { + const { client, sessions, membershipQueries } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client); + const session = await adapter.issue(principal, 'web'); + + assert.equal(sessions.get(session.sessionId)?.organizationId, organizationId); + assert.equal(sessions.get(session.sessionId)?.workspaceId, workspaceId); + assert.equal((await adapter.findPrincipal(session.sessionId))?.workspaceId, workspaceId); + assert.deepEqual(membershipQueries.at(-1), { + principalId: userId, + organizationId, + status: 'ACTIVE', + }); +}); From 12b8c3a0564c8e2c4e41049924054aba36b5a3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:15:46 +0700 Subject: [PATCH 172/177] docs(traceability): reconcile identity foundation coverage --- ...t-entitlement-reconciliation-2026-08-03.md | 29 + docs/plans/requirement-traceability.json | 1638 +++++++---------- 2 files changed, 710 insertions(+), 957 deletions(-) create mode 100644 docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md diff --git a/docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md b/docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md new file mode 100644 index 00000000..f5b99d78 --- /dev/null +++ b/docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md @@ -0,0 +1,29 @@ +# Identity, audit, and entitlement reconciliation — 2026-08-03 + +## Scope + +This evidence record covers the 30-commit `feat/foundation-identity-reconciliation` batch based on `dev`. The batch hardens existing IAM, AUD, and BUA foundations; it does not claim completion of Plan 020 or any production release gate. + +## Implemented in this batch + +- IAM persistence now compares only owned immutable fields, scopes membership and Device lookups before row materialization, applies optimistic revisions to MFA state, binds sessions to the exact sign-in organization/workspace, expires access credentials, separates rejected credentials from authority outages, authorizes sign-out ownership, and bounds cookie/CSRF parsing. +- Membership authority flows only from a containing tenant scope, and the narrowest applicable membership wins. A project or workspace membership cannot authorize its parent or a sibling. +- AUD append paths use scoped, bounded replay/latest lookups; immutable comparisons ignore persistence metadata; read outages return safe retryable `AUDIT_UNAVAILABLE` problems. +- BUA tenant-owned identity lookups and usage reads are scope-bound; immutable comparisons ignore persistence metadata; reservations allow only one terminal transition; API failures return stable Problem Details. + +## Conservative requirement state + +The traceability manifest marks only requirements with concrete implementation and tests as `partial`. All remain `not-verified`; no P0/P1 release status is promoted. Requirements whose primary behavior is absent—such as invitations, service accounts, account recovery, audit exports/legal holds, commercial billing reconciliation, and usage exports—remain `planned`. + +## Evidence + +- Domain tests: `packages/domain/test/identity-v1.test.mjs`, `packages/domain/test/audit-v1.test.mjs`, `packages/domain/test/entitlements-v1.test.mjs`, and tenant/authorization/CSRF/MFA suites. +- API tests: `services/api/test/features/iam/`, `services/api/test/features/aud/`, `services/api/test/features/bua/`, `services/api/test/platform/http/`, and `services/api/test/http-contract.test.ts`. +- Persistence: `services/api/prisma/schema/iam.prisma`, `services/api/prisma/schema/aud.prisma`, `services/api/prisma/schema/bua.prisma`, and the ordered IAM session-scope migration. +- Focused verification passed throughout the batch, including TypeScript compilation, Prisma schema validation, 122 domain tests, and the affected API suites. + +## Remaining gates + +- Plan 020 still requires invitations, ownership transfer workflows, service accounts, signed offline authorization issuance, full permission enforcement, audit action-definition governance, signed independent seals, legal holds/retention/export/restore, provider-independent subscriptions, offline entitlement issuance, reconciliation/exports, client administration surfaces, and real PostgreSQL/backup/security evidence. +- FND-003 remains blocked only on live Docker daemon evidence; this batch does not change its status. +- The feature PR targets `dev` without CodeRabbit. CodeRabbit remains reserved for the later `dev` to `main` promotion PR and is invoked once there. diff --git a/docs/plans/requirement-traceability.json b/docs/plans/requirement-traceability.json index aebe5bba..0934124d 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -731,26 +731,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -762,26 +756,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -793,26 +781,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -824,26 +806,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -855,26 +831,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -886,26 +856,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -917,26 +881,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -948,26 +906,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -979,26 +931,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1010,26 +956,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1041,26 +981,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1072,26 +1006,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1103,23 +1031,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1134,26 +1056,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1165,26 +1081,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1196,23 +1106,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1227,23 +1131,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1258,23 +1156,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1289,26 +1181,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1320,23 +1206,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1351,23 +1231,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1382,23 +1256,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1413,23 +1281,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1444,26 +1306,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" @@ -1475,26 +1331,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1506,26 +1356,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1537,26 +1381,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1568,26 +1406,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1599,26 +1431,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1630,23 +1456,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1661,26 +1481,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1692,26 +1506,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1723,23 +1531,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1754,23 +1556,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1785,23 +1581,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1816,26 +1606,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1847,23 +1631,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1878,23 +1656,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1909,26 +1681,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" @@ -1940,23 +1706,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1971,23 +1731,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -2002,23 +1756,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -2033,23 +1781,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -2064,23 +1806,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -2095,26 +1831,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -2126,26 +1856,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9225,26 +8949,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9256,26 +8980,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9287,26 +9011,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9318,26 +9042,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9349,26 +9073,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9380,26 +9104,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9411,26 +9135,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9442,26 +9166,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9473,26 +9197,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9504,23 +9228,23 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -9535,26 +9259,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9566,26 +9290,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9597,23 +9321,23 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -9628,26 +9352,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" @@ -9659,23 +9383,23 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -9690,26 +9414,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" @@ -9721,23 +9445,23 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -9752,23 +9476,23 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -9783,26 +9507,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9814,26 +9538,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9845,26 +9569,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" From 2c12a911210a0d92f381f114dbe39a4ea037f247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:15:53 +0700 Subject: [PATCH 173/177] docs(git): enforce 30 to 50 commit PR slices --- docs/plans/000-platform-program.md | 2 +- docs/plans/003-luna-handoff-runbook.md | 2 +- docs/plans/004-luna-max-execution-plan.md | 44 +++++++++---------- docs/plans/execution-orchestration.json | 36 +++++++-------- .../src/check-execution-orchestration.mjs | 6 +-- .../test/execution-orchestration.test.mjs | 6 +-- 6 files changed, 48 insertions(+), 48 deletions(-) diff --git a/docs/plans/000-platform-program.md b/docs/plans/000-platform-program.md index f9d652b6..df6dc4b7 100644 --- a/docs/plans/000-platform-program.md +++ b/docs/plans/000-platform-program.md @@ -43,7 +43,7 @@ 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. +`docs/plans/004-luna-max-execution-plan.md` packages the unfinished orchestration tasks into 15 dependency-safe delivery batches. A normal PR slice contains 30–50 atomic commits; an exceptional completed-task boundary may extend to 79 but never to CodeRabbit's 100-commit limit. Promotion diffs stay at or below 260 changed files and use the `feat/*` or `fix/*` → `dev` → reviewed `main` flow below. ## Branch, commit, and review policy diff --git a/docs/plans/003-luna-handoff-runbook.md b/docs/plans/003-luna-handoff-runbook.md index 80fdefce..ab799071 100644 --- a/docs/plans/003-luna-handoff-runbook.md +++ b/docs/plans/003-luna-handoff-runbook.md @@ -91,7 +91,7 @@ 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. 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. +12. Recount the active PR slice against its base. Do not open below 30 commits; target 30–50, stop accepting new tasks at 50, and split at the next completed-task boundary. An exceptional boundary must never exceed 79 commits, preserving margin below CodeRabbit's 100-commit limit. 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 diff --git a/docs/plans/004-luna-max-execution-plan.md b/docs/plans/004-luna-max-execution-plan.md index 4b4f54bd..0bb9e8be 100644 --- a/docs/plans/004-luna-max-execution-plan.md +++ b/docs/plans/004-luna-max-execution-plan.md @@ -17,7 +17,7 @@ - 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. +- Normal feature PR slices contain 30–50 commits. If an atomic task crosses 50, finish that task and split immediately; the exceptional ceiling is 79, preserving margin below CodeRabbit's 100-commit limit. 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. @@ -36,7 +36,7 @@ This checkpoint was reconciled on 2026-08-02 after the latest promotion: | 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` | +| Requirement ledger | 611 total: 565 `planned`, 46 `partial`, 0 `verified` | | Next orchestration task | `FND-003` | | Active delivery batch | `B01` | @@ -60,27 +60,27 @@ Some early child plans contain generic aggregate `Paths` examples. Do not create ## 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. +Each batch may require multiple normal integration PR slices before its exit gate passes. Every slice is followed by its own promotion PR. Commit ranges are review budgets, not quotas. If a coherent slice finishes below 30 commits, keep the branch open and continue the next compatible task; do not create padding 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`. +| `B01` | `feat/foundation-identity-reconciliation` | `FND-003..007`, all Plan 020 tasks | Verified `FND-001/002` | 30–50 target; exceptional ceiling 79 | Foundation external gates recorded; IAM/AUD/BUA obligations reconciled and completed | +| `B02` | `feat/artifacts-datasets-completion` | All Plan 030 tasks | `B01` | 30–50 target; exceptional ceiling 79 | Immutable artifact/evidence/dataset foundations verified | +| `B03` | `feat/jobs-processing-completion` | All Plan 040 tasks | `B02` | 30–50 target; exceptional ceiling 79 | Signed typed jobs execute locally/cloud with approvals and durable recovery | +| `B04` | `feat/devices-sync-completion` | All Plan 050 tasks | `B03` | 30–50 target; exceptional ceiling 79 | Desktop/Android sync, offline, conflict, transfer, and revocation gates pass | +| `B05` | `feat/collaboration-integrations` | All Plan 060 tasks | `B04` | 30–50 target; exceptional ceiling 79 | Notifications, collaboration, public API, connectors, and webhooks pass | +| `B06` | `feat/dogfood-autopilot-core` | `DOG-001..007`, `FA-001..003` | `B05` | 30–50 target; exceptional ceiling 79 | Ten-condition dogfood record accepted; safe Autopilot intake/routing exists | +| `B07` | `feat/autopilot-spreadsheet-auditor` | `FA-004..007`, `SA-001..007` | `B06` | 30–50 target; exceptional ceiling 79 | Folder Autopilot and Spreadsheet Auditor P0/P1 gates pass | +| `B08` | `feat/quote-invoice-intelligence` | `QI-001..007`, then `ILD-001..007` | `B06` | 30–50 target; exceptional ceiling 79 | Quote Intelligence and Invoice Leak Detector P0/P1 gates pass | +| `B09` | `feat/operations-capture` | `OC-001..008` | `B06` | 30–50 target; exceptional ceiling 79 | Offline native capture, immutable submission, supervision, and reconciliation pass | +| `B10` | `feat/client-report-factory` | `CRF-001..007` | `B07`, `B08` | 30–50 target; exceptional ceiling 79 | Evidence-linked multi-format reports and revocable sharing pass | +| `B11` | `feat/private-data-analyst` | `PDA-001..008` | `B09`, `B10` | 30–50 target; exceptional ceiling 79 | Deterministic governed analysis and optional-AI boundaries pass | +| `B12` | `feat/migration-quality-suite` | `MR-001..007`, then `DQG-001..008` | `B08`, `B11` | 30–50 target; exceptional ceiling 79 | Migration Ready and Data Quality Guard P0/P1 gates pass | +| `B13` | `feat/embedded-importer` | `EI-001..007` | `B05` | 30–50 target; exceptional ceiling 79 | Hosted importer and outbound-only local gateway pass hostile tests | +| `B14` | `feat/production-readiness` | `GA-001..012` | `B12`, `B13` | 30–50 target; exceptional ceiling 79 | Every P0/P1 requirement is verified and coordinated GA is released | +| `B15` | `feat/post-ga-extensions` | `P2-001..004` | `B14` | 30–50 target; exceptional ceiling 79 | 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 PR-slice minimum below 30, an exceptional maximum above 79, and an active batch that does not contain `nextTaskId`. ## 4. Parallel execution and integration ownership @@ -118,7 +118,7 @@ Typical reversible commits inside a task are: canonical contract, domain behavio ## 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. +2. Do not open the normal PR below 30 commits. At 30–50 commits, finish the current atomic task and prepare the PR. At 50, stop accepting new tasks and split at the next completed-task boundary. An exceptional boundary must never exceed 79 commits. 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. @@ -129,7 +129,7 @@ Focused promotion-gate fixes may use a smaller PR to `dev` because they close an ## 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. +The active B01 PR slice is `feat/foundation-identity-reconciliation`, based on the fetched `origin/dev` merge checkpoint. Continue B01 through additional branches after each 30–50 commit slice; do not claim the batch complete until its exit gate passes. Run these commands sequentially: diff --git a/docs/plans/execution-orchestration.json b/docs/plans/execution-orchestration.json index 37be5e90..9f334dfd 100644 --- a/docs/plans/execution-orchestration.json +++ b/docs/plans/execution-orchestration.json @@ -40,8 +40,8 @@ }, "commitBudget": { "preferredMinimum": 30, - "preferredMaximum": 70, - "hardMaximum": 99 + "preferredMaximum": 50, + "hardMaximum": 79 } }, "statusVocabulary": [ @@ -144,10 +144,10 @@ { "batchId": "B01", "name": "Foundation verification and identity completion", - "branch": "feat/foundation-identity-completion", + "branch": "feat/foundation-identity-reconciliation", "dependencies": [], "status": "in-progress", - "commitBudget": { "minimum": 30, "target": 70, "maximum": 85 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": [ "FND-003", @@ -175,7 +175,7 @@ "branch": "feat/artifacts-datasets-completion", "dependencies": ["B01"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." @@ -186,7 +186,7 @@ "branch": "feat/jobs-processing-completion", "dependencies": ["B02"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." @@ -197,7 +197,7 @@ "branch": "feat/devices-sync-completion", "dependencies": ["B03"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." @@ -208,7 +208,7 @@ "branch": "feat/collaboration-integrations", "dependencies": ["B04"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." @@ -219,7 +219,7 @@ "branch": "feat/dogfood-autopilot-core", "dependencies": ["B05"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." @@ -230,7 +230,7 @@ "branch": "feat/autopilot-spreadsheet-auditor", "dependencies": ["B06"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 70, "maximum": 85 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." @@ -241,7 +241,7 @@ "branch": "feat/quote-invoice-intelligence", "dependencies": ["B06"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 75, "maximum": 90 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." @@ -252,7 +252,7 @@ "branch": "feat/operations-capture", "dependencies": ["B06"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." @@ -263,7 +263,7 @@ "branch": "feat/client-report-factory", "dependencies": ["B07", "B08"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." @@ -274,7 +274,7 @@ "branch": "feat/private-data-analyst", "dependencies": ["B09", "B10"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." @@ -285,7 +285,7 @@ "branch": "feat/migration-quality-suite", "dependencies": ["B08", "B11"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 80, "maximum": 95 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." @@ -296,7 +296,7 @@ "branch": "feat/embedded-importer", "dependencies": ["B05"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 60, "maximum": 70 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." @@ -307,7 +307,7 @@ "branch": "feat/production-readiness", "dependencies": ["B12", "B13"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 75, "maximum": 90 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." @@ -318,7 +318,7 @@ "branch": "feat/post-ga-extensions", "dependencies": ["B14"], "status": "post-ga-planned", - "commitBudget": { "minimum": 30, "target": 60, "maximum": 70 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "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." diff --git a/tools/repo-cli/src/check-execution-orchestration.mjs b/tools/repo-cli/src/check-execution-orchestration.mjs index 6ee2c595..ddab9773 100644 --- a/tools/repo-cli/src/check-execution-orchestration.mjs +++ b/tools/repo-cli/src/check-execution-orchestration.mjs @@ -25,7 +25,7 @@ const expectedPlans = new Map([ ]); const expectedPriorityTotals = { P0: 444, P1: 154, P2: 13 }; const expectedReviewPolicy = { - commitBudget: { hardMaximum: 99, preferredMaximum: 70, preferredMinimum: 30 }, + commitBudget: { hardMaximum: 79, preferredMaximum: 50, preferredMinimum: 30 }, featurePullRequest: { base: 'dev', codeRabbit: false, mergeAfterHostedChecks: true }, promotionPullRequest: { base: 'main', @@ -160,8 +160,8 @@ function validateDeliveryBatches({ ledger, plans, taskIds, taskToPlan, diagnosti ) { 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(budget.maximum) || budget.maximum > 79) { + diagnostics.push(`batch ${batch.batchId} exceptional commit maximum must not exceed 79`); } if ( !Number.isInteger(batch.maximumChangedFiles) || diff --git a/tools/repo-cli/test/execution-orchestration.test.mjs b/tools/repo-cli/test/execution-orchestration.test.mjs index ab338bd8..d0ebbc11 100644 --- a/tools/repo-cli/test/execution-orchestration.test.mjs +++ b/tools/repo-cli/test/execution-orchestration.test.mjs @@ -142,8 +142,8 @@ test('handoff policy preserves the requested dev and main review flow', () => { }); assert.deepEqual(ledger.reviewPolicy.commitBudget, { preferredMinimum: 30, - preferredMaximum: 70, - hardMaximum: 99, + preferredMaximum: 50, + hardMaximum: 79, }); }); @@ -167,7 +167,7 @@ test('delivery batches cover every unfinished task once within review budgets', 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.commitBudget.maximum <= 79); assert.ok(batch.maximumChangedFiles <= 260); } const activeBatch = ledger.deliveryBatches.find( From 8e9d753842a3895fc147538d2cd82d3e317c5c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:17:12 +0700 Subject: [PATCH 174/177] style(iam): format bootstrap repository --- .../prisma-identity-bootstrap-repository.adapter.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 index 6a35b8c3..350fdc55 100644 --- 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 @@ -93,10 +93,7 @@ function valuesEqual(left: unknown, right: unknown): boolean { return left === right; } -function ownedFieldsMatch( - existing: TRow, - expected: TRow, -): boolean { +function ownedFieldsMatch(existing: TRow, expected: TRow): boolean { const existingRecord = existing as Record; const expectedRecord = expected as Record; return Object.keys(expectedRecord).every((key) => @@ -305,8 +302,7 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran ): Promise { const existing = await delegate.findUnique({ where: { id: expected.id } }); if (existing) { - if (!ownedFieldsMatch(existing, expected)) - throw new Error('IAM_BOOTSTRAP_CONFLICT'); + if (!ownedFieldsMatch(existing, expected)) throw new Error('IAM_BOOTSTRAP_CONFLICT'); return; } await delegate.create({ data: expected }); From 47db553184e1567a8e63840df4d3bfde7ee077cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:28:44 +0700 Subject: [PATCH 175/177] test(api): register session scope migration --- services/api/test/prisma-foundation.test.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 6d13ca9f..fc538be6 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -122,6 +122,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802290000_dsm_export_manifests', '20260802300000_sa_spreadsheet_audits', '20260803000000_iae_lineage_uniqueness', + '20260803010000_iam_session_scope_binding', 'migration_lock.toml', ]); const migration = await readFile( @@ -502,4 +503,19 @@ test('the schema diff and centrally ordered migration inventory establish platfo lineageUniquenessMigration, /CREATE UNIQUE INDEX "artifact_lineage_derived_version_key"/, ); + const sessionScopeMigration = await readFile( + path.join(migrationsDirectory, inventory[33], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'ALTER TABLE "iam"."sessions"', + 'ADD COLUMN "organization_id" UUID NOT NULL', + 'ADD COLUMN "workspace_id" UUID NOT NULL', + 'CREATE INDEX "sessions_scope_user_status_idx"', + ]) { + assert.match( + sessionScopeMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From d323c02994c177ffbec080fa025607491dd58ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:34:18 +0700 Subject: [PATCH 176/177] test(web): tolerate lazy route startup under load --- apps/web/test/error-privacy-query.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/test/error-privacy-query.test.tsx b/apps/web/test/error-privacy-query.test.tsx index bf103e31..ec1a1f33 100644 --- a/apps/web/test/error-privacy-query.test.tsx +++ b/apps/web/test/error-privacy-query.test.tsx @@ -13,7 +13,9 @@ describe('safe localized recovery', () => { const router = createAppRouter({ initialEntries: ['/vi-VN/debug/route-error'] }); render(); - expect(await screen.findByRole('heading', { name: 'Không thể mở khu vực này' })).toBeTruthy(); + expect( + await screen.findByRole('heading', { name: 'Không thể mở khu vực này' }, { timeout: 5_000 }), + ).toBeTruthy(); expect(screen.queryByText(/internal tenant detail/u)).toBeNull(); consoleError.mockRestore(); }); From 5d098430463ce38d56d79ed9129c873a67fdc063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:43:41 +0700 Subject: [PATCH 177/177] fix(aud): preserve success responses in OpenAPI --- services/api/openapi/v1.json | 26 +++++++++++++++++++ .../src/features/aud/api/audit.controller.ts | 3 +++ services/api/test/openapi.test.ts | 6 +++++ 3 files changed, 35 insertions(+) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 3d35a340..d41c6328 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -6804,6 +6804,19 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Audit 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" } + } + } } }, "security": [{ "bearer": [] }], @@ -6872,6 +6885,19 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Audit 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" } + } + } } }, "security": [{ "bearer": [] }], diff --git a/services/api/src/features/aud/api/audit.controller.ts b/services/api/src/features/aud/api/audit.controller.ts index 43b6269f..668709c8 100644 --- a/services/api/src/features/aud/api/audit.controller.ts +++ b/services/api/src/features/aud/api/audit.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Inject, Req } from '@nestjs/common'; import { ApiBearerAuth, + ApiOkResponse, ApiOperation, ApiServiceUnavailableResponse, ApiTags, @@ -27,6 +28,7 @@ export class AuditController { @Get('events') @ApiOperation({ summary: 'List immutable audit events visible to the caller' }) + @ApiOkResponse() @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' }) async events(@Req() request: unknown): Promise { const context = await this.requestContext.resolve(request); @@ -39,6 +41,7 @@ export class AuditController { @Get('seals') @ApiOperation({ summary: 'List verified audit seals visible to the caller' }) + @ApiOkResponse() @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' }) async seals(@Req() request: unknown): Promise { const context = await this.requestContext.resolve(request); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index b2040d55..cf1a55d7 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -193,6 +193,12 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, } } + for (const path of ['/v1/audit/events', '/v1/audit/seals'] as const) { + const auditRead = firstDocument.paths[path]?.get as OperationLike | undefined; + assert.ok(auditRead?.responses['200'], `${path} must document its successful response`); + assert.ok(auditRead.responses['503'], `${path} must document audit persistence outages`); + } + const served = await first.app.inject({ method: 'GET', url: '/v1/openapi.json' }); assert.equal(served.statusCode, 200); assert.deepEqual(served.json(), firstDocument);