diff --git a/.gitattributes b/.gitattributes
index bb742aa2..f321e0e1 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -23,6 +23,7 @@
*.kt text eol=lf
*.kts text eol=lf
*.py text eol=lf
+*.sh text eol=lf
*.ps1 text eol=crlf
*.bat text eol=crlf
*.cmd text eol=crlf
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();
});
diff --git a/docs/operations/coderabbit-pr-29-disposition.md b/docs/operations/coderabbit-pr-29-disposition.md
new file mode 100644
index 00000000..7eec50f6
--- /dev/null
+++ b/docs/operations/coderabbit-pr-29-disposition.md
@@ -0,0 +1,51 @@
+# CodeRabbit PR 29 Disposition
+
+Date: 2026-08-03
+Promotion PR: [#29](https://github.com/DatabreezeService/databreeze-platform/pull/29)
+Automatic review run: `f61cec20-123e-4694-9265-e71aa976b01b`
+Reviewed range: `3ed3d77d..86f25c85`
+
+CodeRabbit ran once automatically on the promotion PR. No manual rerun was requested. Every inline, outside-diff, and review-body finding was reproduced against the later `dev` state. Valid gaps were fixed on `fix/coderabbit-promotion-29`; findings already addressed by later `dev` commits are recorded rather than duplicated.
+
+| ID | Finding | Disposition | Evidence |
+|---|---|---|---|
+| I-01 | Reservation settlement lacked a revision predicate. | Accepted; already fixed on later `dev`. | `216f4a1`, Prisma reservation race test. |
+| I-02 | Membership updates could lose a concurrent write. | Accepted; already fixed on later `dev`. | `237ba56`, Prisma membership race test. |
+| I-03 | Bootstrap immutability used `JSON.stringify`. | Accepted; already fixed on later `dev`. | `e6800db`, owned-field comparison tests. |
+| I-04 | Sign-out did not prove session ownership. | Accepted; already fixed on later `dev`. | `295b911`, cross-user sign-out rejection test. |
+| O-01 | API composition did not expose audit and entitlement database options. | Accepted; already fixed on later `dev`. | `4d3f40d`, foundation composition test. |
+| M-01 | BUA dropped project scope from usage rows. | Accepted and fixed. Project IDs are persisted, indexed, reconstructed, and included in inherited reads. | `ccbb9d3`, project usage round-trip test, migration `20260803020000_bua_project_usage_scope`. |
+| M-02 | Public audit reads were unbounded. | Accepted and fixed. Public event/seal reads now use limits of 1–100 and tenant-bound opaque cursors; event pages verify each immutable digest. | `a5ba31b`, `72064d6`, `bbca81c`, cursor/Prisma/HTTP tests. |
+| M-03 | Direct BUA usage persistence was not transactional. | Accepted and fixed. | `a8a5a47`, transaction invocation test. |
+| M-04 | Audit append loaded the complete scope history. | Accepted; already fixed on later `dev`. | `ebe73cf`, bounded duplicate/latest lookups. |
+| M-05 | Audit reads allegedly verified multiple scopes as one chain. | Rejected as a false positive. The reviewed domain implementation already groups events by canonical scope before verifying each chain. | `packages/domain/src/audit/v1.ts`, multi-scope grouping in `verifyAuditChainV1`. |
+| M-06 | Cookie-name validation rejected valid token characters. | Accepted and fixed. | `0615d54`, hyphenated/dotted cookie-name test. |
+| M-07 | Production CSRF origins were not configured explicitly. | Accepted; already fixed on later `dev`. | `8ea5ec9`, production-origin configuration test. |
+| M-08 | `GET /v1/auth/me` lacked bearer security and a regression guard. | Accepted. The endpoint annotation was already fixed; a contract-wide protected-operation guard was added. | `295b911`, `21eb825`, generated OpenAPI. |
+| M-09 | Refresh response declared `refreshToken` as write-only. | Accepted and fixed. | `0483b4b`, generated-schema assertion. |
+| M-10 | `sessionDatabase` composition did not create request tenant context. | Accepted and fixed with one shared session adapter instance. | `1bf5650`, foundation composition test. |
+| M-11 | MFA factor activation required no factor proof. | Accepted and fixed with a fail-closed proof-verifier port. | `66a9a56`, invalid/valid proof tests. |
+| M-12 | IAM membership reads loaded memberships outside the organization. | Accepted; already fixed on later `dev`. | `b7ee10a`, scoped query tests. |
+| M-13 | Entitlement endpoints broke the Problem Details convention. | Accepted; already fixed on later `dev`. | `2328dd4`, HTTP problem tests. |
+| M-14 | Unsafe-principal test used a malformed bearer token and asserted the wrong path. | Accepted; already fixed on later `dev`. | `44c1fae`, valid-token unsafe-principal test. |
+| M-15 | Session authority outages were reported as credential rejection. | Accepted; already fixed on later `dev`. | `7c94a11`, `a62e515`, availability-boundary tests. |
+| M-16 | Mutation requests fabricated idempotency keys from request IDs. | Accepted and fixed. Unsafe methods now require an explicit `Idempotency-Key`; read-only methods may use the request ID. | `85d60cc`, adapter and HTTP sign-out tests. |
+| M-17 | One malformed membership row could block unrelated reads. | Accepted and fixed. Read paths skip invalid rows while mutation paths remain strict. | `259c92a`, malformed-row isolation test. |
+| M-18 | `mfaRequired` should centrally block protected operations. | Rejected as proposed and retained as planned work. The field currently reports enrolled-factor presence, so blocking when true would lock out MFA-enrolled users. Endpoint risk classification and authenticated step-up assertions remain `partial` under Plan 020/IAM-012 and must be implemented as a dedicated vertical slice. | `PrismaSessionLifecycleAdapter.findPrincipal`, `MfaService.requireStepUp`, requirement traceability status. |
+| M-19 | Sign-out lacked caller authorization. | Accepted; duplicate of I-04 and already fixed. | `295b911`. |
+| M-20 | IAM transaction callbacks incorrectly required root `$transaction`. | Accepted and fixed with a transaction-scoped client type. | `d83eeb9`, compile-time transaction double and repository tests. |
+| M-21 | Personal bootstrap chose unstable first matches and display-name markers. | Accepted and fixed. Selection now finds the unique personal organization and deterministically chooses the earliest active workspace/internal project while preserving renamed display values. | `222910a`, multi-organization and rename tests. |
+| M-22 | MFA compare-and-set did not enforce the revision in the update predicate. | Accepted; already fixed on later `dev`. | `e668bd4`, stale-revision tests. |
+| M-23 | Direct bootstrap save was not transactional. | Accepted; already fixed on later `dev`. | `868c573`, rollback test. |
+| M-24 | Organization membership fallback selected an arbitrary workspace. | Accepted; already fixed on later `dev`. | `26ff403`, deterministic workspace selection test. |
+| M-25 | Refresh fell back to the presented token when no active family token existed. | Accepted and fixed. Missing or multiple active tokens fail closed and revoke the family. | `cf47989`, missing-active-token test. |
+| M-26 | Refresh ignored the session inactivity deadline. | Accepted and fixed. Session, refresh tokens, and access tokens expire atomically at the deadline. | `a5478ae`, inactivity-boundary test. |
+| M-27 | MFA lifecycle timestamps were client-controlled. | Accepted and fixed. Enrollment, verification, and recovery timestamps now come from an injected server clock; forged timestamp fields are rejected. | `774d4d5`, application and HTTP tests. |
+| M-28 | Response DTO `refreshToken` was marked write-only. | Accepted; duplicate of M-09. | `0483b4b`. |
+| M-29 | Documented and machine-enforced commit-budget minimums disagreed. | Accepted; already fixed on later `dev`. | `2c12a91`, orchestration checker and docs. |
+
+## Release handling
+
+- PR #29 remains a historical promotion slice. Review fixes are applied to `dev` first, following the repository rule that feature/fix PRs target `dev` without CodeRabbit.
+- Main is not considered releasable until every ordered promotion slice, including this fix branch, has landed and passed its one automatic CodeRabbit review.
+- The rejected M-18 proposal does not mark IAM-012 complete; the traceability record remains `partial` until the planned step-up authorization slice is implemented and verified.
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/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/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"
diff --git a/infrastructure/local/.env.example b/infrastructure/local/.env.example
index 4d0f8617..be0cb6dd 100644
--- a/infrastructure/local/.env.example
+++ b/infrastructure/local/.env.example
@@ -14,7 +14,7 @@ REDIS_IMAGE=docker.io/library/redis:7.4.5-alpine
REDIS_PORT=6379
MINIO_IMAGE=quay.io/minio/minio:RELEASE.2025-06-13T11-33-47Z
-MINIO_MC_IMAGE=quay.io/minio/mc:RELEASE.2025-06-13T11-33-47Z
+MINIO_MC_IMAGE=quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z
MINIO_ROOT_USER=databreeze
MINIO_ROOT_PASSWORD=databreeze-local-change-me
MINIO_API_PORT=9000
diff --git a/infrastructure/local/compose.yml b/infrastructure/local/compose.yml
index 85fd97de..1eac8831 100644
--- a/infrastructure/local/compose.yml
+++ b/infrastructure/local/compose.yml
@@ -71,7 +71,7 @@ services:
restart: unless-stopped
minio-init:
- image: ${MINIO_MC_IMAGE:-quay.io/minio/mc:RELEASE.2025-06-13T11-33-47Z}
+ image: ${MINIO_MC_IMAGE:-quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z}
init: true
depends_on:
minio:
diff --git a/infrastructure/local/minio/bootstrap-buckets.sh b/infrastructure/local/minio/bootstrap-buckets.sh
index c0e649ef..9663adc0 100644
--- a/infrastructure/local/minio/bootstrap-buckets.sh
+++ b/infrastructure/local/minio/bootstrap-buckets.sh
@@ -1,4 +1,5 @@
#!/bin/sh
+# Mounted into a Linux container; repository attributes keep this script LF-only.
set -eu
: "${MINIO_ROOT_USER:?MINIO_ROOT_USER is required}"
diff --git a/packages/domain/src/audit/v1.ts b/packages/domain/src/audit/v1.ts
index e15c8580..8210c396 100644
--- a/packages/domain/src/audit/v1.ts
+++ b/packages/domain/src/audit/v1.ts
@@ -304,15 +304,23 @@ export function verifyAuditChainV1(
if (!event) return rejected('CHAIN_INVALID');
if (event.sequence !== index + 1 || event.previousDigest !== previousDigest)
return rejected('CHAIN_INVALID');
- const { digest, ...withoutDigest } = event;
- if (digestPort.digest(canonicalEvent(withoutDigest)) !== digest)
- return rejected('CHAIN_INVALID');
- previousDigest = digest;
+ if (!verifyAuditEventDigestV1(event, digestPort).accepted) return rejected('CHAIN_INVALID');
+ previousDigest = event.digest;
}
}
return Object.freeze({ accepted: true, value: true });
}
+/** Verify one immutable event when a bounded page does not contain the full scope chain. */
+export function verifyAuditEventDigestV1(
+ event: AuditEventV1,
+ digestPort: AuditDigestPortV1,
+): AuditResultV1 {
+ const { digest, ...withoutDigest } = event;
+ if (digestPort.digest(canonicalEvent(withoutDigest)) !== digest) return rejected('CHAIN_INVALID');
+ return Object.freeze({ accepted: true, value: true });
+}
+
export function createAuditSealV1(
events: readonly AuditEventV1[],
scopeInput: unknown,
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/src/mfa/v1.ts b/packages/domain/src/mfa/v1.ts
index c76bf302..876b6017 100644
--- a/packages/domain/src/mfa/v1.ts
+++ b/packages/domain/src/mfa/v1.ts
@@ -54,6 +54,7 @@ export type MfaErrorCodeV1 =
| 'INVALID_STATE'
| 'INVALID_REVISION'
| 'FACTOR_NOT_ACTIVE'
+ | 'FACTOR_PROOF_INVALID'
| 'RECOVERY_CODE_INVALID'
| 'RECOVERY_CODE_USED'
| 'STEP_UP_REQUIRED';
diff --git a/packages/domain/test/audit-v1.test.mjs b/packages/domain/test/audit-v1.test.mjs
index 8de3423c..c4f53275 100644
--- a/packages/domain/test/audit-v1.test.mjs
+++ b/packages/domain/test/audit-v1.test.mjs
@@ -7,6 +7,7 @@ import {
createAuditSealV1,
sanitizeAuditSummaryV1,
verifyAuditChainV1,
+ verifyAuditEventDigestV1,
} from '../dist/audit/v1.js';
const id = (tail) => `00000000-0000-4000-8000-${tail.padStart(12, '0')}`;
@@ -94,3 +95,20 @@ test('[AUD-015, AUD-016] seal contains an independently verifiable scoped root',
code: 'CHAIN_INVALID',
});
});
+
+test('[AUD-001] an independently paged event retains verifiable content integrity', () => {
+ const appended = appendAuditEventV1({ events: [] }, input('30', 'invite-30'), digestPort);
+ assert.equal(appended.accepted, true);
+ if (!appended.accepted) return;
+ assert.deepEqual(verifyAuditEventDigestV1(appended.value.event, digestPort), {
+ accepted: true,
+ value: true,
+ });
+ assert.deepEqual(
+ verifyAuditEventDigestV1(
+ { ...appended.value.event, summary: { outcome: 'tampered' } },
+ digestPort,
+ ),
+ { accepted: false, code: 'CHAIN_INVALID' },
+ );
+});
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/openapi/v1.json b/services/api/openapi/v1.json
index 87e58e83..a5706833 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"]
}
@@ -6745,6 +6747,18 @@
"get": {
"operationId": "AuditController.events",
"parameters": [
+ {
+ "name": "limit",
+ "required": false,
+ "in": "query",
+ "schema": { "minimum": 1, "maximum": 100, "type": "number" }
+ },
+ {
+ "name": "cursor",
+ "required": false,
+ "in": "query",
+ "schema": { "maxLength": 512, "type": "string" }
+ },
{
"name": "X-Correlation-Id",
"in": "header",
@@ -6802,6 +6816,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": [] }],
@@ -6813,6 +6840,18 @@
"get": {
"operationId": "AuditController.seals",
"parameters": [
+ {
+ "name": "limit",
+ "required": false,
+ "in": "query",
+ "schema": { "minimum": 1, "maximum": 100, "type": "number" }
+ },
+ {
+ "name": "cursor",
+ "required": false,
+ "in": "query",
+ "schema": { "maxLength": 512, "type": "string" }
+ },
{
"name": "X-Correlation-Id",
"in": "header",
@@ -6870,6 +6909,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": [] }],
@@ -6891,8 +6943,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 +6956,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 +6986,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 +7019,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 +7037,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 +7055,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.",
@@ -7317,12 +7377,7 @@
"properties": {
"sessionId": { "type": "string", "format": "uuid" },
"accessToken": { "type": "string", "minLength": 1, "maxLength": 4096 },
- "refreshToken": {
- "type": "string",
- "minLength": 1,
- "maxLength": 4096,
- "writeOnly": true
- },
+ "refreshToken": { "type": "string", "minLength": 1, "maxLength": 4096 },
"accessExpiresAt": { "type": "string", "format": "date-time" }
},
"required": ["sessionId", "accessToken", "accessExpiresAt"]
@@ -7387,24 +7442,23 @@
"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 }
+ "secretReference": { "type": "string", "maxLength": 512, "writeOnly": true }
},
- "required": ["id", "method", "secretReference", "enrolledAt"]
+ "required": ["id", "method", "secretReference"]
},
"VerifyMfaFactorDto": {
"type": "object",
- "properties": { "at": { "type": "string", "format": "date-time" } },
- "required": ["at"]
+ "properties": {
+ "proof": { "type": "string", "minLength": 1, "maxLength": 4096, "writeOnly": true }
+ },
+ "required": ["proof"]
},
"RedeemMfaRecoveryCodeDto": {
"type": "object",
"properties": {
- "presentedDigest": { "type": "string", "maxLength": 256, "writeOnly": true },
- "at": { "type": "string", "format": "date-time" }
+ "presentedDigest": { "type": "string", "maxLength": 256, "writeOnly": true }
},
- "required": ["presentedDigest", "at"]
+ "required": ["presentedDigest"]
},
"CreateInboxItemDto": {
"type": "object",
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/migrations/20260803020000_bua_project_usage_scope/migration.sql b/services/api/prisma/migrations/20260803020000_bua_project_usage_scope/migration.sql
new file mode 100644
index 00000000..2eaf0e2c
--- /dev/null
+++ b/services/api/prisma/migrations/20260803020000_bua_project_usage_scope/migration.sql
@@ -0,0 +1,14 @@
+-- BUA-008/IAM-009: preserve exact project ancestry for project-scoped quota usage.
+ALTER TABLE "bua"."usage_ledger_entries"
+ ADD COLUMN "project_id" UUID;
+
+ALTER TABLE "bua"."usage_reservations"
+ ADD COLUMN "project_id" UUID;
+
+DROP INDEX "bua"."usage_ledger_scope_idx";
+CREATE INDEX "usage_ledger_scope_idx"
+ ON "bua"."usage_ledger_entries"("organization_id", "workspace_id", "project_id", "metric", "sequence");
+
+DROP INDEX "bua"."usage_reservations_scope_idx";
+CREATE INDEX "usage_reservations_scope_idx"
+ ON "bua"."usage_reservations"("organization_id", "workspace_id", "project_id", "status");
diff --git a/services/api/prisma/schema/bua.prisma b/services/api/prisma/schema/bua.prisma
index 97d52e68..75d97cdc 100644
--- a/services/api/prisma/schema/bua.prisma
+++ b/services/api/prisma/schema/bua.prisma
@@ -45,6 +45,7 @@ model UsageLedgerEntryRecord {
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
metric String @db.VarChar(40)
bucket String @db.VarChar(16)
deltaUnits BigInt @map("delta_units")
@@ -56,7 +57,7 @@ model UsageLedgerEntryRecord {
@@unique([scopeKey, metric, sequence], map: "usage_ledger_scope_metric_sequence_key")
@@unique([scopeKey, idempotencyKey], map: "usage_ledger_scope_idempotency_key")
- @@index([organizationId, workspaceId, metric, sequence], map: "usage_ledger_scope_idx")
+ @@index([organizationId, workspaceId, projectId, metric, sequence], map: "usage_ledger_scope_idx")
@@index([reservationId], map: "usage_ledger_reservation_idx")
@@map("usage_ledger_entries")
@@schema("bua")
@@ -68,6 +69,7 @@ model UsageReservationRecord {
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
metric String @db.VarChar(40)
reservedUnits BigInt @map("reserved_units")
status String @db.VarChar(16)
@@ -75,7 +77,7 @@ model UsageReservationRecord {
revision Int
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
- @@index([organizationId, workspaceId, status], map: "usage_reservations_scope_idx")
+ @@index([organizationId, workspaceId, projectId, status], map: "usage_reservations_scope_idx")
@@index([scopeKey, metric], map: "usage_reservations_metric_idx")
@@map("usage_reservations")
@@schema("bua")
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/app.module.ts b/services/api/src/app.module.ts
index d3b56a14..e07c3da8 100644
--- a/services/api/src/app.module.ts
+++ b/services/api/src/app.module.ts
@@ -9,6 +9,7 @@ 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';
+import { PrismaSessionLifecycleAdapter } from './features/iam/adapter/prisma-session-lifecycle.adapter.js';
export type AppModuleOptions = SystemModuleOptions &
IamModuleOptions &
@@ -22,7 +23,11 @@ export type AppModuleOptions = SystemModuleOptions &
@Module({})
export class AppModule {
static register(options: AppModuleOptions = {}): DynamicModule {
- const sessions = options.sessions;
+ const sessions =
+ options.sessions ??
+ (options.sessionDatabase === undefined
+ ? undefined
+ : new PrismaSessionLifecycleAdapter(options.sessionDatabase));
const requestTenantContext =
options.requestTenantContext ??
(typeof sessions?.findPrincipalByAccessToken === 'function'
@@ -30,8 +35,11 @@ export class AppModule {
findPrincipalByAccessToken: sessions.findPrincipalByAccessToken.bind(sessions),
})
: undefined);
- const composedOptions =
- requestTenantContext === undefined ? options : { ...options, requestTenantContext };
+ const composedOptions = {
+ ...options,
+ ...(sessions === undefined ? {} : { sessions }),
+ ...(requestTenantContext === undefined ? {} : { requestTenantContext }),
+ };
return {
module: AppModule,
imports: [
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..94533370 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
@@ -6,9 +6,16 @@ import {
} from '@databreeze/domain/v1';
import type {
+ AuditPageInputV1,
+ AuditPageV1,
AuditRepositoryPortV1,
AuditTransactionPortV1,
} from '../application/audit-repository.port.js';
+import {
+ createAuditPageCursorV1,
+ parseAuditPageCursorV1,
+} from '../application/audit-page-cursor.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 {
@@ -32,6 +39,19 @@ function cloneSeal(seal: AuditSealV1): AuditSealV1 {
return Object.freeze({ ...seal, tenantScope: Object.freeze({ ...seal.tenantScope }) });
}
+function pageOffset(
+ input: AuditPageInputV1,
+ kind: 'events' | 'seals',
+ scope: TenantScopeV1,
+): number {
+ if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 100)
+ throw new Error('AUD_PAGE_LIMIT_INVALID');
+ if (input.cursor === undefined) return 0;
+ const parsed = parseAuditPageCursorV1(input.cursor, kind, scope);
+ if (!parsed.accepted) throw new Error('AUD_CURSOR_INVALID');
+ return parsed.offset;
+}
+
/** In-memory adapter with PostgreSQL-equivalent append-only and scope checks. */
export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 {
private events = new Map();
@@ -44,8 +64,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()]
@@ -76,6 +95,50 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 {
.map(cloneEvent);
}
+ async listEventPage(
+ context: IamTenantContextV1,
+ input: AuditPageInputV1,
+ ): Promise> {
+ await Promise.resolve();
+ const offset = pageOffset(input, 'events', context.tenantScope);
+ const visible = [...this.events.values()]
+ .filter((event) => visibleInScope(context.tenantScope, event.tenantScope))
+ .sort((left, right) =>
+ left.occurredAt === right.occurredAt
+ ? left.eventId.localeCompare(right.eventId)
+ : left.occurredAt.localeCompare(right.occurredAt),
+ );
+ const items = visible.slice(offset, offset + input.limit).map(cloneEvent);
+ return Object.freeze({
+ items: Object.freeze(items),
+ ...(visible.length > offset + items.length
+ ? {
+ nextCursor: createAuditPageCursorV1(
+ 'events',
+ context.tenantScope,
+ offset + items.length,
+ ),
+ }
+ : {}),
+ });
+ }
+
+ 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))
@@ -87,8 +150,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));
}
@@ -100,6 +162,34 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 {
.map(cloneSeal);
}
+ async listSealPage(
+ context: IamTenantContextV1,
+ input: AuditPageInputV1,
+ ): Promise> {
+ await Promise.resolve();
+ const offset = pageOffset(input, 'seals', context.tenantScope);
+ const visible = [...this.seals.values()]
+ .filter((seal) => visibleInScope(context.tenantScope, seal.tenantScope))
+ .sort((left, right) =>
+ left.sealedAt === right.sealedAt
+ ? left.rootDigest.localeCompare(right.rootDigest)
+ : left.sealedAt.localeCompare(right.sealedAt),
+ );
+ const items = visible.slice(offset, offset + input.limit).map(cloneSeal);
+ return Object.freeze({
+ items: Object.freeze(items),
+ ...(visible.length > offset + items.length
+ ? {
+ nextCursor: createAuditPageCursorV1(
+ 'seals',
+ context.tenantScope,
+ offset + items.length,
+ ),
+ }
+ : {}),
+ });
+ }
+
async withTransaction(
context: IamTenantContextV1,
work: (transaction: AuditTransactionPortV1) => Promise,
@@ -116,6 +206,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..2a2834ee 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
@@ -2,6 +2,7 @@ import {
AUDIT_ACTIONS_V1,
sanitizeAuditSummaryV1,
verifyAuditChainV1,
+ verifyAuditEventDigestV1,
type AuditActorTypeV1,
type AuditEventV1,
type AuditSealV1,
@@ -18,9 +19,16 @@ import { randomUUID } from 'node:crypto';
import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js';
import type {
+ AuditPageInputV1,
+ AuditPageV1,
AuditRepositoryPortV1,
AuditTransactionPortV1,
} from '../application/audit-repository.port.js';
+import {
+ createAuditPageCursorV1,
+ parseAuditPageCursorV1,
+} from '../application/audit-page-cursor.js';
+import { sameAuditEventV1, sameAuditSealV1 } from '../application/audit-equality.js';
export interface AuditEventDatabaseRowV1 {
readonly id: string;
@@ -72,12 +80,17 @@ interface AuditSealCreateDataV1 extends Omit;
- findUnique(input: {
- readonly where: { readonly id: string };
+ findFirst(input: {
+ readonly where: Readonly>;
+ readonly orderBy?: { readonly sequence: 'asc' | 'desc' };
}): Promise;
findMany(input: {
readonly where: Readonly>;
- readonly orderBy: { readonly sequence: 'asc' | 'desc' };
+ readonly orderBy:
+ | Readonly>
+ | readonly Readonly>[];
+ readonly skip?: number;
+ readonly take?: number;
}): Promise;
}
@@ -88,7 +101,11 @@ interface AuditSealDelegateV1 {
}): Promise;
findMany(input: {
readonly where: Readonly>;
- readonly orderBy: { readonly lastSequence: 'asc' | 'desc' };
+ readonly orderBy:
+ | Readonly>
+ | readonly Readonly>[];
+ readonly skip?: number;
+ readonly take?: number;
}): Promise;
}
@@ -267,6 +284,37 @@ function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean {
return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context);
}
+function visibilityWhere(scope: TenantScopeV1): Readonly> {
+ if (scope.scopeType === 'organization') return { organizationId: scope.organizationId };
+ if (scope.scopeType === 'workspace') {
+ return {
+ organizationId: scope.organizationId,
+ OR: [{ scopeType: 'organization' }, { workspaceId: scope.workspaceId }],
+ };
+ }
+ return {
+ organizationId: scope.organizationId,
+ OR: [
+ { scopeType: 'organization' },
+ { scopeType: 'workspace', workspaceId: scope.workspaceId },
+ { scopeType: 'project', projectId: scope.projectId },
+ ],
+ };
+}
+
+function pageOffset(
+ input: AuditPageInputV1,
+ kind: 'events' | 'seals',
+ scope: TenantScopeV1,
+): number {
+ if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 100)
+ throw new Error('AUD_PAGE_LIMIT_INVALID');
+ if (input.cursor === undefined) return 0;
+ const parsed = parseAuditPageCursorV1(input.cursor, kind, scope);
+ if (!parsed.accepted) throw new Error('AUD_CURSOR_INVALID');
+ return parsed.offset;
+}
+
class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 {
public constructor(
private readonly client: AuditDatabaseClientV1,
@@ -279,23 +327,30 @@ 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);
- 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({
- 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');
@@ -317,6 +372,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');
@@ -328,8 +399,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) });
@@ -368,10 +438,77 @@ export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 {
);
}
+ public async listEventPage(
+ context: IamTenantContextV1,
+ input: AuditPageInputV1,
+ ): Promise> {
+ const offset = pageOffset(input, 'events', context.tenantScope);
+ const rows = await this.client.auditEventRecord.findMany({
+ where: visibilityWhere(context.tenantScope),
+ orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
+ skip: offset,
+ take: input.limit + 1,
+ });
+ const visibleRows = rows.filter((row) => visible(context.tenantScope, persistedScope(row)));
+ const pageRows = visibleRows.slice(0, input.limit);
+ const items = pageRows.map(persistedEvent);
+ if (items.some((event) => !verifyAuditEventDigestV1(event, this.digestPort).accepted))
+ throw new Error('AUD_CHAIN_INVALID');
+ return Object.freeze({
+ items: Object.freeze(items),
+ ...(visibleRows.length > pageRows.length
+ ? {
+ nextCursor: createAuditPageCursorV1(
+ 'events',
+ context.tenantScope,
+ offset + pageRows.length,
+ ),
+ }
+ : {}),
+ });
+ }
+
public listEvents(context: IamTenantContextV1): Promise {
return new PrismaAuditTransactionAdapter(this.client, this.digestPort).listEvents(context);
}
+ public async listSealPage(
+ context: IamTenantContextV1,
+ input: AuditPageInputV1,
+ ): Promise> {
+ const offset = pageOffset(input, 'seals', context.tenantScope);
+ const rows = await this.client.auditSealRecord.findMany({
+ where: visibilityWhere(context.tenantScope),
+ orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
+ skip: offset,
+ take: input.limit + 1,
+ });
+ const visibleRows = rows.filter((row) => visible(context.tenantScope, persistedScope(row)));
+ const pageRows = visibleRows.slice(0, input.limit);
+ return Object.freeze({
+ items: Object.freeze(pageRows.map(persistedSeal)),
+ ...(visibleRows.length > pageRows.length
+ ? {
+ nextCursor: createAuditPageCursorV1(
+ 'seals',
+ context.tenantScope,
+ offset + pageRows.length,
+ ),
+ }
+ : {}),
+ });
+ }
+
+ 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/api/audit.controller.ts b/services/api/src/features/aud/api/audit.controller.ts
index 75c6b9bb..96cc053d 100644
--- a/services/api/src/features/aud/api/audit.controller.ts
+++ b/services/api/src/features/aud/api/audit.controller.ts
@@ -1,5 +1,12 @@
-import { Controller, Get, Inject, Req } from '@nestjs/common';
-import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { Controller, Get, Inject, Query, Req } from '@nestjs/common';
+import {
+ ApiBearerAuth,
+ ApiOkResponse,
+ ApiOperation,
+ ApiQuery,
+ ApiServiceUnavailableResponse,
+ ApiTags,
+} from '@nestjs/swagger';
import {
AUDIT_REPOSITORY_PORT,
@@ -9,6 +16,17 @@ import {
REQUEST_TENANT_CONTEXT,
type RequestTenantContextPortV1,
} from '../../../platform/http/request-tenant-context.port.js';
+import { AuditProblemError } from '../application/audit-problem.error.js';
+import { parseAuditPageCursorV1 } from '../application/audit-page-cursor.js';
+import { InputValidationException } from '../../../platform/http/input-validation.exception.js';
+
+function pageLimit(input: string | undefined): number {
+ const value = input === undefined ? 50 : Number(input);
+ if (!Number.isSafeInteger(value) || value < 1 || value > 100) {
+ throw new InputValidationException([{ field: 'limit', code: 'INVALID_PAGE_LIMIT' }]);
+ }
+ return value;
+}
@ApiTags('audit')
@ApiBearerAuth()
@@ -21,15 +39,57 @@ export class AuditController {
@Get('events')
@ApiOperation({ summary: 'List immutable audit events visible to the caller' })
- async events(@Req() request: unknown): Promise {
+ @ApiOkResponse()
+ @ApiQuery({ name: 'limit', required: false, type: Number, minimum: 1, maximum: 100 })
+ @ApiQuery({ name: 'cursor', required: false, type: String, maxLength: 512 })
+ @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' })
+ async events(
+ @Req() request: unknown,
+ @Query('limit') limitInput?: string,
+ @Query('cursor') cursor?: string,
+ ): Promise {
const context = await this.requestContext.resolve(request);
- return this.repository.listEvents(context);
+ const limit = pageLimit(limitInput);
+ if (
+ cursor !== undefined &&
+ !parseAuditPageCursorV1(cursor, 'events', context.tenantScope).accepted
+ )
+ throw new InputValidationException([{ field: 'cursor', code: 'INVALID_CURSOR' }]);
+ try {
+ return await this.repository.listEventPage(context, {
+ limit,
+ ...(cursor === undefined ? {} : { cursor }),
+ });
+ } catch {
+ throw new AuditProblemError('AUDIT_UNAVAILABLE');
+ }
}
@Get('seals')
@ApiOperation({ summary: 'List verified audit seals visible to the caller' })
- async seals(@Req() request: unknown): Promise {
+ @ApiOkResponse()
+ @ApiQuery({ name: 'limit', required: false, type: Number, minimum: 1, maximum: 100 })
+ @ApiQuery({ name: 'cursor', required: false, type: String, maxLength: 512 })
+ @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' })
+ async seals(
+ @Req() request: unknown,
+ @Query('limit') limitInput?: string,
+ @Query('cursor') cursor?: string,
+ ): Promise {
const context = await this.requestContext.resolve(request);
- return this.repository.listSeals(context);
+ const limit = pageLimit(limitInput);
+ if (
+ cursor !== undefined &&
+ !parseAuditPageCursorV1(cursor, 'seals', context.tenantScope).accepted
+ )
+ throw new InputValidationException([{ field: 'cursor', code: 'INVALID_CURSOR' }]);
+ try {
+ return await this.repository.listSealPage(context, {
+ limit,
+ ...(cursor === undefined ? {} : { cursor }),
+ });
+ } catch {
+ throw new AuditProblemError('AUDIT_UNAVAILABLE');
+ }
}
}
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/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-page-cursor.ts b/services/api/src/features/aud/application/audit-page-cursor.ts
new file mode 100644
index 00000000..271a6127
--- /dev/null
+++ b/services/api/src/features/aud/application/audit-page-cursor.ts
@@ -0,0 +1,64 @@
+import type { TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1';
+
+export type AuditPageKindV1 = 'events' | 'seals';
+
+export type AuditPageCursorResultV1 =
+ | { readonly accepted: true; readonly offset: number }
+ | { readonly accepted: false; readonly code: 'INVALID_CURSOR' };
+
+const MAX_CURSOR_LENGTH_V1 = 512;
+
+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 rejected(): AuditPageCursorResultV1 {
+ return Object.freeze({ accepted: false, code: 'INVALID_CURSOR' });
+}
+
+export function createAuditPageCursorV1(
+ kind: AuditPageKindV1,
+ scope: TenantScopeV1,
+ offset: number,
+): string {
+ if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('AUD_CURSOR_OFFSET_INVALID');
+ return Buffer.from(
+ JSON.stringify({ version: 1, kind, scope: scopeKey(scope), offset }),
+ 'utf8',
+ ).toString('base64url');
+}
+
+export function parseAuditPageCursorV1(
+ cursor: unknown,
+ kind: AuditPageKindV1,
+ scope: TenantScopeV1,
+): AuditPageCursorResultV1 {
+ if (
+ typeof cursor !== 'string' ||
+ cursor.length === 0 ||
+ cursor.length > MAX_CURSOR_LENGTH_V1 ||
+ !/^[A-Za-z0-9_-]+$/u.test(cursor)
+ )
+ return rejected();
+ try {
+ const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
+ const parsed = JSON.parse(decoded) as unknown;
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return rejected();
+ const record = parsed as Record;
+ if (
+ Object.keys(record).sort().join(',') !== 'kind,offset,scope,version' ||
+ record['version'] !== 1 ||
+ record['kind'] !== kind ||
+ record['scope'] !== scopeKey(scope) ||
+ !Number.isSafeInteger(record['offset']) ||
+ (record['offset'] as number) < 0
+ )
+ return rejected();
+ return Object.freeze({ accepted: true, offset: record['offset'] as number });
+ } catch {
+ return rejected();
+ }
+}
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/features/aud/application/audit-repository.port.ts b/services/api/src/features/aud/application/audit-repository.port.ts
index 3554d55c..ac4650f3 100644
--- a/services/api/src/features/aud/application/audit-repository.port.ts
+++ b/services/api/src/features/aud/application/audit-repository.port.ts
@@ -1,17 +1,40 @@
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';
export const AUDIT_REPOSITORY_PORT = Symbol('AUDIT_REPOSITORY_PORT');
+export interface AuditPageInputV1 {
+ readonly limit: number;
+ readonly cursor?: string;
+}
+
+export interface AuditPageV1 {
+ readonly items: readonly TItem[];
+ readonly nextCursor?: string;
+}
+
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;
}
export interface AuditRepositoryPortV1 extends AuditTransactionPortV1 {
+ listEventPage(
+ context: IamTenantContextV1,
+ input: AuditPageInputV1,
+ ): Promise>;
+ listSealPage(
+ context: IamTenantContextV1,
+ input: AuditPageInputV1,
+ ): Promise>;
withTransaction(
context: IamTenantContextV1,
work: (transaction: AuditTransactionPortV1) => Promise,
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..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
@@ -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,14 @@ 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);
+}
+
+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. */
@@ -85,8 +95,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 +110,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 +143,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,10 +168,11 @@ 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)
+ !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 b73bc1b5..e46a1882 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']);
@@ -70,6 +77,7 @@ export interface UsageLedgerEntryDatabaseRowV1 {
readonly scopeType: string;
readonly organizationId: string;
readonly workspaceId: string | null;
+ readonly projectId: string | null;
readonly metric: string;
readonly bucket: string;
readonly deltaUnits: bigint | number;
@@ -86,6 +94,7 @@ export interface UsageReservationDatabaseRowV1 {
readonly scopeType: string;
readonly organizationId: string;
readonly workspaceId: string | null;
+ readonly projectId: string | null;
readonly metric: string;
readonly reservedUnits: bigint | number;
readonly status: string;
@@ -113,6 +122,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>;
@@ -122,7 +132,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 }>;
}
@@ -174,6 +184,13 @@ function databaseScope(scope: TenantScopeV1) {
} as const;
}
+function databaseUsageScope(scope: TenantScopeV1) {
+ return {
+ ...databaseScope(scope),
+ 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')
@@ -293,7 +310,7 @@ function persistedEntry(row: UsageLedgerEntryDatabaseRowV1): UsageLedgerEntryV1
const reservationId =
row.reservationId === null ? undefined : parseStableIdentifierV1(row.reservationId);
const occurredAt = parseStrictUtcTimestampV1(row.occurredAt.toISOString());
- const scope = persistedScope({ ...row, projectId: null });
+ const scope = persistedScope(row);
if (
row.schemaVersion !== 1 ||
!entryId.accepted ||
@@ -324,7 +341,7 @@ function persistedEntry(row: UsageLedgerEntryDatabaseRowV1): UsageLedgerEntryV1
function persistedReservation(row: UsageReservationDatabaseRowV1): UsageReservationV1 {
const reservationId = parseStableIdentifierV1(row.id);
const occurredAt = parseStrictUtcTimestampV1(row.createdAt.toISOString());
- const scope = persistedScope({ ...row, projectId: null });
+ const scope = persistedScope(row);
if (
!reservationId.accepted ||
!occurredAt.accepted ||
@@ -393,7 +410,7 @@ function snapshotCreateData(snapshot: EntitlementSnapshotV1): EntitlementSnapsho
function entryCreateData(entry: UsageLedgerEntryV1): UsageLedgerEntryCreateDataV1 {
return {
- ...databaseScope(entry.tenantScope),
+ ...databaseUsageScope(entry.tenantScope),
id: entry.entryId,
schemaVersion: entry.schemaVersion,
scopeKey: scopeKey(entry.tenantScope),
@@ -410,7 +427,7 @@ function entryCreateData(entry: UsageLedgerEntryV1): UsageLedgerEntryCreateDataV
function reservationCreateData(reservation: UsageReservationV1): UsageReservationCreateDataV1 {
return {
- ...databaseScope(reservation.tenantScope),
+ ...databaseUsageScope(reservation.tenantScope),
id: reservation.reservationId,
scopeKey: scopeKey(reservation.tenantScope),
metric: reservation.metric,
@@ -426,14 +443,25 @@ 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;
+ const inherited = [
+ `organization:${scope.organizationId}`,
+ `workspace:${scope.organizationId}:${scope.workspaceId}`,
+ ];
+ if (scope.scopeType === 'project') inherited.push(scopeKey(scope));
+ return Object.freeze(inherited);
+}
+
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);
+}
+
+function validReservationTransition(
+ current: UsageReservationV1,
+ next: UsageReservationV1,
+): boolean {
+ return current.status === 'ACTIVE' && (next.status === 'FINALIZED' || next.status === 'RELEASED');
}
class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV1 {
@@ -444,7 +472,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;
}
@@ -471,11 +499,15 @@ 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 (JSON.stringify(persistedSnapshot(existing)) !== JSON.stringify(snapshot))
+ if (!sameEntitlementSnapshotV1(persistedSnapshot(existing), snapshot))
throw new Error('BUA_IMMUTABLE_SNAPSHOT');
return;
}
@@ -486,8 +518,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);
@@ -502,29 +542,40 @@ 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
- .filter((row) =>
- visible(context.tenantScope, persistedScope({ ...row, projectId: null })),
- )
+ .filter((row) => visible(context.tenantScope, persistedScope(row)))
.map(persistedEntry),
),
reservations: Object.freeze(
reservationRows
- .filter((row) =>
- visible(context.tenantScope, persistedScope({ ...row, projectId: null })),
- )
+ .filter((row) => visible(context.tenantScope, persistedScope(row)))
.map(persistedReservation),
),
});
@@ -537,11 +588,15 @@ 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 (JSON.stringify(persistedEntry(existing)) !== JSON.stringify(entry))
+ if (!sameUsageEntryV1(persistedEntry(existing), entry))
throw new Error('BUA_IMMUTABLE_USAGE_ENTRY');
continue;
}
@@ -550,8 +605,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({
@@ -560,15 +619,21 @@ 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
+ 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');
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');
@@ -612,6 +677,8 @@ export class PrismaEntitlementRepositoryAdapter implements EntitlementRepository
}
public persistUsageState(context: IamTenantContextV1, state: UsageLedgerStateV1): Promise {
- return new PrismaEntitlementTransactionAdapter(this.client).persistUsageState(context, state);
+ return this.client.$transaction((transaction) =>
+ new PrismaEntitlementTransactionAdapter(transaction).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 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-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/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/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/in-memory-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts
index d70f8378..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),
@@ -201,7 +203,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/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/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/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts
index 995fe68c..87a75667 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,11 +30,12 @@ export interface IamMembershipDatabaseRowV1 {
}
interface IamMembershipDelegateV1 {
- findUnique(input: {
- readonly where: { readonly id: string };
+ findFirst(input: {
+ readonly where: Readonly>;
}): Promise;
findMany(input: {
readonly where: Readonly>;
+ readonly orderBy?: Readonly>;
}): Promise;
create(input: { readonly data: IamMembershipDatabaseRowV1 }): Promise;
updateMany(input: {
@@ -43,10 +44,13 @@ interface IamMembershipDelegateV1 {
}): Promise<{ readonly count: number }>;
}
-export interface IamDatabaseClientV1 {
+export interface IamTransactionDatabaseClientV1 {
readonly membershipIdentity: IamMembershipDelegateV1;
+}
+
+export interface IamDatabaseClientV1 extends IamTransactionDatabaseClientV1 {
$transaction(
- work: (transaction: IamDatabaseClientV1) => Promise,
+ work: (transaction: IamTransactionDatabaseClientV1) => Promise,
): Promise;
}
@@ -94,6 +98,16 @@ function membershipFromRow(row: IamMembershipDatabaseRowV1): IamMembershipRecord
return validated.value;
}
+function membershipFromRowOrSkip(
+ row: IamMembershipDatabaseRowV1,
+): IamMembershipRecordV1 | undefined {
+ try {
+ return membershipFromRow(row);
+ } catch {
+ return undefined;
+ }
+}
+
function membershipRow(membership: MembershipIdentityV1): IamMembershipDatabaseRowV1 {
return {
id: membership.id,
@@ -116,30 +130,53 @@ 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) {}
+ public constructor(private readonly client: IamTransactionDatabaseClientV1) {}
public async findMembership(
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(
+ .map(membershipFromRowOrSkip)
+ .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined)
+ .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(
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)
+ .map(membershipFromRowOrSkip)
+ .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined)
.filter((membership) => visibleInScope(context.tenantScope, membership.scope));
}
@@ -151,8 +188,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/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..86c1515d 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
@@ -1,13 +1,14 @@
import {
- bootstrapPersonalOrganizationV1,
createUserIdentityV1,
- type MembershipIdentityV1,
+ validateMembershipV1,
type PersonalOrganizationBootstrapV1,
type UserIdentityV1,
} from '@databreeze/domain/identity/v1';
import {
parseStableIdentifierV1,
parseStrictUtcTimestampV1,
+ type StableIdentifierV1,
+ type StrictUtcTimestampV1,
} from '@databreeze/domain/tenant-scope/v1';
import type {
@@ -88,6 +89,19 @@ 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;
@@ -101,17 +115,32 @@ export interface IdentityBootstrapDatabaseClientV1 {
): Promise;
}
-function stableId(input: unknown): string | undefined {
+function stableId(input: unknown): StableIdentifierV1 | undefined {
const parsed = parseStableIdentifierV1(input);
return parsed.accepted ? parsed.value : undefined;
}
-function timestamp(input: Date | null | undefined): string | undefined {
+function timestamp(input: Date | null | undefined): StrictUtcTimestampV1 | undefined {
if (!input) return undefined;
const parsed = parseStrictUtcTimestampV1(input.toISOString());
return parsed.accepted ? parsed.value : undefined;
}
+function safeText(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 compareCreatedIdentity(
+ left: { readonly id: string; readonly createdAt: Date },
+ right: { readonly id: string; readonly createdAt: Date },
+): number {
+ const time = left.createdAt.getTime() - right.createdAt.getTime();
+ return time === 0 ? left.id.localeCompare(right.id) : time;
+}
+
function userFromRow(row: UserIdentityDatabaseRowV1): UserIdentityV1 {
const created = createUserIdentityV1({
id: row.id,
@@ -125,54 +154,103 @@ function userFromRow(row: UserIdentityDatabaseRowV1): UserIdentityV1 {
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,
+function bootstrapFromRows(
+ user: UserIdentityV1,
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)
- );
+): PersonalOrganizationBootstrapV1 {
+ const organizationId = stableId(organization.id);
+ const workspaceId = stableId(workspace.id);
+ const projectId = stableId(project.id);
+ const organizationName = safeText(organization.name, 200);
+ const workspaceName = safeText(workspace.name, 200);
+ const projectName = safeText(project.name, 200);
+ const organizationCreatedAt = timestamp(organization.createdAt);
+ const workspaceCreatedAt = timestamp(workspace.createdAt);
+ const projectCreatedAt = timestamp(project.createdAt);
+ if (
+ !organizationId ||
+ !organizationName ||
+ !organizationCreatedAt ||
+ !organization.personal ||
+ organization.status !== 'ACTIVE'
+ )
+ throw new Error('IAM_PERSISTED_ORGANIZATION_INVALID');
+ if (
+ !workspaceId ||
+ !workspaceName ||
+ !workspaceCreatedAt ||
+ workspace.organizationId !== organizationId ||
+ workspace.status !== 'ACTIVE' ||
+ !Number.isSafeInteger(workspace.authorizationEpoch) ||
+ workspace.authorizationEpoch < 1
+ )
+ throw new Error('IAM_PERSISTED_WORKSPACE_INVALID');
+ if (
+ !projectId ||
+ !projectName ||
+ !projectCreatedAt ||
+ project.organizationId !== organizationId ||
+ project.workspaceId !== workspaceId ||
+ project.kind !== 'INTERNAL' ||
+ project.status !== 'ACTIVE'
+ )
+ throw new Error('IAM_PERSISTED_PROJECT_INVALID');
+ const parsedMembership = validateMembershipV1({
+ id: membership.id,
+ principalType: membership.principalType,
+ principalId: membership.principalId,
+ scope: { scopeType: 'organization', organizationId: membership.organizationId },
+ roleId: membership.roleId,
+ status: membership.status,
+ ...(membership.startsAt ? { startsAt: timestamp(membership.startsAt) } : {}),
+ ...(membership.expiresAt ? { expiresAt: timestamp(membership.expiresAt) } : {}),
+ revision: membership.revision,
+ });
+ if (
+ !parsedMembership.accepted ||
+ membership.scopeType !== 'ORGANIZATION' ||
+ membership.workspaceId !== null ||
+ membership.projectId !== null ||
+ parsedMembership.value.principalId !== user.id ||
+ parsedMembership.value.scope.organizationId !== organizationId ||
+ parsedMembership.value.roleId !== 'owner' ||
+ parsedMembership.value.status !== 'ACTIVE'
+ )
+ throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID');
+ return Object.freeze({
+ user,
+ organization: Object.freeze({
+ schemaVersion: 1,
+ id: organizationId,
+ name: organizationName,
+ personal: true,
+ status: 'ACTIVE',
+ createdAt: organizationCreatedAt,
+ }),
+ workspace: Object.freeze({
+ schemaVersion: 1,
+ id: workspaceId,
+ organizationId,
+ name: workspaceName,
+ status: 'ACTIVE',
+ authorizationEpoch: workspace.authorizationEpoch,
+ createdAt: workspaceCreatedAt,
+ }),
+ project: Object.freeze({
+ schemaVersion: 1,
+ id: projectId,
+ organizationId,
+ workspaceId,
+ kind: 'INTERNAL',
+ name: projectName,
+ status: 'ACTIVE',
+ createdAt: projectCreatedAt,
+ }),
+ membership: parsedMembership.value,
+ });
}
class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTransactionPortV1 {
@@ -185,59 +263,52 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran
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 },
+ where: {
+ principalId: user.id,
+ status: 'ACTIVE',
+ scopeType: 'ORGANIZATION',
+ roleId: 'owner',
+ workspaceId: null,
+ projectId: null,
+ },
});
- if (!organization || !organization.personal)
- throw new Error('IAM_PERSISTED_ORGANIZATION_INVALID');
+ const personalCandidates: Array<{
+ readonly membership: MembershipIdentityDatabaseRowV1;
+ readonly organization: OrganizationIdentityDatabaseRowV1;
+ }> = [];
+ for (const membership of [...memberships].sort((left, right) =>
+ left.id.localeCompare(right.id),
+ )) {
+ const candidateOrganizationId = stableId(membership.organizationId);
+ if (!candidateOrganizationId) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID');
+ const candidate = await this.client.organizationIdentity.findUnique({
+ where: { id: candidateOrganizationId },
+ });
+ if (candidate?.personal) personalCandidates.push({ membership, organization: candidate });
+ }
+ if (personalCandidates.length === 0) return undefined;
+ if (personalCandidates.length !== 1) throw new Error('IAM_PERSISTED_ORGANIZATION_INVALID');
+ const selected = personalCandidates[0];
+ if (!selected) throw new Error('IAM_PERSISTED_ORGANIZATION_INVALID');
+ const { membership: membershipRow, organization } = selected;
+ const organizationId = organization.id;
const workspaceRows = await this.client.workspaceIdentity.findMany({
where: { organizationId, status: 'ACTIVE' },
});
- const workspace = workspaceRows.find((candidate) => candidate.name === 'Personal workspace');
+ const workspace = [...workspaceRows].sort(compareCreatedIdentity)[0];
if (!workspace) throw new Error('IAM_PERSISTED_WORKSPACE_INVALID');
const projectRows = await this.client.projectIdentity.findMany({
- where: { organizationId, workspaceId: workspace.id, status: 'ACTIVE' },
+ where: { organizationId, workspaceId: workspace.id, status: 'ACTIVE', kind: 'INTERNAL' },
});
- const project = projectRows.find((candidate) => candidate.kind === 'INTERNAL');
+ const project = [...projectRows].sort(compareCreatedIdentity)[0];
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;
+ return bootstrapFromRows(user, organization, workspace, project, membershipRow);
}
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,8 +360,7 @@ 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 (!ownedFieldsMatch(existing, 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 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/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts
index e5de3a6c..a3119754 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;
@@ -105,6 +107,7 @@ interface RefreshTokenDelegateV1 {
}): Promise;
findMany(input: {
readonly where: Readonly>;
+ readonly orderBy?: Readonly>;
}): Promise;
updateMany(input: {
readonly where: Readonly>;
@@ -183,6 +186,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),
@@ -233,6 +238,45 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 {
this.clock = options.clock ?? (() => new Date());
}
+ private async revokeRefreshFamily(
+ transaction: SessionLifecycleDatabaseClientV1,
+ sessionId: StableIdentifierV1,
+ familyId: StableIdentifierV1,
+ now: Date,
+ ): Promise {
+ await transaction.refreshTokenRecord.updateMany({
+ where: { familyId, status: 'ACTIVE' },
+ data: { status: 'REVOKED' },
+ });
+ await transaction.sessionRecord.update({
+ where: { id: sessionId },
+ data: { status: 'REVOKED', revokedAt: now },
+ });
+ await transaction.accessTokenRecord.updateMany({
+ where: { sessionId, status: 'ACTIVE' },
+ data: { status: 'REVOKED', revokedAt: now },
+ });
+ }
+
+ private async expireSession(
+ transaction: SessionLifecycleDatabaseClientV1,
+ sessionId: StableIdentifierV1,
+ familyId: StableIdentifierV1,
+ ): Promise {
+ await transaction.refreshTokenRecord.updateMany({
+ where: { familyId, status: 'ACTIVE' },
+ data: { status: 'EXPIRED' },
+ });
+ await transaction.sessionRecord.update({
+ where: { id: sessionId },
+ data: { status: 'EXPIRED' },
+ });
+ await transaction.accessTokenRecord.updateMany({
+ where: { sessionId, status: 'ACTIVE' },
+ data: { status: 'EXPIRED' },
+ });
+ }
+
public async issue(
principal: AuthenticatedPrincipalV1,
clientPlatform: 'android' | 'desktop' | 'web',
@@ -245,6 +289,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 +307,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),
@@ -322,32 +370,35 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 {
});
if (!sessionRow) return { accepted: false, code: 'INVALID_REFRESH_TOKEN' };
const session = sessionFromRow(sessionRow);
+ if (session.status === 'REVOKED') return { accepted: false, code: 'REVOKED_FAMILY' };
+ if (session.status === 'EXPIRED') return { accepted: false, code: 'EXPIRED' };
+ if (
+ now.getTime() >= Date.parse(session.inactivityExpiresAt) ||
+ now.getTime() >= Date.parse(session.absoluteExpiresAt)
+ ) {
+ await this.expireSession(transaction, token.sessionId, token.familyId);
+ return { accepted: false, code: 'EXPIRED' };
+ }
const active = await transaction.refreshTokenRecord.findMany({
where: { sessionId: token.sessionId, familyId: token.familyId, status: 'ACTIVE' },
+ orderBy: { issuedAt: 'desc' },
});
- const activeToken = active[0] ? tokenFromRow(active[0]) : undefined;
+ if (active.length !== 1 || !active[0]) {
+ await this.revokeRefreshFamily(transaction, token.sessionId, token.familyId, now);
+ return { accepted: false, code: 'REUSE_DETECTED' };
+ }
+ const activeToken = tokenFromRow(active[0]);
const rotated = rotateRefreshFamilyV1({
now: now.toISOString(),
presentedTokenId: token.id,
- activeTokenId: activeToken?.id ?? token.id,
+ activeTokenId: activeToken.id,
nextTokenId: stableIdentifier(randomUUID()),
familyStatus: session.status === 'ACTIVE' ? 'ACTIVE' : 'REVOKED',
tokenExpiresAt: token.expiresAt,
});
if (!rotated.accepted || !rotated.nextTokenId) {
if (rotated.code === 'REUSE_DETECTED') {
- await transaction.refreshTokenRecord.updateMany({
- where: { familyId: token.familyId, status: 'ACTIVE' },
- data: { status: 'REVOKED' },
- });
- await transaction.sessionRecord.update({
- where: { id: token.sessionId },
- data: { status: 'REVOKED', revokedAt: now },
- });
- await transaction.accessTokenRecord.updateMany({
- where: { sessionId: token.sessionId, status: 'ACTIVE' },
- data: { status: 'REVOKED', revokedAt: now },
- });
+ await this.revokeRefreshFamily(transaction, token.sessionId, token.familyId, now);
} else if (rotated.code === 'EXPIRED' && token.status === 'ACTIVE') {
await transaction.refreshTokenRecord.updateMany({
where: { id: token.id, status: 'ACTIVE' },
@@ -444,16 +495,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 +509,62 @@ 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,
+ organizationId: session.organizationId,
+ status: 'ACTIVE',
+ },
+ });
+ const membership = memberships.find(
+ (candidate) =>
+ candidate.principalId === session.userId &&
+ candidate.organizationId === session.organizationId &&
+ candidate.projectId === null &&
+ ((candidate.scopeType === 'ORGANIZATION' && candidate.workspaceId === null) ||
+ (candidate.scopeType === 'WORKSPACE' && candidate.workspaceId === session.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 } }),
+ 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/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts
index 594f5d44..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 {
@@ -133,7 +135,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');
@@ -158,16 +165,34 @@ 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');
- await this.sessions.revoke(input.sessionId);
+ 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 (error) {
+ if (error instanceof SessionProblemError) throw error;
+ 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/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/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts
index df7e017d..04256dbf 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,11 @@ 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.proof),
+ );
if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED');
return result.value;
}
@@ -57,9 +74,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),
+ );
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
index db58a085..035deb0a 100644
--- a/services/api/src/features/iam/api/mfa.dto.ts
+++ b/services/api/src/features/iam/api/mfa.dto.ts
@@ -1,13 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
-import {
- IsIn,
- IsISO8601,
- IsOptional,
- IsString,
- IsUUID,
- MaxLength,
- MinLength,
-} from 'class-validator';
+import { IsIn, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class EnrollMfaFactorDto {
@ApiProperty({ format: 'uuid' })
@@ -23,20 +15,14 @@ export class EnrollMfaFactorDto {
@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;
+ @ApiProperty({ minLength: 1, maxLength: 4096, writeOnly: true })
+ @IsString()
+ @MinLength(1)
+ @MaxLength(4096)
+ proof!: string;
}
export class RedeemMfaRecoveryCodeDto {
@@ -45,8 +31,4 @@ export class RedeemMfaRecoveryCodeDto {
@MinLength(1)
@MaxLength(256)
presentedDigest!: string;
-
- @ApiProperty({ format: 'date-time' })
- @IsISO8601()
- at!: string;
}
diff --git a/services/api/src/features/iam/api/session-cookies.ts b/services/api/src/features/iam/api/session-cookies.ts
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/src/features/iam/api/session-refresh-response.dto.ts b/services/api/src/features/iam/api/session-refresh-response.dto.ts
index a440e029..205c40b9 100644
--- a/services/api/src/features/iam/api/session-refresh-response.dto.ts
+++ b/services/api/src/features/iam/api/session-refresh-response.dto.ts
@@ -12,7 +12,7 @@ export class SessionRefreshResponseDto {
@MaxLength(4096)
accessToken!: string;
- @ApiProperty({ minLength: 1, maxLength: 4096, required: false, writeOnly: true })
+ @ApiProperty({ minLength: 1, maxLength: 4096, required: false })
@IsOptional()
@IsString()
@MinLength(1)
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/features/iam/application/mfa.service.ts b/services/api/src/features/iam/application/mfa.service.ts
index b7916b47..f9f5519c 100644
--- a/services/api/src/features/iam/application/mfa.service.ts
+++ b/services/api/src/features/iam/application/mfa.service.ts
@@ -15,6 +15,22 @@ import type { MfaRepositoryPortV1 } from './mfa-repository.port.js';
export const MFA_SERVICE = Symbol('MFA_SERVICE');
+export interface MfaFactorProofVerifierV1 {
+ verify(input: {
+ readonly userId: StableIdentifierV1;
+ readonly factorId: StableIdentifierV1;
+ readonly method: MfaStateV1['factors'][number]['method'];
+ readonly secretReference: string;
+ readonly proof: string;
+ }): Promise;
+}
+
+export class UnavailableMfaFactorProofVerifier implements MfaFactorProofVerifierV1 {
+ public verify(): Promise {
+ return Promise.resolve(false);
+ }
+}
+
function invalidState(): MfaResultV1 {
return Object.freeze({ accepted: false, code: 'INVALID_STATE' });
}
@@ -24,6 +40,12 @@ function stable(input: unknown): StableIdentifierV1 | undefined {
return result.accepted ? result.value : undefined;
}
+function proof(input: unknown): string | undefined {
+ if (typeof input !== 'string' || input.length === 0 || input.length > 4_096) return undefined;
+ if (/\p{Cc}/u.test(input)) return undefined;
+ return input;
+}
+
export interface MfaStateViewV1 {
readonly factors: readonly Readonly<
Pick<
@@ -63,12 +85,17 @@ export class MfaService {
private readonly recoveryMatcher: {
matches(presentedDigest: string, storedDigest: string): boolean;
},
+ private readonly factorProofVerifier: MfaFactorProofVerifierV1 = new UnavailableMfaFactorProofVerifier(),
+ private readonly clock: () => Date = () => new Date(),
) {}
- public async enroll(
- input: Parameters[0],
- ): Promise> {
- const factor = createMfaFactorV1(input);
+ public async enroll(input: {
+ readonly id: unknown;
+ readonly userId: unknown;
+ readonly method: unknown;
+ readonly secretReference: unknown;
+ }): Promise> {
+ const factor = createMfaFactorV1({ ...input, enrolledAt: this.clock().toISOString() });
if (!factor.accepted) return Object.freeze({ accepted: false, code: factor.code });
return this.repository.withTransaction(async (transaction) => {
const state = await transaction.findState(factor.value.userId);
@@ -89,16 +116,28 @@ export class MfaService {
public async verifyFactor(
userIdInput: unknown,
factorIdInput: unknown,
- at: unknown,
+ proofInput: unknown,
): Promise> {
const userId = stable(userIdInput);
const factorId = stable(factorIdInput);
if (!userId || !factorId) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' });
+ const factorProof = proof(proofInput);
+ if (!factorProof) return Object.freeze({ accepted: false, code: 'FACTOR_PROOF_INVALID' });
return this.repository.withTransaction(async (transaction) => {
const state = await transaction.findState(userId);
const factor = state.factors.find((item) => item.id === factorId);
if (!factor) return invalidState();
- const transitioned = transitionMfaFactorV1(factor, 'VERIFY', at);
+ if (factor.status !== 'PENDING') return invalidState();
+ const verified = await this.factorProofVerifier.verify({
+ userId,
+ factorId,
+ method: factor.method,
+ secretReference: factor.secretReference,
+ proof: factorProof,
+ });
+ if (!verified)
+ return Object.freeze({ accepted: false as const, code: 'FACTOR_PROOF_INVALID' as const });
+ const transitioned = transitionMfaFactorV1(factor, 'VERIFY', this.clock().toISOString());
if (!transitioned.accepted)
return Object.freeze({ accepted: false, code: transitioned.code });
const next = Object.freeze({
@@ -113,7 +152,6 @@ export class MfaService {
public async redeemRecovery(
userIdInput: unknown,
presentedDigest: unknown,
- at: unknown,
): Promise> {
const userId = stable(userIdInput);
if (!userId) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' });
@@ -121,7 +159,7 @@ export class MfaService {
const state = await transaction.findState(userId);
const redeemed = redeemRecoveryCodeV1(
state,
- { userId, presentedDigest, at },
+ { userId, presentedDigest, at: this.clock().toISOString() },
this.recoveryMatcher,
);
if (!redeemed.accepted) return Object.freeze({ accepted: false, code: redeemed.code });
diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts
index 6bf6257e..8efcbc89 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';
@@ -21,7 +22,12 @@ import {
MFA_REPOSITORY_PORT,
type MfaRepositoryPortV1,
} from './application/mfa-repository.port.js';
-import { MFA_SERVICE, MfaService } from './application/mfa.service.js';
+import {
+ MFA_SERVICE,
+ MfaService,
+ UnavailableMfaFactorProofVerifier,
+ type MfaFactorProofVerifierV1,
+} from './application/mfa.service.js';
import {
IAM_REPOSITORY_PORT,
type IamRepositoryPortV1,
@@ -82,6 +88,8 @@ export interface IamModuleOptions {
readonly mfaRepository?: MfaRepositoryPortV1;
readonly mfaDatabase?: MfaDatabaseClientV1;
readonly mfaService?: MfaService;
+ readonly mfaFactorProofVerifier?: MfaFactorProofVerifierV1;
+ readonly mfaClock?: () => Date;
readonly recoveryCodeMatcher?: {
matches(presentedDigest: string, storedDigest: string): boolean;
};
@@ -94,6 +102,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,15 +155,10 @@ 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,
},
+ options.mfaFactorProofVerifier ?? new UnavailableMfaFactorProofVerifier(),
+ options.mfaClock,
));
const iamRepository =
options.iamRepository ??
diff --git a/services/api/src/platform/http/csrf-protection.ts b/services/api/src/platform/http/csrf-protection.ts
index af209493..9786da39 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/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts
index 75a9262b..41c82576 100644
--- a/services/api/src/platform/http/problem-details.filter.ts
+++ b/services/api/src/platform/http/problem-details.filter.ts
@@ -10,6 +10,9 @@ 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 { 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';
@@ -55,14 +58,74 @@ 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 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 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';
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/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/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts
index c7d2f4ef..a0852f39 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) {
@@ -15,6 +18,7 @@ export class RequestTenantContextProblemError extends Error {
}
type HeaderValueV1 = string | readonly string[] | undefined;
+const SAFE_METHODS_V1 = new Set(['GET', 'HEAD', 'OPTIONS']);
interface RequestLikeV1 {
readonly id?: unknown;
@@ -55,6 +59,9 @@ function correlationId(request: RequestLikeV1): string {
function idempotencyKey(request: RequestLikeV1): string {
const header = oneHeader(request, 'idempotency-key');
if (header !== undefined) return header;
+ if (typeof request.method !== 'string' || !SAFE_METHODS_V1.has(request.method.toUpperCase())) {
+ throw new RequestTenantContextProblemError('CONTEXT_INVALID');
+ }
if (typeof request.id === 'string' && request.id.length > 0) return request.id;
return randomUUID();
}
@@ -80,7 +87,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/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 () => {
diff --git a/services/api/test/features/aud/audit-page-cursor.test.ts b/services/api/test/features/aud/audit-page-cursor.test.ts
new file mode 100644
index 00000000..846a25c3
--- /dev/null
+++ b/services/api/test/features/aud/audit-page-cursor.test.ts
@@ -0,0 +1,51 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { parseTenantScopeV1 } from '@databreeze/domain/tenant-scope/v1';
+
+import {
+ createAuditPageCursorV1,
+ parseAuditPageCursorV1,
+} from '../../../src/features/aud/application/audit-page-cursor.js';
+
+const organizationId = '00000000-0000-4000-8000-000000000001';
+const workspaceId = '00000000-0000-4000-8000-000000000002';
+const parsedWorkspaceScope = parseTenantScopeV1({
+ scopeType: 'workspace',
+ organizationId,
+ workspaceId,
+});
+assert.equal(parsedWorkspaceScope.accepted, true);
+if (!parsedWorkspaceScope.accepted) throw new Error('invalid workspace scope fixture');
+const workspaceScope = parsedWorkspaceScope.value;
+
+void test('[AUD-001, IAM-009] audit page cursors bind resource, tenant scope, and offset', () => {
+ const cursor = createAuditPageCursorV1('events', workspaceScope, 100);
+ assert.deepEqual(parseAuditPageCursorV1(cursor, 'events', workspaceScope), {
+ accepted: true,
+ offset: 100,
+ });
+ assert.deepEqual(parseAuditPageCursorV1(cursor, 'seals', workspaceScope), {
+ accepted: false,
+ code: 'INVALID_CURSOR',
+ });
+ const siblingScope = parseTenantScopeV1({
+ scopeType: 'workspace',
+ organizationId,
+ workspaceId: '00000000-0000-4000-8000-000000000003',
+ });
+ assert.equal(siblingScope.accepted, true);
+ if (!siblingScope.accepted) return;
+ assert.deepEqual(parseAuditPageCursorV1(cursor, 'events', siblingScope.value), {
+ accepted: false,
+ code: 'INVALID_CURSOR',
+ });
+});
+
+void test('[AUD-001] audit page cursors fail closed for malformed or oversized values', () => {
+ for (const cursor of ['', 'not/base64', 'e30', 'a'.repeat(513)]) {
+ assert.deepEqual(parseAuditPageCursorV1(cursor, 'events', workspaceScope), {
+ accepted: false,
+ code: 'INVALID_CURSOR',
+ });
+ }
+});
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(
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..9a6ac157 100644
--- a/services/api/test/features/aud/prisma-audit-repository.test.ts
+++ b/services/api/test/features/aud/prisma-audit-repository.test.ts
@@ -29,53 +29,92 @@ function context(workspace = workspaceId, idempotencyKey = 'audit') {
return result.value;
}
-function delegate>(rows: TRow[]) {
+function delegate>(
+ rows: TRow[],
+ 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 };
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,
- );
+ findFirst({
+ where,
+ orderBy,
+ }: {
+ readonly where: Readonly>;
+ readonly orderBy?: Readonly>;
+ }) {
+ firstQueries.push(where);
+ const matching = rows.filter((row) => matches(row, where));
+ 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,
orderBy,
+ skip = 0,
+ take,
}: {
readonly where: Readonly>;
- readonly orderBy: Readonly>;
+ readonly orderBy:
+ | Readonly>
+ | readonly Readonly>[];
+ readonly skip?: number;
+ readonly take?: number;
}) {
- const filtered = rows.filter((row) =>
- Object.entries(where).every(([key, value]) => row[key] === value),
- );
- const [field, direction] = Object.entries(orderBy)[0] ?? [];
+ const filtered = rows.filter((row) => matches(row, where));
+ const ordering: readonly Readonly>[] = Array.isArray(orderBy)
+ ? (orderBy as readonly Readonly>[])
+ : [orderBy as Readonly>];
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;
- }),
+ [...filtered]
+ .sort((left, right) => {
+ for (const order of ordering) {
+ const [field, direction] = Object.entries(order)[0] ?? [];
+ if (!field) continue;
+ const leftValue = left[field];
+ const rightValue = right[field];
+ if (leftValue === rightValue) continue;
+ const comparison = leftValue! < rightValue! ? -1 : 1;
+ return direction === 'desc' ? -comparison : comparison;
+ }
+ return 0;
+ })
+ .slice(skip, take === undefined ? undefined : skip + take),
);
},
};
}
-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 {
@@ -123,6 +162,16 @@ void test('[AUD-001, AUD-003, AUD-008, IAM-009] Prisma audit adapter persists an
input('00000000-0000-4000-8000-000000000122', 'job.completed'),
);
assert.equal(second.accepted, true);
+ const firstPage = await repository.listEventPage(context(workspaceId, 'page-1'), { limit: 1 });
+ assert.equal(firstPage.items.length, 1);
+ assert.ok(firstPage.nextCursor);
+ const secondPage = await repository.listEventPage(context(workspaceId, 'page-2'), {
+ limit: 1,
+ cursor: firstPage.nextCursor,
+ });
+ assert.equal(secondPage.items.length, 1);
+ assert.equal(secondPage.nextCursor, undefined);
+ assert.notEqual(firstPage.items[0]?.eventId, secondPage.items[0]?.eventId);
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);
@@ -137,6 +186,10 @@ void test('[AUD-015, AUD-018] Prisma audit adapter persists and reads immutable
);
const sealed = await service.seal(context(workspaceId, 'seal-1'), '2026-01-01T00:01:00.000Z');
assert.equal(sealed.accepted, true);
+ assert.equal(
+ (await repository.listSealPage(context(workspaceId, 'seal-page'), { limit: 1 })).items.length,
+ 1,
+ );
assert.equal((await repository.listSeals(context(workspaceId, 'read'))).length, 1);
assert.equal((await repository.listSeals(context(siblingWorkspaceId, 'sibling'))).length, 0);
});
@@ -166,3 +219,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,
+ ),
+ );
+});
diff --git a/services/api/test/features/bua/entitlement-repository.test.ts b/services/api/test/features/bua/entitlement-repository.test.ts
index 2daf9bdf..451fc208 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(
@@ -133,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,
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..520cea03 100644
--- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts
+++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts
@@ -21,6 +21,7 @@ import { createIamTenantContextV1 } from '../../../src/features/iam/application/
const organizationId = '00000000-0000-4000-8000-000000000201';
const workspaceId = '00000000-0000-4000-8000-000000000202';
const siblingWorkspaceId = '00000000-0000-4000-8000-000000000203';
+const projectId = '00000000-0000-4000-8000-000000000204';
const actorId = '00000000-0000-4000-8000-000000000210';
const correlationId = '00000000-0000-4000-8000-000000000211';
@@ -44,6 +45,19 @@ function context(workspace = workspaceId, idempotencyKey = 'bua') {
return result.value;
}
+function projectContext(idempotencyKey: string) {
+ const result = createIamTenantContextV1({
+ tenantScope: { scopeType: 'project', organizationId, workspaceId, projectId },
+ actorId,
+ correlationId,
+ idempotencyKey,
+ authorizationEpoch: 1,
+ });
+ assert.equal(result.accepted, true);
+ if (!result.accepted) throw new Error('invalid project entitlement context');
+ return result.value;
+}
+
function plan(): EntitlementPlanV1 {
const result = createPlanV1({
planCode: 'development',
@@ -75,7 +89,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 +119,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 +163,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 +172,11 @@ function delegate>(
}
function client(
- options: { readonly forceRevisionConflict?: boolean } = {},
+ options: {
+ readonly forceRevisionConflict?: boolean;
+ readonly firstQueries?: Array>>;
+ readonly transactionCalls?: { value: number };
+ } = {},
): EntitlementDatabaseClientV1 {
const planRows: Record[] = [];
const snapshotRows: Record[] = [];
@@ -151,12 +184,17 @@ 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 {
+ if (options.transactionCalls) options.transactionCalls.value += 1;
return work(database as unknown as EntitlementDatabaseClientV1);
},
};
@@ -213,6 +251,46 @@ void test('[BUA-001, BUA-002, BUA-008, IAM-009] Prisma entitlement adapter persi
);
});
+void test('[BUA-008, IAM-009] Prisma entitlement adapter round-trips project-scoped usage', async () => {
+ const repository = new PrismaEntitlementRepositoryAdapter(client());
+ await repository.saveSnapshot(context(workspaceId, 'project-snapshot'), snapshot());
+ const service = new EntitlementAdmissionService(repository);
+ const input = admissionInput('project-admit', '1');
+ const admitted = await service.admit(projectContext('project-admit'), {
+ ...input,
+ tenantScope: { scopeType: 'project', organizationId, workspaceId, projectId },
+ });
+ assert.equal(admitted.accepted, true);
+
+ const state = await repository.listUsageState(projectContext('project-read'));
+ assert.equal(state.entries.length, 1);
+ assert.equal(state.reservations.length, 1);
+ assert.deepEqual(state.entries[0]?.tenantScope, {
+ scopeType: 'project',
+ organizationId,
+ workspaceId,
+ projectId,
+ });
+ assert.deepEqual(state.reservations[0]?.tenantScope, {
+ scopeType: 'project',
+ organizationId,
+ workspaceId,
+ projectId,
+ });
+});
+
+void test('[BUA-008, BUA-011] direct usage persistence executes in one database transaction', async () => {
+ const transactionCalls = { value: 0 };
+ const repository = new PrismaEntitlementRepositoryAdapter(client({ transactionCalls }));
+
+ await repository.persistUsageState(context(workspaceId, 'transactional-usage'), {
+ entries: [],
+ reservations: [],
+ });
+
+ assert.equal(transactionCalls.value, 1);
+});
+
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());
@@ -269,3 +347,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);
+});
diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts
index 6ea3f0bc..5c346ed7 100644
--- a/services/api/test/features/foundation-module-composition.test.ts
+++ b/services/api/test/features/foundation-module-composition.test.ts
@@ -148,6 +148,44 @@ void test('[IAM-009] a session access-token lookup composes one live tenant-cont
assert.ok(provider.useValue instanceof SessionRequestTenantContextAdapter);
});
+void test('[IAM-005, IAM-009] a configured session database composes the live tenant-context adapter', () => {
+ const registered = AppModule.register({ sessionDatabase: {} as never });
+ 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 contextProvider = iam.providers?.find(
+ (candidate) =>
+ typeof candidate === 'object' &&
+ candidate !== null &&
+ 'provide' in candidate &&
+ candidate.provide === REQUEST_TENANT_CONTEXT,
+ );
+ const sessionProvider = iam.providers?.find(
+ (candidate) =>
+ typeof candidate === 'object' &&
+ candidate !== null &&
+ 'provide' in candidate &&
+ candidate.provide === SESSION_LIFECYCLE_PORT,
+ );
+ assert.ok(contextProvider && 'useValue' in contextProvider);
+ assert.ok(sessionProvider && 'useValue' in sessionProvider);
+ if (
+ !contextProvider ||
+ !('useValue' in contextProvider) ||
+ !sessionProvider ||
+ !('useValue' in sessionProvider)
+ )
+ return;
+ assert.ok(contextProvider.useValue instanceof SessionRequestTenantContextAdapter);
+ assert.ok(sessionProvider.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 });
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();
}
diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts
index 6bb92c05..4e00f2f0 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';
@@ -13,23 +14,35 @@ const at = '2026-01-01T00:00:00.000Z';
void test('[IAM-012, IAM-013, IAM-014] MFA enrollment and verification are revisioned', async () => {
const repository = new InMemoryMfaRepositoryAdapter();
- const service = new MfaService(repository, {
- matches: (presented, stored) => presented === stored,
- });
+ const service = new MfaService(
+ repository,
+ {
+ matches: (presented, stored) => presented === stored,
+ },
+ {
+ verify: ({ proof }) => Promise.resolve(proof === '654321'),
+ },
+ () => new Date(at),
+ );
const enrolled = await service.enroll({
id: factorId,
userId,
method: 'TOTP',
secretReference: 'secret-ref:totp:1',
- enrolledAt: at,
});
assert.equal(enrolled.accepted, true);
if (!enrolled.accepted) return;
assert.equal(enrolled.value.factors[0]?.status, 'PENDING');
- const verified = await service.verifyFactor(userId, factorId, '2026-01-01T00:01:00.000Z');
+ assert.equal(enrolled.value.factors[0]?.enrolledAt, at);
+ const invalidProof = await service.verifyFactor(userId, factorId, '000000');
+ assert.deepEqual(invalidProof, { accepted: false, code: 'FACTOR_PROOF_INVALID' });
+ const verified = await service.verifyFactor(userId, factorId, '654321');
assert.equal(verified.accepted, true);
- if (verified.accepted) assert.equal(verified.value.factors[0]?.status, 'ACTIVE');
- const secondVerify = await service.verifyFactor(userId, factorId, '2026-01-01T00:02:00.000Z');
+ if (verified.accepted) {
+ assert.equal(verified.value.factors[0]?.status, 'ACTIVE');
+ assert.equal(verified.value.factors[0]?.verifiedAt, at);
+ }
+ const secondVerify = await service.verifyFactor(userId, factorId, '654321');
assert.deepEqual(secondVerify, { accepted: false, code: 'INVALID_STATE' });
});
@@ -39,15 +52,20 @@ void test('[IAM-015, IAM-016] recovery code redemption is one-time and does not
assert.equal(code.accepted, true);
if (!code.accepted) return;
await repository.saveState(userId as never, { factors: [], recoveryCodes: [code.value] });
- const service = new MfaService(repository, {
- matches: (presented, stored) => presented === stored,
- });
- const redeemed = await service.redeemRecovery(userId, 'digest-1', '2026-01-01T00:01:00.000Z');
+ const service = new MfaService(
+ repository,
+ {
+ matches: (presented, stored) => presented === stored,
+ },
+ undefined,
+ () => new Date(at),
+ );
+ const redeemed = await service.redeemRecovery(userId, 'digest-1');
assert.equal(redeemed.accepted, true);
if (!redeemed.accepted) return;
assert.equal(redeemed.value.recoveryCodesRemaining, 0);
assert.equal('digest' in redeemed.value, false);
- assert.deepEqual(await service.redeemRecovery(userId, 'digest-1', '2026-01-01T00:02:00.000Z'), {
+ assert.deepEqual(await service.redeemRecovery(userId, 'digest-1'), {
accepted: false,
code: 'RECOVERY_CODE_USED',
});
@@ -70,3 +88,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);
+});
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);
+});
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);
+});
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..942780ff 100644
--- a/services/api/test/features/iam/prisma-iam-repository.test.ts
+++ b/services/api/test/features/iam/prisma-iam-repository.test.ts
@@ -11,6 +11,7 @@ import {
import {
PrismaIamRepositoryAdapter,
type IamDatabaseClientV1,
+ type IamTransactionDatabaseClientV1,
type IamMembershipDatabaseRowV1,
} from '../../../src/features/iam/adapter/prisma-iam-repository.adapter.js';
import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js';
@@ -26,6 +27,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 +45,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 +57,7 @@ function row(
scopeType: scope,
organizationId,
workspaceId: workspace,
- projectId: null,
+ projectId: project,
roleId,
status: 'ACTIVE',
startsAt: null,
@@ -67,13 +70,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(
@@ -99,10 +112,12 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): {
return { count: 1 };
},
},
- $transaction: async (work: (transaction: IamDatabaseClientV1) => Promise) => {
+ $transaction: async (
+ work: (transaction: IamTransactionDatabaseClientV1) => Promise,
+ ) => {
const before = new Map(memberships);
try {
- return await work(client);
+ return await work({ membershipIdentity: client.membershipIdentity });
} catch (error) {
memberships.clear();
for (const [key, value] of before) memberships.set(key, value);
@@ -110,7 +125,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 () => {
@@ -133,6 +148,53 @@ void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and
);
});
+void test('[IAM-009, IAM-019] malformed membership rows fail closed without blocking valid reads', async () => {
+ const valid = row(id('20'), 'WORKSPACE', workspaceId, 'viewer');
+ const malformed = {
+ ...row(id('21'), 'WORKSPACE', workspaceId, 'viewer'),
+ workspaceId: 'not-a-workspace-id',
+ };
+ const repository = new PrismaIamRepositoryAdapter(createDatabase([valid, malformed]).client);
+
+ assert.deepEqual(
+ (
+ await repository.listMemberships(
+ context({ scopeType: 'workspace', organizationId, workspaceId }),
+ )
+ ).map((membership) => membership.id),
+ [valid.id],
+ );
+});
+
+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);
@@ -203,3 +265,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 }]);
+});
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..f19500ee 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({
@@ -179,6 +186,62 @@ void test('[IAM-011] repeated bootstrap is immutable and conflicting hierarchy i
);
});
+void test('[IAM-001, IAM-011] bootstrap lookup selects the personal organization among multiple ownerships', async () => {
+ const state = createDatabase();
+ const validated = bootstrapPersonalOrganizationV1(input);
+ assert.equal(validated.accepted, true);
+ if (!validated.accepted) return;
+ const unrelatedOrganizationId = '00000000-0000-4000-8000-000000000006';
+ const unrelatedMembershipId = '00000000-0000-4000-8000-000000000007';
+ state.organizations.set(unrelatedOrganizationId, {
+ id: unrelatedOrganizationId,
+ name: 'Client organization',
+ personal: false,
+ status: 'ACTIVE',
+ createdAt,
+ });
+ state.memberships.set(unrelatedMembershipId, {
+ id: unrelatedMembershipId,
+ principalType: 'USER',
+ principalId: userId,
+ scopeType: 'ORGANIZATION',
+ organizationId: unrelatedOrganizationId,
+ workspaceId: null,
+ projectId: null,
+ roleId: 'owner',
+ status: 'ACTIVE',
+ startsAt: null,
+ expiresAt: null,
+ revision: 1,
+ });
+ const adapter = new PrismaIdentityBootstrapRepositoryAdapter(state.client);
+ await adapter.save(validated.value);
+
+ assert.equal(
+ (await adapter.findByUserId(validated.value.user.id))?.organization.id,
+ organizationId,
+ );
+});
+
+void test('[IAM-001, IAM-011] bootstrap lookup survives personal workspace and project renames', async () => {
+ const state = createDatabase();
+ const validated = bootstrapPersonalOrganizationV1(input);
+ assert.equal(validated.accepted, true);
+ if (!validated.accepted) return;
+ const adapter = new PrismaIdentityBootstrapRepositoryAdapter(state.client);
+ await adapter.save(validated.value);
+ const workspace = state.workspaces.get(workspaceId);
+ const project = state.projects.get(projectId);
+ assert.ok(workspace);
+ assert.ok(project);
+ state.workspaces.set(workspaceId, { ...workspace, name: 'Finance workspace' });
+ state.projects.set(projectId, { ...project, name: 'Monthly close' });
+
+ const loaded = await adapter.findByUserId(validated.value.user.id);
+ assert.equal(loaded?.workspace.name, 'Finance workspace');
+ assert.equal(loaded?.project.name, 'Monthly close');
+});
+
void test('[IAM-001] bootstrap transaction rollback does not retain a partially written hierarchy', async () => {
const state = createDatabase();
const adapter = new PrismaIdentityBootstrapRepositoryAdapter(state.client);
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/,
+ );
+});
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..b08dd7fc 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 () => {
@@ -186,6 +194,21 @@ void test('[IAM-005] refresh rotation is transactional and reuse revokes the com
assert.equal(await adapter.findPrincipal(first.sessionId), undefined);
});
+void test('[IAM-005] refresh fails closed when a family has no active token', async () => {
+ const { client, refreshTokens, sessions } = createDatabase();
+ const adapter = new PrismaSessionLifecycleAdapter(client, {
+ clock: () => new Date('2026-01-01T00:00:00.000Z'),
+ });
+ const issued = await adapter.issue(principal, 'desktop');
+ for (const [id, row] of refreshTokens) refreshTokens.set(id, { ...row, status: 'USED' });
+
+ assert.deepEqual(await adapter.refresh(issued.refreshToken, 'desktop'), {
+ accepted: false,
+ code: 'REUSE_DETECTED',
+ });
+ assert.equal(sessions.get(issued.sessionId)?.status, 'REVOKED');
+});
+
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();
@@ -202,6 +225,22 @@ void test('[IAM-005] expired refresh tokens fail closed without returning token
});
});
+void test('[IAM-005] refresh cannot restart an expired inactivity window', async () => {
+ let now = new Date('2026-01-01T00:00:00.000Z');
+ const { client, sessions, refreshTokens, accessTokens } = createDatabase();
+ const adapter = new PrismaSessionLifecycleAdapter(client, { clock: () => new Date(now) });
+ const session = await adapter.issue(principal, 'android');
+ now = new Date('2026-01-01T01:00:00.000Z');
+
+ assert.deepEqual(await adapter.refresh(session.refreshToken, 'android'), {
+ accepted: false,
+ code: 'EXPIRED',
+ });
+ assert.equal(sessions.get(session.sessionId)?.status, 'EXPIRED');
+ assert.equal([...refreshTokens.values()][0]?.status, 'EXPIRED');
+ assert.equal([...accessTokens.values()][0]?.status, 'EXPIRED');
+});
+
void test('[IAM-005] revocation is idempotent and hides session principals afterward', async () => {
const { client } = createDatabase();
const adapter = new PrismaSessionLifecycleAdapter(client);
@@ -211,3 +250,44 @@ 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,
+ );
+});
+
+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',
+ });
+});
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([]);
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 }),
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);
+});
diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts
index c2e39d03..364c38cf 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;
@@ -439,10 +440,36 @@ 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 () => {
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: {
@@ -452,7 +479,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) => {
@@ -463,6 +500,8 @@ 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',
+ 'idempotency-key': 'sign-out-web-001',
},
payload: {
clientPlatform: 'web',
@@ -482,6 +521,10 @@ 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',
+ 'idempotency-key': 'sign-out-native-001',
+ },
payload: {
clientPlatform: 'android',
sessionId: '00000000-0000-4000-8000-000000000011',
@@ -493,6 +536,48 @@ 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',
+ 'idempotency-key': 'sign-out-cross-user-001',
+ },
+ payload: {
+ clientPlatform: 'android',
+ sessionId: '00000000-0000-4000-8000-000000000099',
+ },
+ });
+ assertProblem(crossUser, 401, 'SESSION_INVALID');
+ },
+ );
+
+ 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(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',
+ 'idempotency-key': 'sign-out-unavailable-001',
+ },
+ payload: {
+ clientPlatform: 'android',
+ sessionId: '00000000-0000-4000-8000-000000000011',
+ },
+ });
+ assertProblem(response, 503, 'SESSION_UNAVAILABLE');
},
);
});
@@ -548,7 +633,21 @@ void test('protected artifact reads derive tenant scope from an authenticated ac
headers: { authorization: 'Bearer access-token-for-context-1' },
});
assert.equal(auditEvents.statusCode, 200);
- assert.deepEqual(auditEvents.json(), []);
+ assert.deepEqual(auditEvents.json(), { items: [] });
+
+ const invalidAuditCursor = await app.inject({
+ method: 'GET',
+ url: '/v1/audit/events?cursor=not-a-cursor',
+ headers: { authorization: 'Bearer access-token-for-context-1' },
+ });
+ assertProblem(invalidAuditCursor, 400, 'VALIDATION_FAILED');
+
+ const invalidAuditLimit = await app.inject({
+ method: 'GET',
+ url: '/v1/audit/events?limit=101',
+ headers: { authorization: 'Bearer access-token-for-context-1' },
+ });
+ assertProblem(invalidAuditLimit, 400, 'VALIDATION_FAILED');
const auditSeals = await app.inject({
method: 'GET',
@@ -556,7 +655,7 @@ void test('protected artifact reads derive tenant scope from an authenticated ac
headers: { authorization: 'Bearer access-token-for-context-1' },
});
assert.equal(auditSeals.statusCode, 200);
- assert.deepEqual(auditSeals.json(), []);
+ assert.deepEqual(auditSeals.json(), { items: [] });
const usage = await app.inject({
method: 'GET',
@@ -571,31 +670,87 @@ 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');
+ },
+ );
+
+ 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('audit read outages return retryable service-unavailable problems', async () => {
+ const auditRepository = Object.assign(new InMemoryAuditRepositoryAdapter(), {
+ listEventPage: () => Promise.reject(new Error(`database ${leakedMarker}`)),
+ listSealPage: () => 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(), {
- matches: (presented, stored) => presented === stored,
- });
+ const mfaService = new MfaService(
+ new InMemoryMfaRepositoryAdapter(),
+ {
+ matches: (presented, stored) => presented === stored,
+ },
+ {
+ verify: ({ proof }) => Promise.resolve(proof === '654321'),
+ },
+ () => new Date('2026-01-01T00:00:00.000Z'),
+ );
const contextResult = createIamTenantContextV1({
tenantScope: {
scopeType: 'workspace',
@@ -611,6 +766,18 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con
if (!contextResult.accepted) return;
const requestTenantContext = { resolve: () => Promise.resolve(contextResult.value) };
await withApp({ mfaService, requestTenantContext }, async (app) => {
+ const forgedEnrollmentTime = 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: '2000-01-01T00:00:00.000Z',
+ },
+ });
+ assertProblem(forgedEnrollmentTime, 400, 'VALIDATION_FAILED');
+
const enrolled = await app.inject({
method: 'POST',
url: '/v1/auth/mfa/factors',
@@ -618,7 +785,6 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con
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);
@@ -631,7 +797,7 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con
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' },
+ payload: { proof: '654321' },
});
assert.equal(verified.statusCode, 200);
const verifiedBody = parsedBody<{ readonly factors: readonly [{ readonly status: string }] }>(
@@ -642,8 +808,24 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con
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' },
+ payload: { proof: '654321' },
});
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',
+ },
+ });
+ assertProblem(response, 503, 'MFA_UNAVAILABLE');
+ });
});
diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts
index b2040d55..93ddb713 100644
--- a/services/api/test/openapi.test.ts
+++ b/services/api/test/openapi.test.ts
@@ -21,6 +21,7 @@ interface ResponseLike {
interface OperationLike {
readonly parameters?: readonly ParameterLike[];
readonly responses: Record;
+ readonly security?: readonly Readonly>[];
}
type PathItemLike = Partial>;
@@ -178,6 +179,13 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors,
assert.equal(documentedClientVersion.test('1.2.3'), true);
assert.equal(documentedClientVersion.test('1.2.3-beta.1'), true);
assert.equal(documentedClientVersion.test('1.2.3garbage'), false);
+ const refreshResponse = firstDocument.components?.schemas?.[
+ 'SessionRefreshResponseDto'
+ ] as Record;
+ const refreshToken = (refreshResponse['properties'] as Record>)[
+ 'refreshToken'
+ ];
+ assert.equal(refreshToken?.['writeOnly'], undefined);
for (const operation of operations(firstDocument)) {
const headerNames = (operation.parameters ?? [])
@@ -193,6 +201,35 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors,
}
}
+ const publicOperations = new Set([
+ 'GET /health/live',
+ 'GET /health/ready',
+ 'GET /v1/system/compatibility',
+ 'POST /v1/system/compatibility/check',
+ 'POST /v1/auth/sign-in',
+ 'POST /v1/auth/refresh',
+ ]);
+ for (const [path, pathItem] of Object.entries(firstDocument.paths) as Array<
+ [string, PathItemLike]
+ >) {
+ for (const method of httpMethods) {
+ const operation = pathItem[method];
+ if (operation === undefined) continue;
+ const key = `${method.toUpperCase()} ${path}`;
+ if (publicOperations.has(key)) {
+ assert.equal(operation.security, undefined, `${key} must remain explicitly public`);
+ } else {
+ assert.deepEqual(operation.security, [{ bearer: [] }], `${key} must require bearer auth`);
+ }
+ }
+ }
+
+ 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);
diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts
index ebf3cd85..e5647737 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,
@@ -63,6 +83,23 @@ void test('requires a valid double-submit token for cookie-authenticated mutatio
);
});
+void test('accepts standard token characters in unrelated cookie names', () => {
+ assert.deepEqual(
+ evaluateCsrfRequestV1(
+ {
+ method: 'POST',
+ headers: {
+ cookie: `analytics-id=value; preference.v1=value; databreeze_refresh=session-value; databreeze_csrf=${token}`,
+ origin: 'https://app.databreeze.example',
+ 'x-csrf-token': token,
+ },
+ },
+ { allowedOrigins },
+ ),
+ { accepted: true },
+ );
+});
+
void test('rejects hostile, ambiguous, or missing browser origin signals', () => {
const headers = {
cookie: `databreeze_refresh=session-value; databreeze_csrf=${token}`,
@@ -118,3 +155,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' },
+ );
+});
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..6e6cc77b 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,46 @@ 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',
+ method: 'GET',
+ 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;
+ },
+ );
+});
+
+void test('requires an explicit idempotency key for authenticated mutations', async () => {
+ const adapter = new SessionRequestTenantContextAdapter({
+ findPrincipalByAccessToken: () => Promise.resolve(principal),
+ });
+
+ await assert.rejects(
+ adapter.resolve({
+ id: 'request-mutation-001',
+ method: 'POST',
+ headers: { authorization: 'Bearer opaque-access-token-123456789' },
+ }),
+ (error: unknown) => {
+ assert.equal((error as { code?: unknown }).code, 'CONTEXT_INVALID');
+ return true;
+ },
+ );
+});
+
+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;
},
);
diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs
index 6d13ca9f..f71da3a0 100644
--- a/services/api/test/prisma-foundation.test.mjs
+++ b/services/api/test/prisma-foundation.test.mjs
@@ -122,6 +122,8 @@ 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',
+ '20260803020000_bua_project_usage_scope',
'migration_lock.toml',
]);
const migration = await readFile(
@@ -502,4 +504,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, '\\$&')),
+ );
+ }
});
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/src/local-services.mjs b/tools/repo-cli/src/local-services.mjs
index 1d8409a3..9ceb993e 100644
--- a/tools/repo-cli/src/local-services.mjs
+++ b/tools/repo-cli/src/local-services.mjs
@@ -138,6 +138,10 @@ function runDocker(args, { allowFailure = false, capture = true, timeoutMs = 30_
return result;
}
+export function composeOperationTimeoutMs(waitSeconds) {
+ return (waitSeconds + 30) * 1000;
+}
+
function requireDocker() {
const result = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], {
cwd: repositoryRoot,
@@ -341,6 +345,7 @@ function parseArguments(argv, values = environment()) {
export async function main(argv = process.argv.slice(2)) {
const values = environment();
const { command, options } = parseArguments(argv, values);
+ const operationTimeoutMs = composeOperationTimeoutMs(options.waitSeconds);
if (command === 'help') {
usage();
return;
@@ -374,7 +379,7 @@ export async function main(argv = process.argv.slice(2)) {
return;
}
if (command === 'stop') {
- runDocker([...composeArgs(values), 'stop']);
+ runDocker([...composeArgs(values), 'stop'], { timeoutMs: operationTimeoutMs });
console.log('Local services stopped; named volumes and containers were preserved.');
return;
}
@@ -392,14 +397,16 @@ export async function main(argv = process.argv.slice(2)) {
return;
}
if (command === 'reset') {
- runDocker([...composeArgs(values), 'down', '--remove-orphans']);
- runDocker([...composeArgs(values), 'up', '-d']);
+ runDocker([...composeArgs(values), 'down', '--remove-orphans'], {
+ timeoutMs: operationTimeoutMs,
+ });
+ runDocker([...composeArgs(values), 'up', '-d'], { timeoutMs: operationTimeoutMs });
await waitForReady(values, options.waitSeconds);
console.log('Local services reset without removing named volumes.');
return;
}
if (command === 'restart-check') {
- runDocker([...composeArgs(values), 'restart']);
+ runDocker([...composeArgs(values), 'restart'], { timeoutMs: operationTimeoutMs });
await waitForReady(values, options.waitSeconds);
console.log(
'Local service restart and health checks passed. Use persistence-check for a Redis sentinel probe.',
@@ -423,7 +430,7 @@ export async function main(argv = process.argv.slice(2)) {
'EX',
'300',
]);
- runDocker([...composeArgs(values), 'restart', 'redis']);
+ runDocker([...composeArgs(values), 'restart', 'redis'], { timeoutMs: operationTimeoutMs });
await waitForReady(values, options.waitSeconds);
const result = runDocker([
...composeArgs(values),
@@ -446,7 +453,8 @@ export async function main(argv = process.argv.slice(2)) {
console.log('Local Redis persistence check passed; sentinel was removed.');
return;
}
- if (shouldStart) runDocker([...composeArgs(values), 'up', '-d']);
+ if (shouldStart)
+ runDocker([...composeArgs(values), 'up', '-d'], { timeoutMs: operationTimeoutMs });
await waitForReady(values, options.waitSeconds);
}
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(
diff --git a/tools/repo-cli/test/local-infrastructure.test.mjs b/tools/repo-cli/test/local-infrastructure.test.mjs
index 084cad42..49fe61db 100644
--- a/tools/repo-cli/test/local-infrastructure.test.mjs
+++ b/tools/repo-cli/test/local-infrastructure.test.mjs
@@ -5,9 +5,17 @@ import { spawnSync } from 'node:child_process';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
+import { composeOperationTimeoutMs } from '../src/local-services.mjs';
+
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const read = (relativePath) => readFileSync(path.join(repositoryRoot, relativePath), 'utf8');
+test('local lifecycle grants image pulls the bounded readiness window plus teardown margin', () => {
+ assert.equal(composeOperationTimeoutMs(1), 31_000);
+ assert.equal(composeOperationTimeoutMs(60), 90_000);
+ assert.equal(composeOperationTimeoutMs(3600), 3_630_000);
+});
+
test('local compose defines pinned, healthy disposable dependencies', () => {
const compose = read('infrastructure/local/compose.yml');
const envExample = read('infrastructure/local/.env.example');
@@ -25,7 +33,10 @@ test('local compose defines pinned, healthy disposable dependencies', () => {
}
assert.match(compose, /postgres:17\.5-alpine/);
assert.match(compose, /redis:7\.4\.5-alpine/);
- assert.match(compose, /RELEASE\.2025-06-13T11-33-47Z/);
+ assert.match(compose, /MINIO_IMAGE:-quay\.io\/minio\/minio:RELEASE\.2025-06-13T11-33-47Z/u);
+ assert.match(compose, /MINIO_MC_IMAGE:-quay\.io\/minio\/mc:RELEASE\.2025-08-13T08-35-41Z/u);
+ assert.match(envExample, /^MINIO_IMAGE=quay\.io\/minio\/minio:RELEASE\.2025-06-13T11-33-47Z$/m);
+ assert.match(envExample, /^MINIO_MC_IMAGE=quay\.io\/minio\/mc:RELEASE\.2025-08-13T08-35-41Z$/m);
assert.match(compose, /mailpit:v1\.21\.8/);
assert.match(compose, /collector-contrib:0\.128\.0/);
assert.match(compose, /curlimages\/curl:8\.14\.1/);
@@ -79,6 +90,8 @@ test('local bootstrap is credential-free and creates every owned module schema',
assert.doesNotMatch(sql, /DROP\s+SCHEMA|DROP\s+DATABASE|TRUNCATE/u);
const bucketScript = read('infrastructure/local/minio/bootstrap-buckets.sh');
+ assert.doesNotMatch(bucketScript, /\r/u);
+ assert.match(read('.gitattributes'), /^\*\.sh text eol=lf$/m);
assert.match(bucketScript, /MINIO_ROOT_PASSWORD/);
assert.match(bucketScript, /mc mb --ignore-existing/u);
assert.match(bucketScript, /mc anonymous set none/u);