Skip to content

promote: dev history slice 1 of 4 - #29

Merged
BeforeLights merged 33 commits into
mainfrom
promote/dev-20260803-01
Aug 3, 2026
Merged

promote: dev history slice 1 of 4#29
BeforeLights merged 33 commits into
mainfrom
promote/dev-20260803-01

Conversation

@BeforeLights

@BeforeLights BeforeLights commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Promotion slice 1 of 4

This supersedes oversized historical promotion PR #25 and advances main to the first dev first-parent boundary.

The four promotion slices contain 33, 75, 36, and 37 commits. Each keeps original atomic commits and merge history intact while remaining within the 30-50 normal target or 79-commit exceptional ceiling.

Review policy

  • CodeRabbit: allow the automatic full review exactly once on this PR; do not issue a manual duplicate command
  • merge only after hosted checks pass and all valid findings are resolved
  • use a merge commit into main

Summary by CodeRabbit

  • New Features
    • Added authenticated session management, including current-session lookup, token refresh, sign-out, secure browser cookies, and CSRF protection.
    • Added MFA enrollment, verification, and recovery-code redemption.
    • Added tenant-scoped audit event, entitlement, dataset, mapping, synchronization, capability, policy, and reference-entity APIs.
    • Added PKCE and CSRF security utilities with public package exports.
    • Added persistent support for sessions, access tokens, MFA recovery codes, audit records, and entitlements.
  • Documentation
    • Added execution planning, orchestration, handoff, and rollback guidance.
  • Tests
    • Expanded coverage for security, authentication, tenant isolation, persistence, API contracts, and rollback behavior.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds Luna Max delivery orchestration, PKCE and CSRF domain utilities, IAM session and MFA persistence, tenant-aware HTTP security, audit and entitlement modules, expanded OpenAPI contracts, and comprehensive validation tests.

Changes

Platform foundation and delivery

Layer / File(s) Summary
Luna Max execution controls
docs/plans/*, docs/operations/*, tools/repo-cli/*
Adds a 15-batch execution plan, version 2 orchestration ledger, resumable handoff guidance, and delivery-batch validation.
PKCE and CSRF domain contracts
packages/domain/*
Adds versioned PKCE challenge, redirect URI, CSRF validation, constant-time comparison, exports, and tests.
IAM schema and persistence
services/api/prisma/*, services/api/src/features/iam/adapter/*, services/api/test/features/iam/*
Adds MFA recovery-code and access-token storage, Prisma IAM adapters, session rotation and revocation, tenant validation, revision checks, and transaction tests.
Session, MFA, and tenant HTTP flow
services/api/src/features/iam/api/*, services/api/src/features/iam/application/*, services/api/src/features/iam/iam.module.ts, services/api/test/http-contract.test.ts
Adds browser session cookies, refresh and sign-out endpoints, current-session lookup, MFA endpoints, tenant-context propagation, and structured problem handling.
CSRF and request protection
services/api/src/platform/http/*, services/api/test/platform/http/*
Adds origin and CSRF enforcement for cookie-authenticated unsafe requests and tests fail-closed behavior.
Audit and entitlement persistence
services/api/src/features/aud/*, services/api/src/features/bua/*, services/api/test/features/{aud,bua}/*
Adds transactional Prisma repositories, tenant-scope filtering, immutable audit chains, entitlement usage state, controllers, module registration, and tests.
Application wiring and public API contracts
services/api/src/app.module.ts, services/api/src/bootstrap.ts, services/api/openapi/v1.json, services/api/test/openapi.test.ts
Registers the new feature modules and documents authentication, MFA, audit, entitlement, device, synchronization, policy, and data endpoints.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.19% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies this pull request as the first of four promotions of development history into the main branch.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch promote/dev-20260803-01

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
services/api/src/bootstrap.ts (1)

27-35: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Extend ApiApplicationOptions with the audit and entitlement module options.

AppModule.register at line 43 accepts AppModuleOptions, which now includes AudModuleOptions and BuaModuleOptions (see services/api/src/app.module.ts lines 12-18). ApiApplicationOptions does not extend those two interfaces. A caller of createApiApplication cannot pass auditDatabase or entitlementDatabase. AudModule then selects InMemoryAuditRepositoryAdapter (see services/api/src/features/aud/aud.module.ts lines 33-37), so audit records are not durable in production composition.

🐛 Proposed fix to expose the new module options
 export interface ApiApplicationOptions
   extends IamModuleOptions,
     IaeModuleOptions,
     DsmModuleOptions,
-    DsoModuleOptions {
+    DsoModuleOptions,
+    AudModuleOptions,
+    BuaModuleOptions {
   readonly compatibilityPort?: ClientCompatibilityPort;
   readonly readinessPort?: ReadinessPort;
   readonly requestContext?: RequestContextOptions;
 }

Add the imports:

+import type { AudModuleOptions } from './features/aud/aud.module.js';
+import type { BuaModuleOptions } from './features/bua/bua.module.js';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/src/bootstrap.ts` around lines 27 - 35, Extend
ApiApplicationOptions with AudModuleOptions and BuaModuleOptions, adding the
corresponding imports and preserving the existing module-option inheritance.
This exposes auditDatabase and entitlementDatabase to createApiApplication and
ensures AppModule.register receives the production persistence configuration.
🟠 Major comments (29)
services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts-165-178 (1)

165-178: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

databaseScope drops projectId, which makes a project-scoped row unreadable.

databaseScope persists only scopeType, organizationId, and workspaceId. scopeKey at line 177 still handles a project scope, and entryCreateData (line 392) and reservationCreateData (line 409) both pass a caller-supplied TenantScopeV1, which can be project-scoped.

For a project-scoped entry the write succeeds and stores scopeType: 'project' with no project identifier. Every later read calls persistedScope({ ...row, projectId: null }), which builds a project scope without projectId. parseTenantScopeV1 rejects it and the adapter throws BUA_PERSISTED_SCOPE_INVALID. listUsageState maps all rows for the organization, so one such row makes the whole usage read fail permanently.

Reject a project-scoped tenant scope at the write boundary, or persist projectId and restore it on read. The scopeKey project branch and databaseScope must agree.

Run the following script to check whether project-scoped usage reaches this adapter:

#!/bin/bash
# Description: Check the entitlement tenant scope surface and the Prisma schema columns.
set -euo pipefail

rg -n -C 4 'tenantScope' packages/domain/src/entitlements
rg -n -C 6 "scopeType: 'project'|scopeType === 'project'" services/api/src/features/bua
fd -e prisma . services/api/prisma --exec rg -n -C 25 'UsageLedgerEntry|UsageReservation|EntitlementSnapshot' {}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts`
around lines 165 - 178, Update databaseScope and the corresponding
persisted-scope read path to preserve projectId for project-scoped TenantScopeV1
values, ensuring project scopes remain parseable and consistent with scopeKey.
If the Prisma models cannot persist projectId, instead reject project-scoped
scopes at the entryCreateData and reservationCreateData write boundaries.
services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts-307-311 (1)

307-311: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Bound the audit list reads.

listEvents and listSeals select every row for the organization with no limit and no pagination. AuditController exposes both through GET /v1/audit/events and GET /v1/audit/seals. The audit tables are append-only, so the result size grows for the lifetime of the tenant. A large organization will exhaust request memory and exceed request timeouts. listEvents also hashes every returned event on each call.

Add a bounded page size and a cursor to the port, the adapter, and the controller.

Also applies to: 338-342

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

In `@services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts`
around lines 307 - 311, Update the audit listing contract across the repository
port, Prisma adapter methods listEvents and listSeals, and AuditController
endpoints to accept a bounded page size and cursor, returning only that page
plus continuation metadata as needed. Apply the limit and cursor to both
database queries, preserving ascending sequence ordering, and update controller
request parsing and response handling so callers can paginate instead of loading
all tenant records.
services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts-605-611 (1)

605-611: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

The direct persistUsageState path performs many writes without a transaction.

PrismaEntitlementRepositoryAdapter.persistUsageState builds a PrismaEntitlementTransactionAdapter over the non-transactional client. persistUsageState then writes every ledger entry and every reservation in sequence. A failure in the middle leaves the entries committed and the reservations unwritten.

Usage entries and reservation state must move together. Route this method through this.client.$transaction, the same way withTransaction does at line 581.

🔒 Proposed fix
   public persistUsageState(context: IamTenantContextV1, state: UsageLedgerStateV1): Promise<void> {
-    return new PrismaEntitlementTransactionAdapter(this.client).persistUsageState(context, state);
+    return this.client.$transaction((transaction) =>
+      new PrismaEntitlementTransactionAdapter(transaction).persistUsageState(context, state),
+    );
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts`
around lines 605 - 611, Update
PrismaEntitlementRepositoryAdapter.persistUsageState to execute the operation
inside this.client.$transaction, matching the transaction handling used by
withTransaction. Construct PrismaEntitlementTransactionAdapter with the
transaction client and invoke persistUsageState there so usage entries and
reservations commit or roll back together.
services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts-290-302 (1)

290-302: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Avoid loading the full scope history on every append.

appendEvent loads every audit event row for the scope, but uses only two facts: the presence of a matching idempotencyKey, and the latest row. The audit table is append-only and grows without bound per tenant scope. Each write therefore costs O(n) rows transferred and materialized, and the cost grows for the lifetime of the tenant.

Replace the full scan with two bounded queries: a keyed lookup on (scopeKey, idempotencyKey), and a single-row lookup for the latest sequence. Both require extending AuditEventDelegateV1 with findFirst and take.

⚡ Proposed change
-    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 key = scopeKey(event.tenantScope);
+    const duplicate = await this.client.auditEventRecord.findFirst({
+      where: { scopeKey: key, idempotencyKey: event.idempotencyKey },
+    });
+    if (duplicate !== null) throw new Error('AUD_IDEMPOTENCY_CONFLICT');
+    const [latest] = await this.client.auditEventRecord.findMany({
+      where: { scopeKey: key },
+      orderBy: { sequence: 'desc' },
+      take: 1,
+    });

Extend the delegate interface accordingly:

interface AuditEventDelegateV1 {
  create(input: { readonly data: AuditEventCreateDataV1 }): Promise<AuditEventDatabaseRowV1>;
  findUnique(input: {
    readonly where: { readonly id: string };
  }): Promise<AuditEventDatabaseRowV1 | null>;
  findFirst(input: {
    readonly where: Readonly<Record<string, unknown>>;
  }): Promise<AuditEventDatabaseRowV1 | null>;
  findMany(input: {
    readonly where: Readonly<Record<string, unknown>>;
    readonly orderBy: { readonly sequence: 'asc' | 'desc' };
    readonly take?: number;
  }): Promise<readonly AuditEventDatabaseRowV1[]>;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts`
around lines 290 - 302, Update appendEvent to replace the full-scope findMany
scan with a bounded idempotency lookup using findFirst and a latest-event lookup
using findMany with take: 1, preserving the existing AUD_IDEMPOTENCY_CONFLICT
and AUD_SEQUENCE_CONFLICT checks. Extend AuditEventDelegateV1 with findFirst and
an optional take property on findMany, and ensure the Prisma delegate
implementation supports both inputs.
services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts-312-317 (1)

312-317: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Chain verification groups events across different scopeKey values.

appendEvent maintains sequence and previousDigest continuity per scopeKey (line 291). listEvents selects every row for the organization, filters with visible, and verifies the result as one chain.

visible admits both ancestor and descendant scopes. A tenant that has events at the organization scope and at a workspace scope therefore produces a list that interleaves two independent chains, ordered by sequence. The previousDigest links do not match across that interleave, so verifyAuditChainV1 rejects and listEvents throws AUD_CHAIN_INVALID for a valid database.

Group the events by scopeKey and verify each chain on its own.

🐛 Proposed fix
     const events = rows
       .filter((row) => visible(context.tenantScope, persistedScope(row)))
       .map(persistedEvent);
-    const verified = verifyAuditChainV1(events, this.digestPort);
-    if (!verified.accepted) throw new Error('AUD_CHAIN_INVALID');
+    const chains = new Map<string, AuditEventV1[]>();
+    for (const event of events) {
+      const key = scopeKey(event.tenantScope);
+      const chain = chains.get(key);
+      if (chain === undefined) chains.set(key, [event]);
+      else chain.push(event);
+    }
+    for (const chain of chains.values()) {
+      const verified = verifyAuditChainV1(chain, this.digestPort);
+      if (!verified.accepted) throw new Error('AUD_CHAIN_INVALID');
+    }
     return events;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts`
around lines 312 - 317, Update listEvents around persistedEvent and
verifyAuditChainV1 to group visible rows by scopeKey, then verify each
scope-specific event chain independently using this.digestPort. Preserve
returning the combined events and throw AUD_CHAIN_INVALID if any individual
chain is rejected.
services/api/src/platform/http/csrf-protection.ts-50-76 (1)

50-76: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Widen the cookie-name validation to avoid false CSRF rejections.

parseCookies marks the whole cookie header as malformed when any cookie name fails /^[A-Za-z0-9_]+$/u. Line 120 then rejects the whole request with CSRF_INVALID whenever malformed is true, even though this check only runs when a databreeze_* auth cookie is present.

Real cookie names use characters outside [A-Za-z0-9_] (for example hyphens and dots) per the RFC 2616 token grammar that RFC 6265 references. If a browser sends any such cookie alongside the app's auth cookie, on this call the whole request is rejected, not just the offending cookie. This can break legitimate authenticated mutation requests whenever an unrelated cookie (analytics, another product on the same domain, etc.) uses a hyphenated or dotted name.

Widen the accepted name character set to the standard cookie-name token grammar. Keep the CR/LF checks on the value, since those catch real injection attempts.

🍪 Proposed fix to widen the accepted cookie-name charset
-    if (!/^[A-Za-z0-9_]+$/u.test(name) || value.includes('\r') || value.includes('\n')) {
+    if (!/^[!#$%&'*+\-.^_`|~A-Za-z0-9]+$/u.test(name) || value.includes('\r') || value.includes('\n')) {
       malformed = true;
       continue;
     }

Also applies to: 120-122

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

In `@services/api/src/platform/http/csrf-protection.ts` around lines 50 - 76,
Update the cookie-name validation in parseCookies to accept the RFC token
character set, including valid characters such as hyphens and dots, instead of
restricting names to alphanumerics and underscores. Preserve the existing
malformed handling and duplicate tracking, and keep the value CR/LF rejection
unchanged.
services/api/src/platform/http/request-context.ts-83-86 (1)

83-86: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Configure production CSRF origins.

services/api/src/main.ts starts the API without requestContext options. Production therefore uses the localhost defaults, and browser mutations from deployed frontend origins return ORIGIN_INVALID. Supply options.requestContext.csrf.allowedOrigins from production configuration.

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

In `@services/api/src/platform/http/request-context.ts` around lines 83 - 86,
Update the API startup configuration in main.ts to pass production-configured
CSRF origins through requestContext.csrf.allowedOrigins, instead of allowing
request-context defaults to select localhost origins. Reuse the existing
production configuration source and preserve DEFAULT_CSRF_ALLOWED_ORIGINS_V1
only as the fallback when no production value is configured.
services/api/openapi/v1.json-355-424 (1)

355-424: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

GET /v1/auth/me declares no bearer security, and the contract test cannot detect it. Every other authenticated operation in the snapshot declares "security": [{ "bearer": [] }]. /v1/auth/me returns the authenticated session identity and resolves the caller through SessionRequestTenantContextAdapter, which requires a bearer token. The contract test asserts path presence only, so the omission passes CI.

  • services/api/openapi/v1.json#L355-L424: add @ApiBearerAuth() to AuthenticationController.me and regenerate this snapshot so the operation declares "security": [{ "bearer": [] }].
  • services/api/test/openapi.test.ts#L69-L77: add an assertion that every operation outside the public set (/health/*, /v1/system/*, /v1/auth/sign-in, /v1/auth/refresh) declares a bearer security requirement.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/openapi/v1.json` around lines 355 - 424, Add `@ApiBearerAuth`() to
AuthenticationController.me and regenerate services/api/openapi/v1.json so GET
/v1/auth/me declares security [{ "bearer": [] }]. In
services/api/test/openapi.test.ts, extend the contract test to require bearer
security for every operation except /health/*, /v1/system/*, /v1/auth/sign-in,
and /v1/auth/refresh.
services/api/openapi/v1.json-3935-3949 (1)

3935-3949: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Remove writeOnly from refreshToken in SessionRefreshResponseDto.

SessionRefreshResponseDto is used only as the 200 response of POST /v1/auth/refresh (line 550). In OpenAPI, writeOnly declares that a property appears in requests and must not appear in responses. The current schema therefore documents a property that can never be returned.

Choose one behavior and align the DTO:

  • If the rotated refresh token is returned in the body, remove the writeOnly flag.
  • If the rotated refresh token is returned only through the Set-Cookie header, remove the property from the response DTO.

The refresh-token rotation flow is in services/api/src/features/iam/api/session-refresh-response.dto.ts and services/api/src/features/iam/api/session-cookies.ts.

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

In `@services/api/openapi/v1.json` around lines 3935 - 3949, Align
SessionRefreshResponseDto with the refresh-token delivery behavior by inspecting
the rotation flow in the DTO and session-cookies implementation. If the rotated
token is returned in the response body, remove writeOnly from refreshToken; if
it is delivered only via Set-Cookie, remove refreshToken from the response DTO
and its schema, keeping the OpenAPI response consistent.
services/api/src/app.module.ts-23-32 (1)

23-32: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Derive the tenant context also when only sessionDatabase is supplied.

AppModule.register inspects only options.sessions. IamModule.register also builds a session adapter from options.sessionDatabase (see services/api/src/features/iam/iam.module.ts lines 118-122). If production composition passes sessionDatabase instead of a pre-built sessions port, requestTenantContext stays undefined. Every feature module then receives UnavailableRequestTenantContextAdapter and all tenant-scoped routes fail closed.

Mirror the IamModule fallback so both composition paths produce the same adapter.

🐛 Proposed fix to cover the database-backed composition path
-    const sessions = options.sessions;
+    const sessions =
+      options.sessions ??
+      (options.sessionDatabase === undefined
+        ? undefined
+        : new PrismaSessionLifecycleAdapter(options.sessionDatabase));
     const requestTenantContext =
       options.requestTenantContext ??
       (typeof sessions?.findPrincipalByAccessToken === 'function'
         ? new SessionRequestTenantContextAdapter({
             findPrincipalByAccessToken: sessions.findPrincipalByAccessToken.bind(sessions),
           })
         : undefined);
     const composedOptions =
-      requestTenantContext === undefined ? options : { ...options, requestTenantContext };
+      requestTenantContext === undefined
+        ? options
+        : { ...options, requestTenantContext, sessions };

Add the import:

+import { PrismaSessionLifecycleAdapter } from './features/iam/adapter/prisma-session-lifecycle.adapter.js';

Passing the resolved sessions into composedOptions also prevents IamModule from constructing a second adapter over the same client.

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

In `@services/api/src/app.module.ts` around lines 23 - 32, Update
AppModule.register to derive the sessions port from options.sessionDatabase when
options.sessions is absent, using the same adapter/fallback construction as
IamModule.register. Use the resolved sessions when building composedOptions so
downstream modules, including IamModule, reuse the same client and
requestTenantContext adapter instead of constructing another one.
services/api/openapi/v1.json-4016-4020 (1)

4016-4020: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Require factor proof before activating a factor.

VerifyMfaFactorDto contains only at. The controller passes only input.at, and MfaService.verifyFactor activates the pending factor without validating a TOTP code or WebAuthn assertion. Add method-specific proof validation before saving the transition.

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

In `@services/api/openapi/v1.json` around lines 4016 - 4020, Update
VerifyMfaFactorDto and the factor-verification flow around the controller and
MfaService.verifyFactor to require method-specific proof in addition to at:
validate a TOTP code for TOTP factors or a WebAuthn assertion for WebAuthn
factors before activating or saving the pending factor transition, and reject
missing or invalid proof.
services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts-137-144 (1)

137-144: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Push the tenant filter into the query instead of loading every membership row.

listMemberships calls findMany({ where: {} }). This loads all membership rows for all tenants, then discards most of them in memory. The row count grows with total platform usage, not with the caller's tenant. Every audit, entitlement, and IAM read that reaches this path pays the full table scan.

context.tenantScope.organizationId is always available. Filter on it in the database, then keep visibleInScope for the workspace and project refinement.

⚡ Proposed fix to narrow the query
   public async listMemberships(
     context: IamTenantContextV1,
   ): Promise<readonly IamMembershipRecordV1[]> {
-    const rows = await this.client.membershipIdentity.findMany({ where: {} });
+    const rows = await this.client.membershipIdentity.findMany({
+      where: { organizationId: context.tenantScope.organizationId },
+    });
     return rows
       .map(membershipFromRow)
       .filter((membership) => visibleInScope(context.tenantScope, membership.scope));
   }

Apply the same narrowing to findMembership at Line 126:

const rows = await this.client.membershipIdentity.findMany({
  where: { principalId, organizationId: context.tenantScope.organizationId },
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts`
around lines 137 - 144, Update listMemberships and findMembership in the Prisma
IAM repository to include context.tenantScope.organizationId in the
membershipIdentity.findMany where clause, restricting rows at the database
level. Preserve visibleInScope filtering in listMemberships for workspace and
project refinement, and retain the existing principalId condition in
findMembership.
services/api/test/http-contract.test.ts-569-589 (1)

569-589: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

The entitlement endpoints break the problem-details convention used elsewhere in this contract.

Both cases assert HTTP 200 with an accepted: false envelope. A missing snapshot returns 200. A malformed identifier returns 200. The same test file asserts RFC 9457 problem responses with accurate status codes everywhere else: 403 for CSRF at Line 201, 401 for an invalid session at Line 438, 400 for a rejected MFA request at Line 647.

The behavior originates in services/api/src/features/bua/api/entitlement.controller.ts (Lines 30-47), which returns the envelope instead of raising a problem. Two consequences follow. A client cannot rely on the status code to detect failure and must branch on the body shape for these two routes only. Caches and proxies treat the 200 "not found" as a successful response.

A malformed identifier fits 400. A missing snapshot fits 404.

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

In `@services/api/test/http-contract.test.ts` around lines 569 - 589, Update the
entitlement snapshot GET handler in entitlement.controller.ts to use RFC 9457
problem responses instead of returning accepted:false envelopes: raise a 400
problem for malformed identifiers and a 404 problem when the snapshot is
missing, while preserving successful responses for valid snapshots. Update the
corresponding assertions in the entitlement contract test to verify the status
codes and problem-details response shape.
services/api/test/platform/http/session-tenant-context.test.ts-69-80 (1)

69-80: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

This test passes for the wrong reason and asserts the wrong code.

'Bearer token' carries a 5-character token. The regex at Line 65 of services/api/src/platform/http/session-tenant-context.adapter.ts requires 20 to 4096 characters. bearerToken returns undefined, and the adapter throws AUTHENTICATION_FAILED at Line 77 before findPrincipalByAccessToken runs.

Two consequences follow. The securityEpoch: 0 stub is never evaluated, so the "unsafe principal state" behavior is untested. The request.id fallback for the idempotency key is never reached, so the "uses the request id for read-only calls" behavior in the test name is also untested.

The expected code is wrong for the intended path. A valid token with securityEpoch: 0 reaches createIamTenantContextV1, which rejects it with INVALID_EPOCH, so the adapter throws CONTEXT_INVALID.

💚 Proposed fix
 void test('uses the request id for read-only calls and rejects unsafe principal state', async () => {
-  const adapter = new SessionRequestTenantContextAdapter({
-    findPrincipalByAccessToken: () => Promise.resolve({ ...principal, securityEpoch: 0 }),
-  });
-  await assert.rejects(
-    adapter.resolve({ id: 'request-read-001', headers: { authorization: 'Bearer token' } }),
-    (error: unknown) => {
-      assert.equal((error as { code?: unknown }).code, 'AUTHENTICATION_FAILED');
-      return true;
-    },
-  );
+  const unsafe = new SessionRequestTenantContextAdapter({
+    findPrincipalByAccessToken: () => Promise.resolve({ ...principal, securityEpoch: 0 }),
+  });
+  await assert.rejects(
+    unsafe.resolve({
+      id: 'request-read-001',
+      headers: { authorization: 'Bearer valid-looking-access-token-1' },
+    }),
+    (error: unknown) => {
+      assert.equal((error as { code?: unknown }).code, 'CONTEXT_INVALID');
+      return true;
+    },
+  );
+
+  const healthy = new SessionRequestTenantContextAdapter({
+    findPrincipalByAccessToken: () => Promise.resolve(principal),
+  });
+  const context = await healthy.resolve({
+    id: 'request-read-001',
+    headers: { authorization: 'Bearer valid-looking-access-token-1' },
+  });
+  assert.equal(context.idempotencyKey, 'request-read-001');
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/test/platform/http/session-tenant-context.test.ts` around lines
69 - 80, Update the test around SessionRequestTenantContextAdapter.resolve to
use an authorization token meeting the adapter’s minimum length, allowing
findPrincipalByAccessToken to execute with securityEpoch: 0 and the request.id
fallback to be exercised. Assert the resulting error code is CONTEXT_INVALID,
reflecting createIamTenantContextV1’s rejection of the unsafe principal state.
services/api/src/platform/http/session-tenant-context.adapter.ts-79-86 (1)

79-86: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The catch reports infrastructure failures as authentication failures.

findPrincipalByAccessToken reaches the session store. The catch at Lines 82-84 converts every thrown error into AUTHENTICATION_FAILED. A database outage, a connection-pool exhaustion, and a rejected token all produce the same 401.

Two consequences follow. Clients treat a transient outage as a credential problem and re-authenticate, which cannot succeed. Operators lose the signal, because the original error is discarded without being logged or rethrown.

Map a lookup failure to a distinct problem code, and keep AUTHENTICATION_FAILED for the undefined principal at Line 85. Preserve the original error as the cause so it reaches the logs.

🔧 Proposed fix
-export type RequestTenantContextProblemCodeV1 = 'AUTHENTICATION_FAILED' | 'CONTEXT_INVALID';
+export type RequestTenantContextProblemCodeV1 =
+  | 'AUTHENTICATION_FAILED'
+  | 'CONTEXT_INVALID'
+  | 'SESSION_LOOKUP_UNAVAILABLE';
 
 export class RequestTenantContextProblemError extends Error {
-  constructor(readonly code: RequestTenantContextProblemCodeV1) {
-    super(code);
+  constructor(
+    readonly code: RequestTenantContextProblemCodeV1,
+    options?: { readonly cause?: unknown },
+  ) {
+    super(code, options);
     this.name = 'RequestTenantContextProblemError';
   }
 }
     let principal: AuthenticatedPrincipalV1 | undefined;
     try {
       principal = await this.sessions.findPrincipalByAccessToken(token);
-    } catch {
-      throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED');
+    } catch (error) {
+      throw new RequestTenantContextProblemError('SESSION_LOOKUP_UNAVAILABLE', { cause: error });
     }

Map the new code to a 503 in the problem-details filter.

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

In `@services/api/src/platform/http/session-tenant-context.adapter.ts` around
lines 79 - 86, Update the error handling around SessionTenantContextAdapter’s
findPrincipalByAccessToken call to throw a distinct lookup/infrastructure
problem code while preserving the caught error as its cause. Keep
AUTHENTICATION_FAILED exclusively for an undefined principal, and add the new
problem code to the problem-details filter with a 503 response mapping.
services/api/src/platform/http/session-tenant-context.adapter.ts-55-60 (1)

55-60: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fabricating an idempotency key defeats idempotency for mutations.

When no idempotency-key header is present and request.id is absent, idempotencyKey returns randomUUID(). Each retry of the same mutation then carries a different key. A downstream deduplication check keyed on this value never matches, so a retried write executes twice.

RequestLikeV1 declares method at Line 21, and no code reads it. The test at Line 69 of services/api/test/platform/http/session-tenant-context.test.ts is named "uses the request id for read-only calls", which suggests that method-aware handling was intended and not completed.

For unsafe methods, require a caller-supplied key and reject the request when it is missing. Keep the generated fallback for safe methods, where the key is only correlation metadata.

🔧 Proposed fix
+const SAFE_METHODS_V1 = new Set(['GET', 'HEAD', 'OPTIONS']);
+
+function isSafeMethod(request: RequestLikeV1): boolean {
+  return typeof request.method === 'string' && SAFE_METHODS_V1.has(request.method.toUpperCase());
+}
+
 function idempotencyKey(request: RequestLikeV1): string {
   const header = oneHeader(request, 'idempotency-key');
   if (header !== undefined) return header;
   if (typeof request.id === 'string' && request.id.length > 0) return request.id;
+  if (!isSafeMethod(request)) throw new RequestTenantContextProblemError('CONTEXT_INVALID');
   return randomUUID();
 }

A dedicated IDEMPOTENCY_KEY_REQUIRED problem code would describe the failure better than CONTEXT_INVALID.

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

In `@services/api/src/platform/http/session-tenant-context.adapter.ts` around
lines 55 - 60, Update idempotencyKey to branch on RequestLikeV1.method: preserve
the header and non-empty request.id behavior, retain randomUUID() only for safe
methods, and reject unsafe-method requests when neither caller-supplied value
exists. Use the existing request-validation mechanism, preferably introducing
the proposed IDEMPOTENCY_KEY_REQUIRED problem code instead of CONTEXT_INVALID.
services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts-122-144 (1)

122-144: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A single unparseable row makes both read paths throw for unrelated callers.

findMembership and listMemberships map every fetched row through membershipFromRow, which throws IAM_PERSISTED_MEMBERSHIP_INVALID when validation fails. One corrupt, legacy, or partially migrated row therefore fails the whole read for callers who have no interest in that row.

The credential lookup adapter takes the opposite approach. In services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts (Lines 84-99), activeMembership returns undefined for rows it cannot interpret. Align the read paths with that behavior and keep the throw in saveMembership, where a corrupt existing row must block the write.

🛡️ Proposed fix to skip unusable rows on reads
+function membershipFromRowOrSkip(
+  row: IamMembershipDatabaseRowV1,
+): IamMembershipRecordV1 | undefined {
+  const scope = scopeFromRow(row);
+  const validated = validateMembershipV1({
+    id: row.id,
+    principalType: row.principalType,
+    principalId: row.principalId,
+    scope,
+    roleId: row.roleId,
+    status: row.status,
+    ...(row.startsAt ? { startsAt: timestamp(row.startsAt) } : {}),
+    ...(row.expiresAt ? { expiresAt: timestamp(row.expiresAt) } : {}),
+    revision: row.revision,
+  });
+  return validated.accepted ? validated.value : undefined;
+}

Then use it in the read paths:

     return rows
-      .map(membershipFromRow)
+      .flatMap((row) => membershipFromRowOrSkip(row) ?? [])
       .filter((membership) => visibleInScope(context.tenantScope, membership.scope));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts`
around lines 122 - 144, Update findMembership and listMemberships to skip rows
that membershipFromRow cannot parse, matching the credential lookup adapter’s
activeMembership behavior. Preserve filtering for principal, ACTIVE status, and
visible scope on successfully parsed rows, while leaving saveMembership’s
validation failure behavior unchanged.
services/api/src/features/iam/application/tenant-context.ts-14-14 (1)

14-14: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Enforce mfaRequired before protected operations.

SessionRequestTenantContextAdapter only propagates principal.mfaRequired. MfaService.requireStepUp has no production call sites. Protected controllers pass the context to repositories and services without checking the flag. Add a centralized guard or interceptor that blocks sensitive operations until step-up succeeds.

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

In `@services/api/src/features/iam/application/tenant-context.ts` at line 14,
Update SessionRequestTenantContextAdapter and the protected-operation request
path to enforce tenantContext.mfaRequired before sensitive controllers,
repositories, or services execute. Add a centralized guard or interceptor that
blocks requests requiring MFA until MfaService.requireStepUp succeeds, while
allowing operations when the flag is absent or false.
services/api/test/http-contract.test.ts-459-495 (1)

459-495: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Authorize sign-out against the caller's session.

signOut directly revokes the body-supplied sessionId without authentication or ownership checks. Require the caller to own that session, and test rejection of another user's sessionId.

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

In `@services/api/test/http-contract.test.ts` around lines 459 - 495, Update the
sign-out flow exercised by the web and native requests to authenticate the
caller and verify ownership of the supplied sessionId before revoking it.
Preserve successful revocation for the caller’s own sessions, and extend the
sign-out contract test to submit another user’s sessionId and assert the request
is rejected without revoking that session.
services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts-32-51 (1)

32-51: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use a transaction-specific client type.

PrismaClient is not assignable to IamDatabaseClientV1 because Prisma’s TransactionClient does not expose $transaction. Define a separate transaction client interface and use it for the callback parameter. The membershipIdentity delegate name matches the MembershipIdentity model.

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

In `@services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts`
around lines 32 - 51, Define a separate transaction-specific interface for the
client passed to `$transaction`, containing only the `membershipIdentity`
delegate and no `$transaction` method. Update the `$transaction` callback
parameter in `IamDatabaseClientV1` to use that interface while retaining
`IamDatabaseClientV1` as the root client type and preserving the existing
delegate name.
services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts-190-215 (1)

190-215: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Select the bootstrap records with stable discriminators instead of first match.

findByUserId resolves three records by taking the first candidate that matches a loose predicate:

  • Line 190: the first owner organization-scope membership. A user can own more than one organization. The selected membership is then not necessarily the personal organization.
  • Line 209: the first workspace whose name equals the literal 'Personal workspace'. If the workspace is renamed, the method throws IAM_PERSISTED_WORKSPACE_INVALID instead of returning the bootstrap.
  • Line 214: the first project with kind === 'INTERNAL'.

None of the findMany calls sets orderBy, so PostgreSQL does not guarantee a stable order for the remaining candidates.

Filter the membership by the personal organization (organization.personal === true) and select the workspace and project by a persisted marker or a deterministic order rather than by display name.

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

In
`@services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts`
around lines 190 - 215, Update findByUserId to resolve bootstrap records using
stable discriminators: identify the owner membership whose organization is
personal rather than selecting the first owner membership, and select the
personal workspace via its persisted marker or deterministic ordering instead of
the literal name. Replace the project’s first INTERNAL match with a persisted
marker or deterministic ordering as well, preserving the existing invalid-record
errors when no valid bootstrap record exists.
services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts-230-248 (1)

230-248: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Enforce the revision check in the UPDATE predicate.

saveState reads the current state at Line 230, validates revisions in memory at Line 231, then updates each row by id alone. The read and the write are separated, so the check does not hold under concurrency. On PostgreSQL, Prisma runs $transaction at the database default isolation level, which is READ COMMITTED. Two concurrent saveState calls for the same user can both read revision 1, both pass immutableState, and both write revision 2. One update is then lost, and a revoked factor can be resurrected as active.

Include the expected revision in the where clause and verify that exactly one row changed. MfaFactorDelegateV1.update and MfaRecoveryCodeDelegateV1.update currently accept only { id }, so the delegate types need a matching change (or use updateMany).

Line 195 relaxes the check further: it accepts factor.revision === prior.revision while other fields change, so a status change can be persisted without a revision bump. Require a strict increment for every modified record.

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

In `@services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts`
around lines 230 - 248, Update saveState and the
MfaFactorDelegateV1/MfaRecoveryCodeDelegateV1 update path to enforce optimistic
concurrency: require every modified factor’s revision to equal prior.revision +
1, include prior.revision in the database update predicate, and verify exactly
one row was updated, raising IAM_MFA_REVISION_CONFLICT otherwise. Preserve
immutableState validation while preventing concurrent writes from silently
overwriting each other.
services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts-300-318 (1)

300-318: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Run save inside a transaction.

PrismaIdentityBootstrapRepositoryAdapter.save calls the transaction adapter with this.client, which is the base client. PrismaIdentityBootstrapTransactionAdapter.save then performs four separate writes (Lines 280-283). If one write fails, the earlier writes stay committed. The user is left with a partial personal-organization graph: for example an organization and workspace without a project or an owner membership. A later retry then hits saveImmutable on the already-created rows.

Wrap the write path in $transaction, as PrismaMfaRepositoryAdapter.saveState does in services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts (Lines 275-279).

🐛 Proposed fix to make bootstrap persistence atomic
   public save(bootstrap: PersonalOrganizationBootstrapV1) {
-    return new PrismaIdentityBootstrapTransactionAdapter(this.client).save(bootstrap);
+    return this.client.$transaction((transaction) =>
+      new PrismaIdentityBootstrapTransactionAdapter(transaction).save(bootstrap),
+    );
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts`
around lines 300 - 318, Update PrismaIdentityBootstrapRepositoryAdapter.save to
execute the PrismaIdentityBootstrapTransactionAdapter.save operation inside
this.client.$transaction, passing the transaction-scoped client to the adapter
so all four writes commit or roll back atomically. Keep findByUserId and
withTransaction unchanged.
services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts-150-162 (1)

150-162: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the fallback workspace selection deterministic.

For an ORGANIZATION-scope membership, the adapter picks the first row that workspaceIdentity.findMany returns. The query has no orderBy, so PostgreSQL does not guarantee a stable row order. If the organization has more than one active workspace, the resolved workspaceId can change between sign-ins for the same user. The principal's tenant scope then becomes unstable.

Add an explicit orderBy (for example the workspace creation timestamp) so the canonical workspace resolves the same way on every lookup. The membership query on Line 126 already applies orderBy: { createdAt: 'asc' }, so apply the same rule here.

Note that WorkspaceLookupDelegateV1.findMany (Lines 52-56) does not accept orderBy today, so the delegate type also needs the field.

🐛 Proposed fix for deterministic workspace selection
 interface WorkspaceLookupDelegateV1 extends UniqueDelegateV1<WorkspaceIdentityDatabaseRowV1> {
   readonly findMany?: (input: {
     readonly where: Readonly<Record<string, unknown>>;
+    readonly orderBy?: Readonly<Record<string, 'asc' | 'desc'>>;
   }) => Promise<readonly WorkspaceIdentityDatabaseRowV1[]>;
 }
       const workspaces = await this.client.workspaceIdentity.findMany({
         where: { organizationId: selected.organizationId, status: 'ACTIVE' },
+        orderBy: { createdAt: 'asc' },
       });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts`
around lines 150 - 162, Make the fallback workspace lookup deterministic by
adding ascending createdAt ordering to the workspaceIdentity.findMany call in
the ORGANIZATION-scope resolution path, matching the existing membership query.
Extend the WorkspaceLookupDelegateV1.findMany type to accept the orderBy field
and ensure the adapter forwards it, preserving selection of the first active
workspace after ordering.
services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts-325-336 (1)

325-336: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not fall back to the presented token as the active token.

Line 332 uses activeToken?.id ?? token.id. If the family has no ACTIVE refresh token, the adapter treats the presented token as the active one, and rotateRefreshFamilyV1 cannot report REUSE_DETECTED. The rotation then depends only on familyStatus and tokenExpiresAt for safety. That is a fail-open default in the reuse-detection path.

Line 325 also reads active[0] from a findMany without orderBy. If more than one row is ACTIVE for the family, the selected row is not deterministic.

Treat an empty active set as reuse and fail closed.

🔒️ Proposed fail-closed handling
       const activeToken = active[0] ? tokenFromRow(active[0]) : undefined;
+      if (!activeToken) return { accepted: false, code: 'REUSE_DETECTED' };
       const rotated = rotateRefreshFamilyV1({
         now: now.toISOString(),
         presentedTokenId: token.id,
-        activeTokenId: activeToken?.id ?? token.id,
+        activeTokenId: activeToken.id,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts`
around lines 325 - 336, Update the active-token selection in the refresh-family
rotation flow around rotateRefreshFamilyV1: make the ACTIVE query deterministic
with an explicit orderBy, and do not substitute token.id when no ACTIVE row
exists. Represent the missing active token so rotateRefreshFamilyV1 can classify
the request as reuse (REUSE_DETECTED) and fail closed, while preserving the
existing family-status and expiry inputs.
services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts-320-324 (1)

320-324: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

refresh does not enforce the inactivity window.

refresh loads the session and then rotates the token family. It checks the refresh-token expiry and the session status, but it never compares session.inactivityExpiresAt with now. Line 379 then extends inactivityExpiresAt unconditionally. An idle session can therefore be refreshed after the inactivity window has passed, and the window restarts. The inactivity control never terminates a session while the refresh token remains valid, which leaves the absolute window as the only bound.

findPrincipal enforces the same field at Line 474, so the two paths disagree about session validity.

Reject the refresh and mark the session EXPIRED when now is at or after session.inactivityExpiresAt.

🔒️ Proposed inactivity check
       const session = sessionFromRow(sessionRow);
+      if (now.getTime() >= Date.parse(session.inactivityExpiresAt)) {
+        await transaction.refreshTokenRecord.updateMany({
+          where: { familyId: token.familyId, status: 'ACTIVE' },
+          data: { status: 'EXPIRED' },
+        });
+        await transaction.sessionRecord.update({
+          where: { id: token.sessionId },
+          data: { status: 'EXPIRED' },
+        });
+        return { accepted: false, code: 'EXPIRED' };
+      }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts`
around lines 320 - 324, Update the refresh flow after sessionFromRow in the
refresh method to reject sessions when now is at or after
session.inactivityExpiresAt, marking the session status as EXPIRED before
returning INVALID_REFRESH_TOKEN. Keep valid sessions on the existing
token-rotation path, while preserving the corresponding behavior enforced by
findPrincipal.
services/api/src/features/iam/api/mfa.controller.ts-25-34 (1)

25-34: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use server time for MFA timestamps

The domain functions only validate timestamp syntax. They do not compare timestamps with trusted server state. Client input can therefore forge enrolledAt, verifiedAt, revokedAt, and usedAt. Recovery-code single-use enforcement uses status, not timestamps. Remove these writable timestamp fields and derive them from an injected server clock.

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

In `@services/api/src/features/iam/api/mfa.controller.ts` around lines 25 - 34,
Update the MFA enrollment flow centered on enroll and the underlying MFA domain
APIs to stop accepting client-provided enrolledAt, verifiedAt, revokedAt, and
usedAt values; derive each timestamp from the injected server clock at the
corresponding state transition, while preserving recovery-code single-use
enforcement through status.
services/api/src/features/iam/api/session-refresh-response.dto.ts-15-20 (1)

15-20: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Remove writeOnly: true; this DTO is a response with refreshToken actually populated.

writeOnly: true tells OpenAPI consumers that a field is accepted in requests but never returned in responses. This is a response DTO, and authentication.controller.ts's refresh handler explicitly returns refreshToken in the body for non-web clients. The sibling AuthSessionDto.refreshToken property (auth-session.dto.ts) has the same shape without writeOnly, which confirms the inconsistency here. Generated API clients that honor writeOnly may drop this field and break token persistence for non-web clients.

🛠️ Proposed fix
-  `@ApiProperty`({ minLength: 1, maxLength: 4096, required: false, writeOnly: true })
+  `@ApiProperty`({ minLength: 1, maxLength: 4096, required: false })
   `@IsOptional`()
   `@IsString`()
   `@MinLength`(1)
   `@MaxLength`(4096)
   refreshToken?: string;
#!/bin/bash
rg -n "writeOnly" services/api/openapi/v1.json -C3
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api/src/features/iam/api/session-refresh-response.dto.ts` around
lines 15 - 20, Remove the writeOnly: true option from the ApiProperty decorator
on the refreshToken property in the session refresh response DTO. Preserve the
existing validation constraints and optionality so OpenAPI clients recognize
refreshToken as a returned response field.
docs/plans/004-luna-max-execution-plan.md-65-82 (1)

65-82: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Commit-budget minimums disagree between the plan table and the JSON ledger for every batch. docs/plans/004-luna-max-execution-plan.md's "Commit budget" column documents batch-specific minimums (for example, 50 for B01, 65 for B12, 35 for B13), while docs/plans/execution-orchestration.json sets commitBudget.minimum: 30 uniformly for all 15 batches. target and maximum match exactly between the two files in every batch; only minimum diverges. Neither the checker (check-execution-orchestration.mjs, which only asserts minimum >= 30) nor the tests catch this drift.

  • docs/plans/004-luna-max-execution-plan.md#L65-L82: Update the "Commit budget" column to state 30 as the minimum for every batch to match the enforced ledger, or state explicitly that these minimums are non-binding planning guidance distinct from the enforced floor.
  • docs/plans/execution-orchestration.json#L143-L170: Set each batch's commitBudget.minimum to the value documented in the 004 plan's table (repeat for every batch through line 325), so the machine-enforced floor matches the documented per-batch policy.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/plans/004-luna-max-execution-plan.md` around lines 65 - 82, Align the
commit-budget minimum policy across both sources: in
docs/plans/004-luna-max-execution-plan.md lines 65-82, retain the documented
per-batch minimums and explicitly identify them as the enforced policy, then
update every batch’s commitBudget.minimum in
docs/plans/execution-orchestration.json lines 143-170 and through line 325 to
match those values; update checker/tests if needed to validate the alignment.

Comment thread services/api/src/features/iam/api/authentication.controller.ts
@BeforeLights

Copy link
Copy Markdown
Contributor Author

CodeRabbit review disposition: this PR received exactly one automatic full review, and no rerun was requested. All four inline findings were reproduced, accepted, fixed on dev, and merged through PR #30; the fixing commits are linked in the inline replies. The broader review also produced two rejected suggestions: the alleged multi-scope audit-chain break is not reproducible because verification groups entries by scope, and a proposed central MFA-required guard would lock out every enrolled user before step-up proof is established. Full accepted/rejected rationale and verification evidence: https://github.com/DatabreezeService/databreeze-platform/blob/dev/docs/operations/coderabbit-pr-29-disposition.md. This is an ordered historical promotion slice only; main is not releaseable until all bounded promotion slices have landed and the coordinated release gate passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant