From e6800dbab7e873eaf7b26dfe8282067eaa37dae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:32:05 +0700 Subject: [PATCH 01/59] fix(iam): compare bootstrap rows by owned fields --- ...a-identity-bootstrap-repository.adapter.ts | 20 +++++++++++++++++-- ...isma-identity-bootstrap-repository.test.ts | 9 ++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts index 03c7d76f..6a35b8c3 100644 --- a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts @@ -88,6 +88,22 @@ interface MembershipDelegateV1 extends IdentityDelegateV1; } +function valuesEqual(left: unknown, right: unknown): boolean { + if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime(); + return left === right; +} + +function ownedFieldsMatch( + existing: TRow, + expected: TRow, +): boolean { + const existingRecord = existing as Record; + const expectedRecord = expected as Record; + return Object.keys(expectedRecord).every((key) => + valuesEqual(existingRecord[key], expectedRecord[key]), + ); +} + export interface IdentityBootstrapDatabaseClientV1 { readonly userIdentity: UserDelegateV1; readonly organizationIdentity: IdentityDelegateV1; @@ -237,7 +253,7 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran public async save(bootstrap: PersonalOrganizationBootstrapV1): Promise { const userRow = await this.client.userIdentity.findUnique({ where: { id: bootstrap.user.id } }); if (!userRow) throw new Error('IAM_USER_NOT_FOUND'); - if (JSON.stringify(userFromRow(userRow)) !== JSON.stringify(bootstrap.user)) + if (!ownedFieldsMatch(userFromRow(userRow), bootstrap.user)) throw new Error('IAM_BOOTSTRAP_CONFLICT'); const organizationData: OrganizationIdentityDatabaseRowV1 = { id: bootstrap.organization.id, @@ -289,7 +305,7 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran ): Promise { const existing = await delegate.findUnique({ where: { id: expected.id } }); if (existing) { - if (JSON.stringify(existing) !== JSON.stringify(expected)) + if (!ownedFieldsMatch(existing, expected)) throw new Error('IAM_BOOTSTRAP_CONFLICT'); return; } diff --git a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts index ccdbe89b..6af14a07 100644 --- a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts +++ b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts @@ -162,13 +162,20 @@ void test('[IAM-001, IAM-009, IAM-011] Prisma bootstrap persists and reconstruct }); void test('[IAM-011] repeated bootstrap is immutable and conflicting hierarchy is rejected', async () => { - const { client } = createDatabase(); + const { client, organizations } = createDatabase(); const adapter = new PrismaIdentityBootstrapRepositoryAdapter(client); const validated = bootstrapPersonalOrganizationV1(input); assert.equal(validated.accepted, true); if (!validated.accepted) return; await adapter.save(validated.value); + const organization = organizations.get(organizationId); + assert.ok(organization); + organizations.set(organizationId, { + ...organization, + updatedAt: new Date('2026-01-01T00:00:01.000Z'), + createdAt: organization.createdAt, + } as typeof organization); await assert.doesNotReject(() => adapter.save(validated.value)); await assert.rejects( adapter.save({ From b7ee10abc89849b63b70aef196b47bd1f39789da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:33:45 +0700 Subject: [PATCH 02/59] perf(iam): bound membership reads to organization scope --- .../iam/adapter/prisma-iam-repository.adapter.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts index 995fe68c..7c163734 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -35,6 +35,7 @@ interface IamMembershipDelegateV1 { }): Promise; findMany(input: { readonly where: Readonly>; + readonly orderBy?: Readonly>; }): Promise; create(input: { readonly data: IamMembershipDatabaseRowV1 }): Promise; updateMany(input: { @@ -123,7 +124,14 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { context: IamTenantContextV1, principalId: StableIdentifierV1, ): Promise { - const rows = await this.client.membershipIdentity.findMany({ where: { principalId } }); + const rows = await this.client.membershipIdentity.findMany({ + where: { + organizationId: context.tenantScope.organizationId, + principalId, + status: 'ACTIVE', + }, + orderBy: { id: 'asc' }, + }); return rows .map(membershipFromRow) .find( @@ -137,7 +145,10 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { public async listMemberships( context: IamTenantContextV1, ): Promise { - const rows = await this.client.membershipIdentity.findMany({ where: {} }); + const rows = await this.client.membershipIdentity.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); return rows .map(membershipFromRow) .filter((membership) => visibleInScope(context.tenantScope, membership.scope)); From 26ff403e453a6dc1121ddc8f09ed755d7a9a9b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:35:33 +0700 Subject: [PATCH 03/59] fix(iam): make membership selection deterministic --- .../prisma-credential-lookup.adapter.ts | 10 +++++- .../iam/prisma-credential-lookup.test.ts | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts index 4830cfc5..bdae6fcc 100644 --- a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts @@ -52,6 +52,7 @@ interface UniqueDelegateV1 { interface WorkspaceLookupDelegateV1 extends UniqueDelegateV1 { readonly findMany?: (input: { readonly where: Readonly>; + readonly orderBy?: Readonly>; }) => Promise; } @@ -140,7 +141,13 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { const selected = memberships .map((membership) => activeMembership(membership, userId)) - .find((membership): membership is ActiveMembershipV1 => membership !== undefined); + .filter((membership): membership is ActiveMembershipV1 => membership !== undefined) + .sort((left, right) => + `${left.organizationId}:${left.workspaceId ?? ''}`.localeCompare( + `${right.organizationId}:${right.workspaceId ?? ''}`, + ), + ) + .at(0); if (!selected) return undefined; const [organization, factors] = await Promise.all([ @@ -152,6 +159,7 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { if (!this.client.workspaceIdentity.findMany) return undefined; const workspaces = await this.client.workspaceIdentity.findMany({ where: { organizationId: selected.organizationId, status: 'ACTIVE' }, + orderBy: { id: 'asc' }, }); const workspace = workspaces.find( (candidate) => diff --git a/services/api/test/features/iam/prisma-credential-lookup.test.ts b/services/api/test/features/iam/prisma-credential-lookup.test.ts index 2636c30a..c82b5805 100644 --- a/services/api/test/features/iam/prisma-credential-lookup.test.ts +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -141,3 +141,36 @@ void test('[IAM-001, IAM-009] an organization owner resolves the canonical activ assert.equal(result?.principal.organizationId, organizationId); assert.equal(result?.principal.workspaceId, workspaceId); }); + +void test('[IAM-002] workspace membership selection is deterministic', async () => { + const secondWorkspaceId = '00000000-0000-4000-8000-000000000007'; + const adapter = new PrismaCredentialLookupAdapter( + database({ + membershipIdentity: { + findMany: async () => [ + { + id: '00000000-0000-4000-8000-000000000008', + principalId: userId, + organizationId, + workspaceId: secondWorkspaceId, + projectId: null, + scopeType: 'WORKSPACE', + status: 'ACTIVE', + }, + { + id: membershipId, + principalId: userId, + organizationId, + workspaceId, + projectId: null, + scopeType: 'WORKSPACE', + status: 'ACTIVE', + }, + ], + }, + }), + ); + + const result = await adapter.findCredential('user@example.com'); + assert.equal(result?.principal.workspaceId, workspaceId); +}); From 587eebeead46efa7b2290ecfd99e45aa711dd869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:36:14 +0700 Subject: [PATCH 04/59] fix(iam): expire in-memory access tokens --- .../adapter/in-memory-session-lifecycle.adapter.ts | 8 +++++++- .../api/test/features/iam/session-lifecycle.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts index d70f8378..1030359a 100644 --- a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts @@ -201,7 +201,13 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { await Promise.resolve(); if (typeof accessTokenInput !== 'string' || accessTokenInput.length < 80) return undefined; const sessionId = this.accessTokens.get(digestToken(accessTokenInput)); - return sessionId === undefined ? undefined : this.findPrincipal(sessionId); + if (sessionId === undefined) return undefined; + const session = this.sessions.get(sessionId); + if (!session || Date.parse(session.record.accessExpiresAt) <= this.clock().getTime()) { + this.accessTokens.delete(digestToken(accessTokenInput)); + return undefined; + } + return this.findPrincipal(sessionId); } private revokeFamily(familyId: StableIdentifierV1): void { diff --git a/services/api/test/features/iam/session-lifecycle.test.ts b/services/api/test/features/iam/session-lifecycle.test.ts index 396edc60..d6dcccbc 100644 --- a/services/api/test/features/iam/session-lifecycle.test.ts +++ b/services/api/test/features/iam/session-lifecycle.test.ts @@ -53,3 +53,15 @@ void test('[IAM-005] expired and malformed refresh tokens fail without token dis code: 'INVALID_REFRESH_TOKEN', }); }); + +void test('[IAM-005] access-token lookup fails closed at expiry', async () => { + let now = new Date('2026-01-01T00:00:00.000Z'); + const adapter = new InMemorySessionLifecycleAdapter({ clock: () => new Date(now) }); + const session = await adapter.issue(principal, 'web'); + assert.equal( + (await adapter.findPrincipalByAccessToken(session.accessToken))?.userId, + principal.userId, + ); + now = new Date('2026-01-01T00:15:00.000Z'); + assert.equal(await adapter.findPrincipalByAccessToken(session.accessToken), undefined); +}); From e668bd4b34e8438a2c3bec8146d17cf1486319e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:41:11 +0700 Subject: [PATCH 05/59] fix(iam): compare-and-set MFA revisions --- .../adapter/prisma-mfa-repository.adapter.ts | 18 ++++-- .../iam/prisma-mfa-repository.test.ts | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts index 748723d9..4af5d094 100644 --- a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts @@ -50,6 +50,10 @@ interface MfaFactorDelegateV1 { readonly where: { readonly id: string }; readonly data: Partial; }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Partial; + }): Promise<{ readonly count: number }>; } interface MfaRecoveryCodeDelegateV1 { @@ -66,6 +70,10 @@ interface MfaRecoveryCodeDelegateV1 { readonly where: { readonly id: string }; readonly data: Partial; }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Partial; + }): Promise<{ readonly count: number }>; } export interface MfaDatabaseClientV1 { @@ -244,8 +252,8 @@ class PrismaMfaTransactionAdapter implements MfaTransactionPortV1 { continue; } if (JSON.stringify(prior) === JSON.stringify(factor)) continue; - await this.client.mfaFactor.update({ - where: { id: factor.id }, + const updated = await this.client.mfaFactor.updateMany({ + where: { id: factor.id, revision: prior.revision }, data: { status: factor.status, verifiedAt: factor.verifiedAt ? new Date(factor.verifiedAt) : null, @@ -253,6 +261,7 @@ class PrismaMfaTransactionAdapter implements MfaTransactionPortV1 { revision: factor.revision, }, }); + if (updated.count !== 1) throw new Error('IAM_MFA_REVISION_CONFLICT'); } for (const code of state.recoveryCodes) { const prior = existing.recoveryCodes.find((candidate) => candidate.id === code.id); @@ -261,14 +270,15 @@ class PrismaMfaTransactionAdapter implements MfaTransactionPortV1 { continue; } if (JSON.stringify(prior) === JSON.stringify(code)) continue; - await this.client.mfaRecoveryCode.update({ - where: { id: code.id }, + const updated = await this.client.mfaRecoveryCode.updateMany({ + where: { id: code.id, revision: prior.revision }, data: { status: code.status, usedAt: code.usedAt ? new Date(code.usedAt) : null, revision: code.revision, }, }); + if (updated.count !== 1) throw new Error('IAM_MFA_REVISION_CONFLICT'); } } } diff --git a/services/api/test/features/iam/prisma-mfa-repository.test.ts b/services/api/test/features/iam/prisma-mfa-repository.test.ts index 9d9994f0..daa55d3b 100644 --- a/services/api/test/features/iam/prisma-mfa-repository.test.ts +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -55,6 +55,19 @@ function createDatabase(): { factors.set(where.id, updated); return updated; }, + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Partial; + }) => { + const current = factors.get(String(where['id'])); + if (!current || (where['revision'] !== undefined && current.revision !== where['revision'])) + return { count: 0 }; + factors.set(current.id, { ...current, ...data }); + return { count: 1 }; + }, }, mfaRecoveryCode: { findMany: async ({ where }: { readonly where: Readonly> }) => @@ -82,6 +95,19 @@ function createDatabase(): { recoveryCodes.set(where.id, updated); return updated; }, + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Partial; + }) => { + const current = recoveryCodes.get(String(where['id'])); + if (!current || (where['revision'] !== undefined && current.revision !== where['revision'])) + return { count: 0 }; + recoveryCodes.set(current.id, { ...current, ...data }); + return { count: 1 }; + }, }, $transaction: async (work: (transaction: MfaDatabaseClientV1) => Promise) => { const beforeFactors = new Map(factors); @@ -200,3 +226,32 @@ void test('[IAM-012, IAM-014] Prisma MFA persistence rejects a changed stale rev /IAM_MFA_REVISION_CONFLICT/u, ); }); + +void test('[IAM-012, IAM-016] Prisma MFA persistence rejects a redemption race with compare-and-set', async () => { + const { client } = createDatabase(); + const adapter = new PrismaMfaRepositoryAdapter(client); + const input = state(); + const factor = input.factors[0]; + const code = input.recoveryCodes[0]; + if (!factor || !code) throw new Error('fixture missing MFA state'); + await adapter.saveState(factor.userId, input); + const recoveryDelegate = client.mfaRecoveryCode as MfaDatabaseClientV1['mfaRecoveryCode'] & { + updateMany: MfaDatabaseClientV1['mfaRecoveryCode']['updateMany']; + }; + recoveryDelegate.updateMany = async () => ({ count: 0 }); + const racedAdapter = new PrismaMfaRepositoryAdapter(client); + await assert.rejects( + racedAdapter.saveState(factor.userId, { + factors: input.factors, + recoveryCodes: [ + { + ...code, + status: 'USED', + usedAt: '2026-01-01T00:01:00.000Z' as typeof code.createdAt, + revision: 2, + }, + ], + }), + /IAM_MFA_REVISION_CONFLICT/, + ); +}); From 6d26ec7a63ce4cf2157a68bee2233d44fe10dfd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:43:05 +0700 Subject: [PATCH 06/59] fix(iam): use constant-time recovery matching --- services/api/src/features/iam/iam.module.ts | 21 ++++++++++++------- .../api/test/features/iam/mfa.service.test.ts | 7 +++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 6bf6257e..fac825fc 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from 'node:crypto'; import { type DynamicModule, Module } from '@nestjs/common'; import { AuthenticationController } from './api/authentication.controller.js'; @@ -94,6 +95,17 @@ export interface IamModuleOptions { readonly requestTenantContext?: RequestTenantContextPortV1; } +/** Compare already-normalized recovery-code digests without data-dependent byte comparisons. */ +export function constantTimeRecoveryCodeMatchV1( + presentedDigest: string, + storedDigest: string, +): boolean { + const presented = Buffer.from(presentedDigest, 'utf8'); + const stored = Buffer.from(storedDigest, 'utf8'); + if (presented.length !== stored.length) return false; + return timingSafeEqual(presented, stored); +} + export function composeAuthenticationUseCase(options: IamModuleOptions): AuthenticationUseCaseV1 { if (options.authentication) return options.authentication; if (options.credentials && options.passwordCredentials && options.sessions) { @@ -136,14 +148,7 @@ export class IamModule { : new MfaService( mfaRepository, options.recoveryCodeMatcher ?? { - matches: (presentedDigest, storedDigest) => { - if (presentedDigest.length !== storedDigest.length) return false; - let difference = 0; - for (let index = 0; index < presentedDigest.length; index += 1) { - difference |= presentedDigest.charCodeAt(index) ^ storedDigest.charCodeAt(index); - } - return difference === 0; - }, + matches: constantTimeRecoveryCodeMatchV1, }, )); const iamRepository = diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts index 6bb92c05..97e5213d 100644 --- a/services/api/test/features/iam/mfa.service.test.ts +++ b/services/api/test/features/iam/mfa.service.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import { createRecoveryCodeV1 } from '@databreeze/domain/mfa/v1'; import { InMemoryMfaRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-mfa-repository.adapter.js'; +import { constantTimeRecoveryCodeMatchV1 } from '../../../src/features/iam/iam.module.js'; import { MfaService } from '../../../src/features/iam/application/mfa.service.js'; const userId = '00000000-0000-4000-8000-000000000001'; @@ -70,3 +71,9 @@ void test('[IAM-012] high-risk operations require a fresh step-up assertion', () true, ); }); + +void test('[IAM-015] default recovery-code matching compares normalized bytes safely', () => { + assert.equal(constantTimeRecoveryCodeMatchV1('digest-1', 'digest-1'), true); + assert.equal(constantTimeRecoveryCodeMatchV1('digest-1', 'digest-2'), false); + assert.equal(constantTimeRecoveryCodeMatchV1('digest-1', 'digest-10'), false); +}); From a62e515621cc4714dd29f49b5aaa3dcfeb0059b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:46:15 +0700 Subject: [PATCH 07/59] fix(iam): map session persistence failures to unavailable --- .../iam/api/authentication.controller.ts | 13 +++++- services/api/test/http-contract.test.ts | 41 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 594f5d44..3b94c7fc 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -133,7 +133,12 @@ export class AuthenticationController { ) { throw new SessionProblemError('SESSION_INVALID'); } - const result = await this.sessions.refresh(refreshToken, input.clientPlatform); + let result: Awaited>; + try { + result = await this.sessions.refresh(refreshToken, input.clientPlatform); + } catch { + throw new SessionProblemError('SESSION_UNAVAILABLE'); + } if (!result.accepted) throw new SessionProblemError('SESSION_INVALID'); if (input.clientPlatform === 'web') { const csrfToken = randomBytes(32).toString('base64url'); @@ -167,7 +172,11 @@ export class AuthenticationController { @Res({ passthrough: true }) reply: FastifyReply, ): Promise { if (this.sessions === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); - await this.sessions.revoke(input.sessionId); + try { + await this.sessions.revoke(input.sessionId); + } catch { + throw new SessionProblemError('SESSION_UNAVAILABLE'); + } if (input.clientPlatform === 'web') { reply.header('Set-Cookie', [ clearCookieV1(REFRESH_COOKIE_NAME_V1, { httpOnly: true }), diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index c2e39d03..b04ed4b4 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -439,6 +439,25 @@ void test('refresh rotates Web cookies without returning the refresh token and p assert.doesNotMatch(response.body, /REUSE_DETECTED/); }, ); + + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('database unavailable')), + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(undefined), + }, + }, + async (app) => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/refresh', + payload: { clientPlatform: 'desktop', refreshToken: 'database-refresh-token' }, + }); + assertProblem(response, 503, 'SESSION_UNAVAILABLE'); + }, + ); }); void test('sign-out revokes idempotently and clears browser credentials', async () => { @@ -495,6 +514,28 @@ void test('sign-out revokes idempotently and clears browser credentials', async ]); }, ); + + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: () => Promise.reject(new Error('database unavailable')), + findPrincipal: () => Promise.resolve(undefined), + }, + }, + async (app) => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/sign-out', + payload: { + clientPlatform: 'android', + sessionId: '00000000-0000-4000-8000-000000000011', + }, + }); + assertProblem(response, 503, 'SESSION_UNAVAILABLE'); + }, + ); }); void test('protected artifact reads derive tenant scope from an authenticated access token', async () => { From 8ea5ec9099bf3e194a7e7637d77853d6b55cd5fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:48:07 +0700 Subject: [PATCH 08/59] fix(http): require production CSRF origins --- .../api/src/platform/http/request-context.ts | 22 +++++++++++++++++++ .../platform/http/csrf-protection.test.ts | 20 +++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/services/api/src/platform/http/request-context.ts b/services/api/src/platform/http/request-context.ts index 3b9068d2..20f5f512 100644 --- a/services/api/src/platform/http/request-context.ts +++ b/services/api/src/platform/http/request-context.ts @@ -25,6 +25,27 @@ export interface RequestContextOptions { readonly csrf?: Partial; } +/** Production must explicitly declare browser origins; development defaults are not deployable. */ +export function validateRequestContextOptionsV1( + options: RequestContextOptions = {}, + environment = process.env['NODE_ENV'], +): void { + if (environment !== 'production') return; + const origins = options.csrf?.allowedOrigins; + if (!origins || origins.length === 0) throw new Error('CSRF_ALLOWED_ORIGINS_REQUIRED'); + if ( + origins.some((origin) => { + try { + const parsed = new URL(origin); + return parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== ''; + } catch { + return true; + } + }) + ) + throw new Error('CSRF_ALLOWED_ORIGINS_INVALID'); +} + export function parseCorrelationHeader( values: readonly string[], requestId: string, @@ -48,6 +69,7 @@ export function installRequestContext( fastify: FastifyInstance, options: RequestContextOptions = {}, ): void { + validateRequestContextOptionsV1(options); fastify.addHook('onRequest', (request, reply, done) => { const requestId = randomUUID(); const context: RequestContext = { correlationId: requestId, requestId }; diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts index ebf3cd85..37adeaea 100644 --- a/services/api/test/platform/http/csrf-protection.test.ts +++ b/services/api/test/platform/http/csrf-protection.test.ts @@ -1,12 +1,32 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { validateRequestContextOptionsV1 } from '../../../src/platform/http/request-context.js'; + import { evaluateCsrfRequestV1 } from '../../../src/platform/http/csrf-protection.js'; const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; const allowedOrigins = ['https://app.databreeze.example']; +void test('production request context requires explicit HTTPS browser origins', () => { + assert.throws( + () => validateRequestContextOptionsV1({}, 'production'), + /CSRF_ALLOWED_ORIGINS_REQUIRED/, + ); + assert.throws( + () => + validateRequestContextOptionsV1( + { csrf: { allowedOrigins: ['http://localhost:3000'] } }, + 'production', + ), + /CSRF_ALLOWED_ORIGINS_INVALID/, + ); + assert.doesNotThrow(() => + validateRequestContextOptionsV1({ csrf: { allowedOrigins } }, 'production'), + ); +}); + void test('allows safe methods and non-cookie clients without a CSRF token', () => { assert.deepEqual(evaluateCsrfRequestV1({ method: 'GET', headers: {} }, { allowedOrigins }), { accepted: true, From 8874b36d9719c281fcc13890cd62cef5dcdd0b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:50:09 +0700 Subject: [PATCH 09/59] perf(aud): scope chain reads to the target ledger --- .../in-memory-audit-repository.adapter.ts | 17 +++++++++++ .../prisma-audit-repository.adapter.ts | 26 +++++++++++++++++ .../aud/application/audit-ledger.service.ts | 4 +-- .../aud/application/audit-repository.port.ts | 5 ++++ .../features/aud/audit-ledger.service.test.ts | 28 +++++++++++++++++++ 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts index 2c02f27b..b3c1a177 100644 --- a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts @@ -76,6 +76,22 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { .map(cloneEvent); } + async listEventsForScope( + context: IamTenantContextV1, + scope: TenantScopeV1, + ): Promise { + await Promise.resolve(); + if (!scopeAllowsMutation(context, scope)) throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); + return [...this.events.values()] + .filter( + (event) => + tenantScopeContainsV1(event.tenantScope, scope) && + tenantScopeContainsV1(scope, event.tenantScope), + ) + .sort((left, right) => left.sequence - right.sequence) + .map(cloneEvent); + } + async saveSeal(context: IamTenantContextV1, seal: AuditSealV1): Promise { await Promise.resolve(); if (!scopeAllowsMutation(context, seal.tenantScope)) @@ -116,6 +132,7 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { return await work({ appendEvent: this.appendEvent.bind(this), listEvents: this.listEvents.bind(this), + listEventsForScope: this.listEventsForScope.bind(this), saveSeal: this.saveSeal.bind(this), listSeals: this.listSeals.bind(this), }); diff --git a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts index 202c5b25..a08d0b8f 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -317,6 +317,22 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { return events; } + public async listEventsForScope( + context: IamTenantContextV1, + scope: TenantScopeV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, scope)) + throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); + const rows = await this.client.auditEventRecord.findMany({ + where: { scopeKey: scopeKey(scope) }, + orderBy: { sequence: 'asc' }, + }); + const events = rows.map(persistedEvent); + const verified = verifyAuditChainV1(events, this.digestPort); + if (!verified.accepted) throw new Error('AUD_CHAIN_INVALID'); + return events; + } + public async saveSeal(context: IamTenantContextV1, seal: AuditSealV1): Promise { if (!tenantScopeContainsV1(context.tenantScope, seal.tenantScope)) throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); @@ -372,6 +388,16 @@ export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 { return new PrismaAuditTransactionAdapter(this.client, this.digestPort).listEvents(context); } + public listEventsForScope( + context: IamTenantContextV1, + scope: TenantScopeV1, + ): Promise { + return new PrismaAuditTransactionAdapter(this.client, this.digestPort).listEventsForScope( + context, + scope, + ); + } + public saveSeal(context: IamTenantContextV1, seal: AuditSealV1): Promise { return new PrismaAuditTransactionAdapter(this.client, this.digestPort).saveSeal(context, seal); } diff --git a/services/api/src/features/aud/application/audit-ledger.service.ts b/services/api/src/features/aud/application/audit-ledger.service.ts index ab74a29c..1db6be63 100644 --- a/services/api/src/features/aud/application/audit-ledger.service.ts +++ b/services/api/src/features/aud/application/audit-ledger.service.ts @@ -33,7 +33,7 @@ export class AuditLedgerService { input: AuditLedgerInputV1, ): Promise> { return this.repository.withTransaction(context, async (transaction) => { - const existing = await transaction.listEvents(context); + const existing = await transaction.listEventsForScope(context, context.tenantScope); const appended = appendAuditEventV1( { events: existing }, { @@ -62,7 +62,7 @@ export class AuditLedgerService { sealedAt: unknown, ): Promise> { return this.repository.withTransaction(context, async (transaction) => { - const events = await transaction.listEvents(context); + const events = await transaction.listEventsForScope(context, context.tenantScope); const created = createAuditSealV1(events, context.tenantScope, sealedAt, this.digestPort); if (!created.accepted) return created; await transaction.saveSeal(context, created.value); diff --git a/services/api/src/features/aud/application/audit-repository.port.ts b/services/api/src/features/aud/application/audit-repository.port.ts index 3554d55c..b513ed64 100644 --- a/services/api/src/features/aud/application/audit-repository.port.ts +++ b/services/api/src/features/aud/application/audit-repository.port.ts @@ -1,4 +1,5 @@ import type { AuditEventV1, AuditSealV1 } from '@databreeze/domain/audit/v1'; +import type { TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; @@ -7,6 +8,10 @@ export const AUDIT_REPOSITORY_PORT = Symbol('AUDIT_REPOSITORY_PORT'); export interface AuditTransactionPortV1 { appendEvent(context: IamTenantContextV1, event: AuditEventV1): Promise; listEvents(context: IamTenantContextV1): Promise; + listEventsForScope( + context: IamTenantContextV1, + scope: TenantScopeV1, + ): Promise; saveSeal(context: IamTenantContextV1, seal: AuditSealV1): Promise; listSeals(context: IamTenantContextV1): Promise; } diff --git a/services/api/test/features/aud/audit-ledger.service.test.ts b/services/api/test/features/aud/audit-ledger.service.test.ts index f48329f8..95c989ff 100644 --- a/services/api/test/features/aud/audit-ledger.service.test.ts +++ b/services/api/test/features/aud/audit-ledger.service.test.ts @@ -39,6 +39,12 @@ function input(eventId: string) { void test('[AUD-001, AUD-003, AUD-005, IAM-009] service binds audit identity to the authorized context', async () => { const repository = new InMemoryAuditRepositoryAdapter(); + let broadReads = 0; + const broadList = repository.listEvents.bind(repository); + repository.listEvents = async (...args) => { + broadReads += 1; + return broadList(...args); + }; const service = new AuditLedgerService(repository, { digest: (value) => createHash('sha256').update(value).digest('base64url'), }); @@ -61,6 +67,28 @@ void test('[AUD-001, AUD-003, AUD-005, IAM-009] service binds audit identity to ); assert.deepEqual(repeated, first); assert.equal((await repository.listEvents(context('read'))).length, 1); + assert.equal(broadReads, 1); +}); + +void test('[AUD-001, AUD-015] append and seal use the exact scope chain instead of loading visible tenant descendants', async () => { + const repository = new InMemoryAuditRepositoryAdapter(); + let scopedReads = 0; + const scopedList = repository.listEventsForScope.bind(repository); + repository.listEventsForScope = async (...args) => { + scopedReads += 1; + return scopedList(...args); + }; + const service = new AuditLedgerService(repository, { + digest: (value) => createHash('sha256').update(value).digest('base64url'), + }); + const appended = await service.append( + context('scoped-1'), + input('00000000-0000-4000-8000-000000000024'), + ); + assert.equal(appended.accepted, true); + const sealed = await service.seal(context('scoped-seal'), '2026-01-01T00:05:00.000Z'); + assert.equal(sealed.accepted, true); + assert.equal(scopedReads, 2); }); void test('[AUD-003, AUD-005] invalid actor and unsafe summary fail before persistence', async () => { From 463abbb393eaa35be68dce47482a284ce19313ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 05:52:44 +0700 Subject: [PATCH 10/59] fix(aud): compare immutable records by owned fields --- .../in-memory-audit-repository.adapter.ts | 7 +-- .../prisma-audit-repository.adapter.ts | 6 +- .../aud/application/audit-equality.ts | 56 +++++++++++++++++++ .../features/aud/audit-repository.test.ts | 9 ++- 4 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 services/api/src/features/aud/application/audit-equality.ts diff --git a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts index b3c1a177..ed1584fa 100644 --- a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts @@ -9,6 +9,7 @@ import type { AuditRepositoryPortV1, AuditTransactionPortV1, } from '../application/audit-repository.port.js'; +import { sameAuditEventV1, sameAuditSealV1 } from '../application/audit-equality.js'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; function visibleInScope(context: TenantScopeV1, record: TenantScopeV1): boolean { @@ -44,8 +45,7 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); const existing = this.events.get(event.eventId); if (existing) { - if (JSON.stringify(existing) !== JSON.stringify(event)) - throw new Error('AUD_IMMUTABLE_EVENT'); + if (!sameAuditEventV1(existing, event)) throw new Error('AUD_IMMUTABLE_EVENT'); return cloneEvent(existing); } const scopedEvents = [...this.events.values()] @@ -103,8 +103,7 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { tenantScopeContainsV1(item.tenantScope, seal.tenantScope) && tenantScopeContainsV1(seal.tenantScope, item.tenantScope), ); - if (existing && JSON.stringify(existing) !== JSON.stringify(seal)) - throw new Error('AUD_IMMUTABLE_SEAL'); + if (existing && !sameAuditSealV1(existing, seal)) throw new Error('AUD_IMMUTABLE_SEAL'); this.seals.set(seal.rootDigest, cloneSeal(seal)); } diff --git a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts index a08d0b8f..5cec0777 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -21,6 +21,7 @@ import type { AuditRepositoryPortV1, AuditTransactionPortV1, } from '../application/audit-repository.port.js'; +import { sameAuditEventV1, sameAuditSealV1 } from '../application/audit-equality.js'; export interface AuditEventDatabaseRowV1 { readonly id: string; @@ -284,7 +285,7 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { }); if (existing !== null) { const current = persistedEvent(existing); - if (JSON.stringify(current) !== JSON.stringify(event)) throw new Error('AUD_IMMUTABLE_EVENT'); + if (!sameAuditEventV1(current, event)) throw new Error('AUD_IMMUTABLE_EVENT'); return current; } const siblings = await this.client.auditEventRecord.findMany({ @@ -344,8 +345,7 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { }, }); if (existing !== null) { - if (JSON.stringify(persistedSeal(existing)) !== JSON.stringify(seal)) - throw new Error('AUD_IMMUTABLE_SEAL'); + if (!sameAuditSealV1(persistedSeal(existing), seal)) throw new Error('AUD_IMMUTABLE_SEAL'); return; } await this.client.auditSealRecord.create({ data: sealCreateData(seal) }); diff --git a/services/api/src/features/aud/application/audit-equality.ts b/services/api/src/features/aud/application/audit-equality.ts new file mode 100644 index 00000000..a2429f6a --- /dev/null +++ b/services/api/src/features/aud/application/audit-equality.ts @@ -0,0 +1,56 @@ +import type { AuditEventV1, AuditSealV1, AuditSummaryV1 } from '@databreeze/domain/audit/v1'; +import type { TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; + +function sameScope(left: TenantScopeV1, right: TenantScopeV1): boolean { + return ( + left.scopeType === right.scopeType && + left.organizationId === right.organizationId && + ('workspaceId' in left ? left.workspaceId : undefined) === + ('workspaceId' in right ? right.workspaceId : undefined) && + ('projectId' in left ? left.projectId : undefined) === + ('projectId' in right ? right.projectId : undefined) + ); +} + +function sameSummary(left: AuditSummaryV1, right: AuditSummaryV1): boolean { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every((key, index) => { + const rightKey = rightKeys[index]; + return rightKey === key && left[key] === right[key]; + }); +} + +export function sameAuditEventV1(left: AuditEventV1, right: AuditEventV1): boolean { + return ( + left.schemaVersion === right.schemaVersion && + left.eventId === right.eventId && + left.action === right.action && + sameScope(left.tenantScope, right.tenantScope) && + left.actor.actorType === right.actor.actorType && + left.actor.actorId === right.actor.actorId && + left.entityType === right.entityType && + left.entityId === right.entityId && + left.entityRevision === right.entityRevision && + left.sequence === right.sequence && + left.occurredAt === right.occurredAt && + left.correlationId === right.correlationId && + left.idempotencyKey === right.idempotencyKey && + sameSummary(left.summary, right.summary) && + left.previousDigest === right.previousDigest && + left.digest === right.digest + ); +} + +export function sameAuditSealV1(left: AuditSealV1, right: AuditSealV1): boolean { + return ( + left.schemaVersion === right.schemaVersion && + sameScope(left.tenantScope, right.tenantScope) && + left.firstSequence === right.firstSequence && + left.lastSequence === right.lastSequence && + left.eventCount === right.eventCount && + left.rootDigest === right.rootDigest && + left.sealedAt === right.sealedAt + ); +} diff --git a/services/api/test/features/aud/audit-repository.test.ts b/services/api/test/features/aud/audit-repository.test.ts index 3faed9ee..02d87cce 100644 --- a/services/api/test/features/aud/audit-repository.test.ts +++ b/services/api/test/features/aud/audit-repository.test.ts @@ -48,7 +48,7 @@ function event(eventId: string, idempotencyKey: string, workspace = workspaceId) occurredAt: '2026-01-01T00:00:00.000Z', correlationId, idempotencyKey, - summary: { outcome: 'accepted' }, + summary: { status: 'accepted', outcome: 'accepted' }, }, digestPort, ); @@ -62,6 +62,13 @@ void test('[AUD-001, AUD-004, AUD-006, IAM-009] audit events are append-only and const stored = event('00000000-0000-4000-8000-000000000021', 'invite-1'); await repository.appendEvent(context(workspaceId), stored); assert.deepEqual(await repository.listEvents(context(workspaceId)), [stored]); + assert.deepEqual( + await repository.appendEvent(context(workspaceId), { + ...stored, + summary: { outcome: 'accepted', status: 'accepted' }, + }), + stored, + ); assert.deepEqual(await repository.listEvents(context(siblingWorkspaceId)), []); assert.deepEqual(await repository.appendEvent(context(workspaceId), stored), stored); await assert.rejects( From eb020778aa7ed4794217ef940cc95ff7e15e80f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:42:47 +0700 Subject: [PATCH 11/59] fix(bua): compare immutable records by owned fields --- ...n-memory-entitlement-repository.adapter.ts | 23 ++-- .../prisma-entitlement-repository.adapter.ts | 23 ++-- .../bua/application/entitlement-equality.ts | 116 ++++++++++++++++++ .../bua/entitlement-repository.test.ts | 12 ++ 4 files changed, 152 insertions(+), 22 deletions(-) create mode 100644 services/api/src/features/bua/application/entitlement-equality.ts diff --git a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts index f52acfca..402dbdf2 100644 --- a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts @@ -13,6 +13,13 @@ import type { EntitlementTransactionPortV1, } from '../application/entitlement-repository.port.js'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import { + sameEntitlementPlanV1, + sameEntitlementSnapshotV1, + sameUsageEntryV1, + sameUsageReservationExceptStatusV1, + sameUsageReservationV1, +} from '../application/entitlement-equality.js'; function visibleInScope(context: TenantScopeV1, record: TenantScopeV1): boolean { return tenantScopeContainsV1(context, record) || tenantScopeContainsV1(record, context); @@ -67,11 +74,7 @@ function cloneState(state: UsageLedgerStateV1): UsageLedgerStateV1 { } function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReservationV1): boolean { - return ( - left.reservationId === right.reservationId && - JSON.stringify({ ...left, status: undefined, revision: undefined }) === - JSON.stringify({ ...right, status: undefined, revision: undefined }) - ); + return sameUsageReservationExceptStatusV1(left, right); } /** In-memory adapter with append-only usage and immutable plan/snapshot semantics. */ @@ -85,8 +88,7 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito async savePlan(plan: EntitlementPlanV1): Promise { await Promise.resolve(); const existing = this.plans.get(plan.planCode); - if (existing && JSON.stringify(existing) !== JSON.stringify(plan)) - throw new Error('BUA_IMMUTABLE_PLAN'); + if (existing && !sameEntitlementPlanV1(existing, plan)) throw new Error('BUA_IMMUTABLE_PLAN'); this.plans.set(plan.planCode, clonePlan(plan)); } @@ -101,7 +103,7 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito if (!scopeAllowsMutation(context, snapshotScope(snapshot))) throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); const existing = this.snapshots.get(snapshot.snapshotId); - if (existing && JSON.stringify(existing) !== JSON.stringify(snapshot)) + if (existing && !sameEntitlementSnapshotV1(existing, snapshot)) throw new Error('BUA_IMMUTABLE_SNAPSHOT'); this.snapshots.set(snapshot.snapshotId, cloneSnapshot(snapshot)); } @@ -134,8 +136,7 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito for (const entry of state.entries) { const existing = this.entries.get(entry.entryId); if (existing) { - if (JSON.stringify(existing) !== JSON.stringify(entry)) - throw new Error('BUA_IMMUTABLE_USAGE_ENTRY'); + if (!sameUsageEntryV1(existing, entry)) throw new Error('BUA_IMMUTABLE_USAGE_ENTRY'); continue; } if (!scopeAllowsMutation(context, entry.tenantScope)) @@ -160,7 +161,7 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito this.reservations.set(reservation.reservationId, cloneReservation(reservation)); continue; } - if (JSON.stringify(existing) === JSON.stringify(reservation)) continue; + if (sameUsageReservationV1(existing, reservation)) continue; if ( existing.revision + 1 !== reservation.revision || !sameReservationExceptStatus(existing, reservation) diff --git a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts index b73bc1b5..f335fa66 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -22,6 +22,13 @@ import type { EntitlementRepositoryPortV1, EntitlementTransactionPortV1, } from '../application/entitlement-repository.port.js'; +import { + sameEntitlementPlanV1, + sameEntitlementSnapshotV1, + sameUsageEntryV1, + sameUsageReservationExceptStatusV1, + sameUsageReservationV1, +} from '../application/entitlement-equality.js'; const planCodes = new Set(['free', 'development', 'admin_granted']); const statuses = new Set(['ACTIVE', 'SUSPENDED', 'EXPIRED']); @@ -427,13 +434,7 @@ function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { } function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReservationV1): boolean { - return ( - left.reservationId === right.reservationId && - left.metric === right.metric && - left.reservedUnits === right.reservedUnits && - JSON.stringify(left.tenantScope) === JSON.stringify(right.tenantScope) && - left.createdAt === right.createdAt - ); + return sameUsageReservationExceptStatusV1(left, right); } class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV1 { @@ -444,7 +445,7 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV where: { planCode: plan.planCode }, }); if (existing !== null) { - if (JSON.stringify(persistedPlan(existing)) !== JSON.stringify(plan)) + if (!sameEntitlementPlanV1(persistedPlan(existing), plan)) throw new Error('BUA_IMMUTABLE_PLAN'); return; } @@ -475,7 +476,7 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV where: { id: snapshot.snapshotId }, }); if (existing !== null) { - if (JSON.stringify(persistedSnapshot(existing)) !== JSON.stringify(snapshot)) + if (!sameEntitlementSnapshotV1(persistedSnapshot(existing), snapshot)) throw new Error('BUA_IMMUTABLE_SNAPSHOT'); return; } @@ -541,7 +542,7 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV where: { id: entry.entryId }, }); if (existing !== null) { - if (JSON.stringify(persistedEntry(existing)) !== JSON.stringify(entry)) + if (!sameUsageEntryV1(persistedEntry(existing), entry)) throw new Error('BUA_IMMUTABLE_USAGE_ENTRY'); continue; } @@ -560,7 +561,7 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV continue; } const current = persistedReservation(existing); - if (JSON.stringify(current) === JSON.stringify(reservation)) continue; + if (sameUsageReservationV1(current, reservation)) continue; if ( !sameReservationExceptStatus(current, reservation) || reservation.revision !== current.revision + 1 diff --git a/services/api/src/features/bua/application/entitlement-equality.ts b/services/api/src/features/bua/application/entitlement-equality.ts new file mode 100644 index 00000000..d77e472e --- /dev/null +++ b/services/api/src/features/bua/application/entitlement-equality.ts @@ -0,0 +1,116 @@ +import type { + EntitlementPlanV1, + EntitlementQuotaV1, + EntitlementSnapshotV1, + UsageLedgerEntryV1, + UsageReservationV1, +} from '@databreeze/domain/entitlements/v1'; +import type { TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; + +function sameScope(left: TenantScopeV1, right: TenantScopeV1): boolean { + return ( + left.scopeType === right.scopeType && + left.organizationId === right.organizationId && + ('workspaceId' in left ? left.workspaceId : undefined) === + ('workspaceId' in right ? right.workspaceId : undefined) && + ('projectId' in left ? left.projectId : undefined) === + ('projectId' in right ? right.projectId : undefined) + ); +} + +function sameQuotas( + left: readonly EntitlementQuotaV1[], + right: readonly EntitlementQuotaV1[], +): boolean { + if (left.length !== right.length) return false; + const normalize = (quotas: readonly EntitlementQuotaV1[]) => + [...quotas].sort((a, b) => a.metric.localeCompare(b.metric)); + const normalizedLeft = normalize(left); + const normalizedRight = normalize(right); + return normalizedLeft.every( + (quota, index) => + quota.metric === normalizedRight[index]?.metric && + quota.limit === normalizedRight[index]?.limit, + ); +} + +function sameFeatures(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) return false; + const normalizedLeft = [...left].sort(); + const normalizedRight = [...right].sort(); + return normalizedLeft.every((feature, index) => feature === normalizedRight[index]); +} + +export function sameEntitlementPlanV1(left: EntitlementPlanV1, right: EntitlementPlanV1): boolean { + return ( + left.schemaVersion === right.schemaVersion && + left.planCode === right.planCode && + left.displayNameKey === right.displayNameKey && + left.providerIndependent === right.providerIndependent && + sameFeatures(left.features, right.features) && + sameQuotas(left.quotas, right.quotas) + ); +} + +export function sameEntitlementSnapshotV1( + left: EntitlementSnapshotV1, + right: EntitlementSnapshotV1, +): boolean { + return ( + left.schemaVersion === right.schemaVersion && + left.snapshotId === right.snapshotId && + left.organizationId === right.organizationId && + left.workspaceId === right.workspaceId && + left.planCode === right.planCode && + left.status === right.status && + left.revision === right.revision && + left.securityEpoch === right.securityEpoch && + left.effectiveAt === right.effectiveAt && + left.expiresAt === right.expiresAt && + sameFeatures(left.features, right.features) && + sameQuotas(left.quotas, right.quotas) + ); +} + +export function sameUsageEntryV1(left: UsageLedgerEntryV1, right: UsageLedgerEntryV1): boolean { + return ( + left.schemaVersion === right.schemaVersion && + left.entryId === right.entryId && + sameScope(left.tenantScope, right.tenantScope) && + left.metric === right.metric && + left.bucket === right.bucket && + left.deltaUnits === right.deltaUnits && + left.sequence === right.sequence && + left.reservationId === right.reservationId && + left.idempotencyKey === right.idempotencyKey && + left.occurredAt === right.occurredAt + ); +} + +export function sameUsageReservationV1( + left: UsageReservationV1, + right: UsageReservationV1, +): boolean { + return ( + left.reservationId === right.reservationId && + sameScope(left.tenantScope, right.tenantScope) && + left.metric === right.metric && + left.reservedUnits === right.reservedUnits && + left.status === right.status && + left.createdAt === right.createdAt && + left.revision === right.revision + ); +} + +export function sameUsageReservationExceptStatusV1( + left: UsageReservationV1, + right: UsageReservationV1, +): boolean { + return ( + left.reservationId === right.reservationId && + sameScope(left.tenantScope, right.tenantScope) && + left.metric === right.metric && + left.reservedUnits === right.reservedUnits && + left.createdAt === right.createdAt + ); +} diff --git a/services/api/test/features/bua/entitlement-repository.test.ts b/services/api/test/features/bua/entitlement-repository.test.ts index 2daf9bdf..2878f559 100644 --- a/services/api/test/features/bua/entitlement-repository.test.ts +++ b/services/api/test/features/bua/entitlement-repository.test.ts @@ -86,7 +86,19 @@ void test('[BUA-001, BUA-002, BUA-003] plans and snapshots are immutable and sco const repository = new InMemoryEntitlementRepositoryAdapter(); await repository.savePlan(plan()); assert.deepEqual(await repository.findPlan('development'), plan()); + const reorderedPlan = { + ...plan(), + features: ['job.execute', 'artifact.register'], + }; + await repository.savePlan({ + ...reorderedPlan, + }); + assert.deepEqual(await repository.findPlan('development'), reorderedPlan); await repository.saveSnapshot(context(workspaceId), snapshot()); + await repository.saveSnapshot(context(workspaceId), { + ...snapshot(), + features: ['job.execute', 'artifact.register'], + }); assert.equal( ( await repository.findSnapshot( From ebe73cff225fb696e58f65f172fc160e55f338ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:44:35 +0700 Subject: [PATCH 12/59] perf(aud): bound append lookups to latest and replay --- .../prisma-audit-repository.adapter.ts | 24 ++++++++++++------- .../aud/prisma-audit-repository.test.ts | 22 +++++++++++++---- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts index 5cec0777..1eb63f71 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -76,6 +76,10 @@ interface AuditEventDelegateV1 { findUnique(input: { readonly where: { readonly id: string }; }): Promise; + findFirst(input: { + readonly where: Readonly>; + readonly orderBy?: { readonly sequence: 'asc' | 'desc' }; + }): Promise; findMany(input: { readonly where: Readonly>; readonly orderBy: { readonly sequence: 'asc' | 'desc' }; @@ -288,15 +292,19 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { if (!sameAuditEventV1(current, event)) throw new Error('AUD_IMMUTABLE_EVENT'); return current; } - const siblings = await this.client.auditEventRecord.findMany({ - where: { scopeKey: scopeKey(event.tenantScope) }, - orderBy: { sequence: 'desc' }, - }); - const duplicate = siblings.find((row) => row.idempotencyKey === event.idempotencyKey); - if (duplicate !== undefined) throw new Error('AUD_IDEMPOTENCY_CONFLICT'); - const latest = siblings[0]; + const eventScopeKey = scopeKey(event.tenantScope); + const [duplicate, latest] = await Promise.all([ + this.client.auditEventRecord.findFirst({ + where: { scopeKey: eventScopeKey, idempotencyKey: event.idempotencyKey }, + }), + this.client.auditEventRecord.findFirst({ + where: { scopeKey: eventScopeKey }, + orderBy: { sequence: 'desc' }, + }), + ]); + if (duplicate !== null) throw new Error('AUD_IDEMPOTENCY_CONFLICT'); if ( - latest !== undefined && + latest !== null && (event.sequence !== latest.sequence + 1 || event.previousDigest !== latest.digest) ) { throw new Error('AUD_SEQUENCE_CONFLICT'); diff --git a/services/api/test/features/aud/prisma-audit-repository.test.ts b/services/api/test/features/aud/prisma-audit-repository.test.ts index dd5e3d83..40d3006e 100644 --- a/services/api/test/features/aud/prisma-audit-repository.test.ts +++ b/services/api/test/features/aud/prisma-audit-repository.test.ts @@ -39,11 +39,25 @@ function delegate>(rows: TRow[]) { findUnique({ where }: { readonly where: { readonly id: string } }) { return Promise.resolve(rows.find((row) => row['id'] === where.id) ?? null); }, - findFirst({ where }: { readonly where: Readonly> }) { - return Promise.resolve( - rows.find((row) => Object.entries(where).every(([key, value]) => row[key] === value)) ?? - null, + findFirst({ + where, + orderBy, + }: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }) { + const matching = rows.filter((row) => + Object.entries(where).every(([key, value]) => row[key] === value), ); + const [field, direction] = Object.entries(orderBy ?? {})[0] ?? []; + if (field) { + matching.sort((left, right) => { + if (left[field] === right[field]) return 0; + const comparison = left[field]! < right[field]! ? -1 : 1; + return direction === 'desc' ? -comparison : comparison; + }); + } + return Promise.resolve(matching[0] ?? null); }, findMany({ where, From bb8a11f2c3ca0ea9b0544ba776f434189b9185cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:45:30 +0700 Subject: [PATCH 13/59] perf(bua): scope usage reads to tenant ancestry --- .../prisma-entitlement-repository.adapter.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts index f335fa66..8d414e88 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -433,6 +433,14 @@ function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); } +function inheritedUsageScopeKeys(scope: TenantScopeV1): readonly string[] | undefined { + if (scope.scopeType === 'organization') return undefined; + return Object.freeze([ + `organization:${scope.organizationId}`, + `workspace:${scope.organizationId}:${scope.workspaceId}`, + ]); +} + function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReservationV1): boolean { return sameUsageReservationExceptStatusV1(left, right); } @@ -503,16 +511,31 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV } public async listUsageState(context: IamTenantContextV1): Promise { - const [entryRows, reservationRows] = await Promise.all([ + const scopeKeys = inheritedUsageScopeKeys(context.tenantScope); + const entryQueries = (scopeKeys ?? [undefined]).map((key) => this.client.usageLedgerEntryRecord.findMany({ - where: { organizationId: context.tenantScope.organizationId }, + where: + key === undefined + ? { organizationId: context.tenantScope.organizationId } + : { scopeKey: key }, orderBy: { sequence: 'asc' }, }), + ); + const reservationQueries = (scopeKeys ?? [undefined]).map((key) => this.client.usageReservationRecord.findMany({ - where: { organizationId: context.tenantScope.organizationId }, + where: + key === undefined + ? { organizationId: context.tenantScope.organizationId } + : { scopeKey: key }, orderBy: { createdAt: 'asc' }, }), + ); + const [entryGroups, reservationGroups] = await Promise.all([ + Promise.all(entryQueries), + Promise.all(reservationQueries), ]); + const entryRows = entryGroups.flat(); + const reservationRows = reservationGroups.flat(); return Object.freeze({ entries: Object.freeze( entryRows From 50e325d0118da8fbdc615afc158a4b1d2f6ec660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:46:08 +0700 Subject: [PATCH 14/59] fix(bua): enforce terminal reservation transitions --- .../in-memory-entitlement-repository.adapter.ts | 10 +++++++++- .../adapter/prisma-entitlement-repository.adapter.ts | 10 +++++++++- .../test/features/bua/entitlement-repository.test.ts | 9 +++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts index 402dbdf2..d5364b93 100644 --- a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts @@ -77,6 +77,13 @@ function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReser return sameUsageReservationExceptStatusV1(left, right); } +function validReservationTransition( + current: UsageReservationV1, + next: UsageReservationV1, +): boolean { + return current.status === 'ACTIVE' && (next.status === 'FINALIZED' || next.status === 'RELEASED'); +} + /** In-memory adapter with append-only usage and immutable plan/snapshot semantics. */ export class InMemoryEntitlementRepositoryAdapter implements EntitlementRepositoryPortV1 { private plans = new Map(); @@ -164,7 +171,8 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito if (sameUsageReservationV1(existing, reservation)) continue; if ( existing.revision + 1 !== reservation.revision || - !sameReservationExceptStatus(existing, reservation) + !sameReservationExceptStatus(existing, reservation) || + !validReservationTransition(existing, reservation) ) throw new Error('BUA_RESERVATION_CONFLICT'); this.reservations.set(reservation.reservationId, cloneReservation(reservation)); diff --git a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts index 8d414e88..b61f13f3 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -445,6 +445,13 @@ function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReser return sameUsageReservationExceptStatusV1(left, right); } +function validReservationTransition( + current: UsageReservationV1, + next: UsageReservationV1, +): boolean { + return current.status === 'ACTIVE' && (next.status === 'FINALIZED' || next.status === 'RELEASED'); +} + class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV1 { public constructor(private readonly client: EntitlementDatabaseClientV1) {} @@ -587,7 +594,8 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV if (sameUsageReservationV1(current, reservation)) continue; if ( !sameReservationExceptStatus(current, reservation) || - reservation.revision !== current.revision + 1 + reservation.revision !== current.revision + 1 || + !validReservationTransition(current, reservation) ) throw new Error('BUA_RESERVATION_CONFLICT'); if (!this.client.usageReservationRecord.updateMany) throw new Error('BUA_UPDATE_UNAVAILABLE'); diff --git a/services/api/test/features/bua/entitlement-repository.test.ts b/services/api/test/features/bua/entitlement-repository.test.ts index 2878f559..451fc208 100644 --- a/services/api/test/features/bua/entitlement-repository.test.ts +++ b/services/api/test/features/bua/entitlement-repository.test.ts @@ -145,6 +145,15 @@ void test('[BUA-008, BUA-009, BUA-010, BUA-011] usage state persists append-only if (!reserved.accepted) return; await repository.persistUsageState(context(workspaceId), reserved.value.state); assert.equal((await repository.listUsageState(context(workspaceId))).entries.length, 1); + const activeReservation = reserved.value.state.reservations[0]; + if (!activeReservation) throw new Error('fixture reservation missing'); + await assert.rejects( + repository.persistUsageState(context(workspaceId), { + entries: reserved.value.state.entries, + reservations: [{ ...activeReservation, revision: 2 }], + }), + /BUA_RESERVATION_CONFLICT/, + ); await assert.rejects( repository.persistUsageState(context(workspaceId), { ...reserved.value.state, From 2328dd410bc55171b8f9aca5a0c53575cc3240ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:47:55 +0700 Subject: [PATCH 15/59] fix(bua): return entitlement problems with HTTP status --- services/api/openapi/v1.json | 48 +++++++++++-------- .../bua/api/entitlement.controller.ts | 44 ++++++++++------- .../application/entitlement-problem.error.ts | 10 ++++ .../platform/http/problem-details.filter.ts | 20 ++++++++ services/api/test/http-contract.test.ts | 12 +---- 5 files changed, 88 insertions(+), 46 deletions(-) create mode 100644 services/api/src/features/bua/application/entitlement-problem.error.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 87e58e83..bedee5ec 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -6891,8 +6891,8 @@ } ], "responses": { - "200": { - "description": "", + "400": { + "description": "The snapshot identifier is invalid.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -6904,13 +6904,8 @@ } } }, - "400": { - "description": "The request was malformed or failed closed validation.", - "content": { - "application/problem+json": { - "schema": { "$ref": "#/components/schemas/ProblemDetails" } - } - }, + "404": { + "description": "The entitlement snapshot is not visible.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -6939,6 +6934,19 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Entitlement persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } } }, "security": [{ "bearer": [] }], @@ -6959,8 +6967,13 @@ } ], "responses": { - "200": { - "description": "", + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -6972,8 +6985,8 @@ } } }, - "400": { - "description": "The request was malformed or failed closed validation.", + "500": { + "description": "An unexpected failure was safely mapped.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } @@ -6990,13 +7003,8 @@ } } }, - "500": { - "description": "An unexpected failure was safely mapped.", - "content": { - "application/problem+json": { - "schema": { "$ref": "#/components/schemas/ProblemDetails" } - } - }, + "503": { + "description": "Usage persistence is unavailable.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", diff --git a/services/api/src/features/bua/api/entitlement.controller.ts b/services/api/src/features/bua/api/entitlement.controller.ts index 0813c5ea..0b242308 100644 --- a/services/api/src/features/bua/api/entitlement.controller.ts +++ b/services/api/src/features/bua/api/entitlement.controller.ts @@ -1,5 +1,12 @@ import { Controller, Get, Inject, Param, Req } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiNotFoundResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import type { EntitlementSnapshotV1, UsageLedgerStateV1 } from '@databreeze/domain/entitlements/v1'; @@ -11,8 +18,7 @@ import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; - -type EntitlementNotFoundV1 = { readonly accepted: false; readonly code: 'ENTITLEMENT_NOT_FOUND' }; +import { EntitlementProblemError } from '../application/entitlement-problem.error.js'; @ApiTags('entitlements') @ApiBearerAuth() @@ -27,29 +33,35 @@ export class EntitlementController { @Get('snapshots/:snapshotId') @ApiOperation({ summary: 'Read one immutable entitlement snapshot in the caller scope' }) + @ApiBadRequestResponse({ description: 'The snapshot identifier is invalid.' }) + @ApiNotFoundResponse({ description: 'The entitlement snapshot is not visible.' }) + @ApiServiceUnavailableResponse({ description: 'Entitlement persistence is unavailable.' }) async snapshot( @Req() request: unknown, @Param('snapshotId') snapshotIdInput: string, - ): Promise< - | EntitlementSnapshotV1 - | EntitlementNotFoundV1 - | { readonly accepted: false; readonly code: 'INVALID_IDENTIFIER' } - > { + ): Promise { const context = await this.requestContext.resolve(request); const parsed = parseStableIdentifierV1(snapshotIdInput); - if (!parsed.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' }; - return ( - (await this.repository.findSnapshot(context, parsed.value)) ?? { - accepted: false, - code: 'ENTITLEMENT_NOT_FOUND', - } - ); + if (!parsed.accepted) throw new EntitlementProblemError('ENTITLEMENT_REQUEST_INVALID'); + try { + const snapshot = await this.repository.findSnapshot(context, parsed.value); + if (!snapshot) throw new EntitlementProblemError('ENTITLEMENT_NOT_FOUND'); + return snapshot; + } catch (error) { + if (error instanceof EntitlementProblemError) throw error; + throw new EntitlementProblemError('ENTITLEMENT_UNAVAILABLE'); + } } @Get('usage') @ApiOperation({ summary: 'Read the append-only usage ledger state in the caller scope' }) + @ApiServiceUnavailableResponse({ description: 'Usage persistence is unavailable.' }) async usage(@Req() request: unknown): Promise { const context = await this.requestContext.resolve(request); - return this.repository.listUsageState(context); + try { + return await this.repository.listUsageState(context); + } catch { + throw new EntitlementProblemError('ENTITLEMENT_UNAVAILABLE'); + } } } diff --git a/services/api/src/features/bua/application/entitlement-problem.error.ts b/services/api/src/features/bua/application/entitlement-problem.error.ts new file mode 100644 index 00000000..0d58eb00 --- /dev/null +++ b/services/api/src/features/bua/application/entitlement-problem.error.ts @@ -0,0 +1,10 @@ +export type EntitlementProblemCodeV1 = + | 'ENTITLEMENT_NOT_FOUND' + | 'ENTITLEMENT_REQUEST_INVALID' + | 'ENTITLEMENT_UNAVAILABLE'; + +export class EntitlementProblemError extends Error { + public constructor(readonly code: EntitlementProblemCodeV1) { + super(code); + } +} diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 75a9262b..d6df796d 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -10,6 +10,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { AuthenticationProblemError } from '../../features/iam/application/authentication-problem.error.js'; import { SessionProblemError } from '../../features/iam/application/session-problem.error.js'; import { MfaProblemError } from '../../features/iam/application/mfa-problem.error.js'; +import { EntitlementProblemError } from '../../features/bua/application/entitlement-problem.error.js'; import { RequestTenantContextProblemError } from './session-tenant-context.adapter.js'; import { NotReadyError } from '../../features/system/application/not-ready.error.js'; import { InputValidationException } from './input-validation.exception.js'; @@ -55,6 +56,25 @@ function describe(error: unknown, correlationId: string): ProblemInput { status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST, }; } + if (error instanceof EntitlementProblemError) { + const unavailable = error.code === 'ENTITLEMENT_UNAVAILABLE'; + const notFound = error.code === 'ENTITLEMENT_NOT_FOUND'; + return { + code: error.code, + correlationId, + messageKey: unavailable + ? 'api.error.entitlement_unavailable' + : notFound + ? 'api.error.entitlement_not_found' + : 'api.error.entitlement_request_invalid', + retryable: unavailable, + status: unavailable + ? HttpStatus.SERVICE_UNAVAILABLE + : notFound + ? HttpStatus.NOT_FOUND + : HttpStatus.BAD_REQUEST, + }; + } if (error instanceof RequestTenantContextProblemError) { const invalidContext = error.code === 'CONTEXT_INVALID'; return { diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index b04ed4b4..93fa917f 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -612,22 +612,14 @@ void test('protected artifact reads derive tenant scope from an authenticated ac url: '/v1/entitlements/snapshots/80000000-0000-4000-8000-000000000099', headers: { authorization: 'Bearer access-token-for-context-1' }, }); - assert.equal(missingSnapshot.statusCode, 200); - assert.deepEqual(missingSnapshot.json(), { - accepted: false, - code: 'ENTITLEMENT_NOT_FOUND', - }); + assertProblem(missingSnapshot, 404, 'ENTITLEMENT_NOT_FOUND'); const invalidSnapshot = await app.inject({ method: 'GET', url: '/v1/entitlements/snapshots/not-an-id', headers: { authorization: 'Bearer access-token-for-context-1' }, }); - assert.equal(invalidSnapshot.statusCode, 200); - assert.deepEqual(invalidSnapshot.json(), { - accepted: false, - code: 'INVALID_IDENTIFIER', - }); + assertProblem(invalidSnapshot, 400, 'ENTITLEMENT_REQUEST_INVALID'); }, ); }); From 295b9111c4b9aa4d595ace3cf595f9a238e7585d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:49:35 +0700 Subject: [PATCH 16/59] fix(iam): authorize sign-out session ownership --- services/api/openapi/v1.json | 2 + .../iam/api/authentication.controller.ts | 18 ++++++++- services/api/test/http-contract.test.ts | 37 ++++++++++++++++++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index bedee5ec..3d35a340 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -418,6 +418,7 @@ } } }, + "security": [{ "bearer": [] }], "summary": "Read the redacted authenticated session identity", "tags": ["auth"] } @@ -710,6 +711,7 @@ } } }, + "security": [{ "bearer": [] }], "summary": "Revoke a session and clear browser credentials", "tags": ["auth"] } diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 3b94c7fc..08e43d4a 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -3,6 +3,7 @@ import { randomBytes } from 'node:crypto'; import { Body, Controller, Get, HttpCode, Inject, Optional, Post, Req, Res } from '@nestjs/common'; import { ApiBody, + ApiBearerAuth, ApiOkResponse, ApiOperation, ApiServiceUnavailableResponse, @@ -53,6 +54,7 @@ export class AuthenticationController { ) {} @Get('me') + @ApiBearerAuth() @ApiOperation({ summary: 'Read the redacted authenticated session identity' }) @ApiOkResponse({ type: CurrentSessionDto }) async me(@Req() request: FastifyRequest): Promise { @@ -163,18 +165,32 @@ export class AuthenticationController { @Post('sign-out') @HttpCode(204) + @ApiBearerAuth() @ApiOperation({ summary: 'Revoke a session and clear browser credentials' }) @ApiBody({ type: SessionSignOutDto }) @ApiUnauthorizedResponse({ description: 'The session could not be authenticated.' }) @ApiServiceUnavailableResponse({ description: 'Session persistence is unavailable.' }) async signOut( @Body() input: SessionSignOutDto, + @Req() request: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply, ): Promise { if (this.sessions === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); try { + if (this.requestContext === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); + const context = await this.requestContext.resolve(request); + const principal = await this.sessions.findPrincipal(input.sessionId); + if ( + !principal || + principal.userId !== context.actorId || + principal.organizationId !== context.tenantScope.organizationId || + (context.tenantScope.scopeType !== 'organization' && + principal.workspaceId !== context.tenantScope.workspaceId) + ) + throw new SessionProblemError('SESSION_INVALID'); await this.sessions.revoke(input.sessionId); - } catch { + } catch (error) { + if (error instanceof SessionProblemError) throw error; throw new SessionProblemError('SESSION_UNAVAILABLE'); } if (input.clientPlatform === 'web') { diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 93fa917f..4444d079 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -462,6 +462,13 @@ void test('refresh rotates Web cookies without returning the refresh token and p void test('sign-out revokes idempotently and clears browser credentials', async () => { const revoked: string[] = []; + const signOutPrincipal = { + userId: '00000000-0000-4000-8000-000000000001', + organizationId: '00000000-0000-4000-8000-000000000002', + workspaceId: '00000000-0000-4000-8000-000000000003', + securityEpoch: 1, + mfaRequired: false, + }; await withApp( { sessions: { @@ -471,7 +478,17 @@ void test('sign-out revokes idempotently and clears browser credentials', async revoked.push(String(sessionId)); return Promise.resolve(false); }, - findPrincipal: () => Promise.resolve(undefined), + findPrincipal: (sessionId) => + Promise.resolve( + sessionId === '00000000-0000-4000-8000-000000000099' + ? { + ...signOutPrincipal, + userId: '00000000-0000-4000-8000-000000000099', + } + : signOutPrincipal, + ), + findPrincipalByAccessToken: (token) => + Promise.resolve(token === 'sign-out-access-token' ? signOutPrincipal : undefined), }, }, async (app) => { @@ -482,6 +499,7 @@ void test('sign-out revokes idempotently and clears browser credentials', async cookie: `databreeze_refresh=current-refresh-token; databreeze_csrf=${csrfToken}`, 'x-csrf-token': csrfToken, origin: 'http://localhost:3000', + authorization: 'Bearer sign-out-access-token', }, payload: { clientPlatform: 'web', @@ -501,6 +519,7 @@ void test('sign-out revokes idempotently and clears browser credentials', async const native = await app.inject({ method: 'POST', url: '/v1/auth/sign-out', + headers: { authorization: 'Bearer sign-out-access-token' }, payload: { clientPlatform: 'android', sessionId: '00000000-0000-4000-8000-000000000011', @@ -512,6 +531,17 @@ void test('sign-out revokes idempotently and clears browser credentials', async '00000000-0000-4000-8000-000000000010', '00000000-0000-4000-8000-000000000011', ]); + + const crossUser = await app.inject({ + method: 'POST', + url: '/v1/auth/sign-out', + headers: { authorization: 'Bearer sign-out-access-token' }, + payload: { + clientPlatform: 'android', + sessionId: '00000000-0000-4000-8000-000000000099', + }, + }); + assertProblem(crossUser, 401, 'SESSION_INVALID'); }, ); @@ -521,13 +551,16 @@ void test('sign-out revokes idempotently and clears browser credentials', async issue: () => Promise.reject(new Error('not used')), refresh: () => Promise.reject(new Error('not used')), revoke: () => Promise.reject(new Error('database unavailable')), - findPrincipal: () => Promise.resolve(undefined), + findPrincipal: () => Promise.resolve(signOutPrincipal), + findPrincipalByAccessToken: (token) => + Promise.resolve(token === 'sign-out-access-token' ? signOutPrincipal : undefined), }, }, async (app) => { const response = await app.inject({ method: 'POST', url: '/v1/auth/sign-out', + headers: { authorization: 'Bearer sign-out-access-token' }, payload: { clientPlatform: 'android', sessionId: '00000000-0000-4000-8000-000000000011', From afdd94b8dbc56180ea76553956a0847a86685d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:51:00 +0700 Subject: [PATCH 17/59] fix(iam): map MFA persistence failures to unavailable --- .../src/features/iam/api/mfa.controller.ts | 35 ++++++++++++++----- services/api/test/http-contract.test.ts | 17 +++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/services/api/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts index df7e017d..e1ae9111 100644 --- a/services/api/src/features/iam/api/mfa.controller.ts +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -18,17 +18,32 @@ export class MfaController { @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, ) {} + private requireService(): MfaService { + if (this.mfa === undefined) throw new MfaProblemError('MFA_UNAVAILABLE'); + return this.mfa; + } + + private async execute(work: () => Promise): Promise { + try { + return await work(); + } catch { + throw new MfaProblemError('MFA_UNAVAILABLE'); + } + } + @Post('factors') @HttpCode(200) @ApiOperation({ summary: 'Enroll a pending MFA factor for the authenticated user' }) @ApiBody({ type: EnrollMfaFactorDto }) async enroll(@Req() request: unknown, @Body() input: EnrollMfaFactorDto): Promise { - if (this.mfa === undefined) throw new MfaProblemError('MFA_UNAVAILABLE'); + const mfa = this.requireService(); const context = await this.requestContext.resolve(request); - const result = await this.mfa.enroll({ - ...input, - userId: context.actorId, - }); + const result = await this.execute(() => + mfa.enroll({ + ...input, + userId: context.actorId, + }), + ); if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); return result.value; } @@ -42,9 +57,9 @@ export class MfaController { @Param('factorId') factorId: string, @Body() input: VerifyMfaFactorDto, ): Promise { - if (this.mfa === undefined) throw new MfaProblemError('MFA_UNAVAILABLE'); + const mfa = this.requireService(); const context = await this.requestContext.resolve(request); - const result = await this.mfa.verifyFactor(context.actorId, factorId, input.at); + const result = await this.execute(() => mfa.verifyFactor(context.actorId, factorId, input.at)); if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); return result.value; } @@ -57,9 +72,11 @@ export class MfaController { @Req() request: unknown, @Body() input: RedeemMfaRecoveryCodeDto, ): Promise { - if (this.mfa === undefined) throw new MfaProblemError('MFA_UNAVAILABLE'); + const mfa = this.requireService(); const context = await this.requestContext.resolve(request); - const result = await this.mfa.redeemRecovery(context.actorId, input.presentedDigest, input.at); + const result = await this.execute(() => + mfa.redeemRecovery(context.actorId, input.presentedDigest, input.at), + ); if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); return result.value; } diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 4444d079..e3cf310c 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -712,4 +712,21 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con }); assertProblem(invalid, 400, 'MFA_REQUEST_REJECTED'); }); + + const unavailableMfa = { + enroll: () => Promise.reject(new Error('database unavailable')), + } as unknown as MfaService; + await withApp({ mfaService: unavailableMfa, requestTenantContext }, async (app) => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/mfa/factors', + payload: { + id: '00000000-0000-4000-8000-000000000010', + method: 'TOTP', + secretReference: 'vault://iam/mfa/test-factor', + enrolledAt: '2026-01-01T00:00:00.000Z', + }, + }); + assertProblem(response, 503, 'MFA_UNAVAILABLE'); + }); }); From 5e67f8b2eadeec005344d776bd5eeef492174d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:52:16 +0700 Subject: [PATCH 18/59] fix(iam): fail closed at device identity API boundary --- .../iam/api/device-identity.controller.ts | 50 ++++++++++++++----- .../device-identity-problem.error.ts | 12 +++++ .../platform/http/problem-details.filter.ts | 20 ++++++++ .../iam/device-identity.controller.test.ts | 40 +++++++++++++++ 4 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 services/api/src/features/iam/application/device-identity-problem.error.ts diff --git a/services/api/src/features/iam/api/device-identity.controller.ts b/services/api/src/features/iam/api/device-identity.controller.ts index 1d573ab2..9b9383d0 100644 --- a/services/api/src/features/iam/api/device-identity.controller.ts +++ b/services/api/src/features/iam/api/device-identity.controller.ts @@ -4,8 +4,10 @@ import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import { DEVICE_IDENTITY_SERVICE, + type DeviceIdentityApplicationResultV1, type DeviceIdentityService, } from '../application/device-identity.service.js'; +import { DeviceIdentityProblemError } from '../application/device-identity-problem.error.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -28,6 +30,24 @@ export class DeviceIdentityController { private readonly requestContext: RequestTenantContextPortV1, ) {} + private async execute( + work: () => Promise>, + ): Promise> { + let result: DeviceIdentityApplicationResultV1; + try { + result = await work(); + } catch { + throw new DeviceIdentityProblemError('DEVICE_UNAVAILABLE'); + } + if (result.accepted) return result; + if (result.code === 'SCOPE_DENIED') throw new DeviceIdentityProblemError('DEVICE_SCOPE_DENIED'); + if (result.code === 'DEVICE_NOT_FOUND') + throw new DeviceIdentityProblemError('DEVICE_NOT_FOUND'); + if (result.code === 'REVISION_CONFLICT' || result.code === 'DEVICE_REVOKED') + throw new DeviceIdentityProblemError('DEVICE_REVISION_CONFLICT'); + throw new DeviceIdentityProblemError('DEVICE_REQUEST_REJECTED'); + } + @Post('devices/enrollment-challenges') @HttpCode(200) @ApiOperation({ summary: 'Issue a short-lived device proof-of-possession challenge' }) @@ -39,7 +59,7 @@ export class DeviceIdentityController { ): Promise { const context = await this.requestContext.resolve(request); void idempotencyKey; - return this.devices.issueEnrollmentChallenge(context, input); + return this.execute(() => this.devices.issueEnrollmentChallenge(context, input)); } @Post('devices/enroll') @@ -48,7 +68,7 @@ export class DeviceIdentityController { @ApiBody({ type: EnrollDeviceDto }) async enroll(@Req() request: unknown, @Body() input: EnrollDeviceDto): Promise { const context = await this.requestContext.resolve(request); - return this.devices.enroll(context, input); + return this.execute(() => this.devices.enroll(context, input)); } @Post('devices/:deviceId/activate') @@ -61,7 +81,9 @@ export class DeviceIdentityController { @Body() input: DeviceRevisionDto, ): Promise { const context = await this.requestContext.resolve(request); - return this.devices.activate(context, deviceId, input.expectedRevision, input.at); + return this.execute(() => + this.devices.activate(context, deviceId, input.expectedRevision, input.at), + ); } @Get('organizations/:organizationId/devices') @@ -77,8 +99,8 @@ export class DeviceIdentityController { context.tenantScope.scopeType !== 'organization' || parsed.value !== context.tenantScope.organizationId ) - return { accepted: false, code: 'SCOPE_DENIED' as const }; - return this.devices.list(context); + throw new DeviceIdentityProblemError('DEVICE_SCOPE_DENIED'); + return this.execute(() => this.devices.list(context)); } @Post('devices/:deviceId/revoke') @@ -91,7 +113,9 @@ export class DeviceIdentityController { @Body() input: DeviceRevisionDto, ): Promise { const context = await this.requestContext.resolve(request); - return this.devices.revoke(context, deviceId, input.expectedRevision, input.at); + return this.execute(() => + this.devices.revoke(context, deviceId, input.expectedRevision, input.at), + ); } @Post('devices/:deviceId/key') @@ -104,12 +128,14 @@ export class DeviceIdentityController { @Body() input: RotateDeviceKeyDto, ): Promise { const context = await this.requestContext.resolve(request); - return this.devices.rotateKey( - context, - deviceId, - input.expectedRevision, - input.nextPublicKey, - input.at, + return this.execute(() => + this.devices.rotateKey( + context, + deviceId, + input.expectedRevision, + input.nextPublicKey, + input.at, + ), ); } } diff --git a/services/api/src/features/iam/application/device-identity-problem.error.ts b/services/api/src/features/iam/application/device-identity-problem.error.ts new file mode 100644 index 00000000..317fadfe --- /dev/null +++ b/services/api/src/features/iam/application/device-identity-problem.error.ts @@ -0,0 +1,12 @@ +export type DeviceIdentityProblemCodeV1 = + | 'DEVICE_NOT_FOUND' + | 'DEVICE_REQUEST_REJECTED' + | 'DEVICE_REVISION_CONFLICT' + | 'DEVICE_SCOPE_DENIED' + | 'DEVICE_UNAVAILABLE'; + +export class DeviceIdentityProblemError extends Error { + public constructor(readonly code: DeviceIdentityProblemCodeV1) { + super(code); + } +} diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index d6df796d..21c50a64 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -11,6 +11,7 @@ import { AuthenticationProblemError } from '../../features/iam/application/authe import { SessionProblemError } from '../../features/iam/application/session-problem.error.js'; import { MfaProblemError } from '../../features/iam/application/mfa-problem.error.js'; import { EntitlementProblemError } from '../../features/bua/application/entitlement-problem.error.js'; +import { DeviceIdentityProblemError } from '../../features/iam/application/device-identity-problem.error.js'; import { RequestTenantContextProblemError } from './session-tenant-context.adapter.js'; import { NotReadyError } from '../../features/system/application/not-ready.error.js'; import { InputValidationException } from './input-validation.exception.js'; @@ -75,6 +76,25 @@ function describe(error: unknown, correlationId: string): ProblemInput { : HttpStatus.BAD_REQUEST, }; } + if (error instanceof DeviceIdentityProblemError) { + const status = + error.code === 'DEVICE_UNAVAILABLE' + ? HttpStatus.SERVICE_UNAVAILABLE + : error.code === 'DEVICE_NOT_FOUND' + ? HttpStatus.NOT_FOUND + : error.code === 'DEVICE_SCOPE_DENIED' + ? HttpStatus.FORBIDDEN + : error.code === 'DEVICE_REVISION_CONFLICT' + ? HttpStatus.CONFLICT + : HttpStatus.BAD_REQUEST; + return { + code: error.code, + correlationId, + messageKey: `api.error.${error.code.toLowerCase()}`, + retryable: error.code === 'DEVICE_UNAVAILABLE', + status, + }; + } if (error instanceof RequestTenantContextProblemError) { const invalidContext = error.code === 'CONTEXT_INVALID'; return { diff --git a/services/api/test/features/iam/device-identity.controller.test.ts b/services/api/test/features/iam/device-identity.controller.test.ts index 515777e6..8da3a32d 100644 --- a/services/api/test/features/iam/device-identity.controller.test.ts +++ b/services/api/test/features/iam/device-identity.controller.test.ts @@ -85,6 +85,46 @@ void test('[IAM-007, IAM-021] device identity HTTP endpoints use the authenticat const devicesValue = jsonObject(devices)['value']; assert.ok(Array.isArray(devicesValue)); assert.equal(devicesValue.length, 1); + + const denied = await app.inject({ + method: 'GET', + url: '/v1/organizations/00000000-0000-4000-8000-000000000699/devices', + }); + assert.equal(denied.statusCode, 403); + assert.equal(jsonObject(denied)['code'], 'DEVICE_SCOPE_DENIED'); + } finally { + await app.close(); + } +}); + +void test('[IAM-007] device identity persistence failures return a retryable unavailable problem', async () => { + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(context()), + }; + const unavailableService = { + issueEnrollmentChallenge: () => Promise.reject(new Error('database unavailable')), + } as unknown as DeviceIdentityService; + const { app } = await createApiApplication({ + deviceIdentityService: unavailableService, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/devices/enrollment-challenges', + payload: { + challengeId, + platform: 'WINDOWS', + installationIdHash: 'a'.repeat(64), + challengeDigest: 'b'.repeat(64), + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T00:05:00.000Z', + }, + }); + assert.equal(response.statusCode, 503); + const problem = jsonObject(response); + assert.equal(problem['code'], 'DEVICE_UNAVAILABLE'); + assert.equal(problem['retryable'], true); } finally { await app.close(); } From 7c94a11ccca7b1db460d8270b0872af4c734a441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:54:57 +0700 Subject: [PATCH 19/59] fix(iam): distinguish session authority outages --- .../platform/http/problem-details.filter.ts | 21 +++++++++++++++---- .../http/session-tenant-context.adapter.ts | 7 +++++-- services/api/test/http-contract.test.ts | 20 ++++++++++++++++++ .../http/session-tenant-context.test.ts | 15 +++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 21c50a64..d4f77a47 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -97,12 +97,25 @@ function describe(error: unknown, correlationId: string): ProblemInput { } if (error instanceof RequestTenantContextProblemError) { const invalidContext = error.code === 'CONTEXT_INVALID'; + const unavailable = error.code === 'AUTHENTICATION_UNAVAILABLE'; return { - code: invalidContext ? 'CONTEXT_INVALID' : 'AUTHENTICATION_FAILED', + code: invalidContext + ? 'CONTEXT_INVALID' + : unavailable + ? 'AUTHENTICATION_UNAVAILABLE' + : 'AUTHENTICATION_FAILED', correlationId, - messageKey: invalidContext ? 'api.error.context_invalid' : 'api.error.authentication_failed', - retryable: false, - status: invalidContext ? HttpStatus.BAD_REQUEST : HttpStatus.UNAUTHORIZED, + messageKey: invalidContext + ? 'api.error.context_invalid' + : unavailable + ? 'api.error.authentication_unavailable' + : 'api.error.authentication_failed', + retryable: unavailable, + status: invalidContext + ? HttpStatus.BAD_REQUEST + : unavailable + ? HttpStatus.SERVICE_UNAVAILABLE + : HttpStatus.UNAUTHORIZED, }; } if (error instanceof InputValidationException) { diff --git a/services/api/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts index c7d2f4ef..4fc088aa 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -5,7 +5,10 @@ import { createIamTenantContextV1 } from '../../features/iam/application/tenant- import type { RequestTenantContextPortV1 } from './request-tenant-context.port.js'; import { getRequestContext } from './request-context.js'; -export type RequestTenantContextProblemCodeV1 = 'AUTHENTICATION_FAILED' | 'CONTEXT_INVALID'; +export type RequestTenantContextProblemCodeV1 = + | 'AUTHENTICATION_FAILED' + | 'AUTHENTICATION_UNAVAILABLE' + | 'CONTEXT_INVALID'; export class RequestTenantContextProblemError extends Error { constructor(readonly code: RequestTenantContextProblemCodeV1) { @@ -80,7 +83,7 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP try { principal = await this.sessions.findPrincipalByAccessToken(token); } catch { - throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + throw new RequestTenantContextProblemError('AUTHENTICATION_UNAVAILABLE'); } if (principal === undefined) throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index e3cf310c..b1c75488 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -655,6 +655,26 @@ void test('protected artifact reads derive tenant scope from an authenticated ac assertProblem(invalidSnapshot, 400, 'ENTITLEMENT_REQUEST_INVALID'); }, ); + + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(undefined), + findPrincipalByAccessToken: () => Promise.reject(new Error('database unavailable')), + }, + }, + async (app) => { + const response = await app.inject({ + method: 'GET', + url: '/v1/artifacts/inbox', + headers: { authorization: 'Bearer unavailable-access-token-12345' }, + }); + assertProblem(response, 503, 'AUTHENTICATION_UNAVAILABLE'); + }, + ); }); void test('MFA HTTP lifecycle derives the user from the authenticated tenant context and returns redacted state', async () => { diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts index 34b0a886..e4a56213 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -78,3 +78,18 @@ void test('uses the request id for read-only calls and rejects unsafe principal }, ); }); + +void test('reports session authority outages separately from rejected bearer credentials', async () => { + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: () => Promise.reject(new Error('database unavailable')), + }); + await assert.rejects( + adapter.resolve({ + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }), + (error: unknown) => { + assert.equal((error as { code?: unknown }).code, 'AUTHENTICATION_UNAVAILABLE'); + return true; + }, + ); +}); From 44c1fae36be7e9df40ff042b5b0fd95d1c1f92fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:55:34 +0700 Subject: [PATCH 20/59] test(iam): exercise unsafe principal rejection --- .../api/test/platform/http/session-tenant-context.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts index e4a56213..cb8a6eaf 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -71,9 +71,12 @@ void test('uses the request id for read-only calls and rejects unsafe principal findPrincipalByAccessToken: () => Promise.resolve({ ...principal, securityEpoch: 0 }), }); await assert.rejects( - adapter.resolve({ id: 'request-read-001', headers: { authorization: 'Bearer token' } }), + adapter.resolve({ + id: 'request-read-001', + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }), (error: unknown) => { - assert.equal((error as { code?: unknown }).code, 'AUTHENTICATION_FAILED'); + assert.equal((error as { code?: unknown }).code, 'CONTEXT_INVALID'); return true; }, ); From 868267dee0653048bce986502a2e561dbe2bf500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:56:55 +0700 Subject: [PATCH 21/59] fix(iam): bound session cookie parsing --- .../src/features/iam/api/session-cookies.ts | 20 ++++++++--- .../test/features/iam/session-cookies.test.ts | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/services/api/src/features/iam/api/session-cookies.ts b/services/api/src/features/iam/api/session-cookies.ts index 5feab2a3..b00aed6f 100644 --- a/services/api/src/features/iam/api/session-cookies.ts +++ b/services/api/src/features/iam/api/session-cookies.ts @@ -1,5 +1,9 @@ const COOKIE_NAME_PATTERN_V1 = /^[A-Za-z0-9_]+$/u; const COOKIE_VALUE_PATTERN_V1 = /^[A-Za-z0-9._~-]+$/u; +const MAX_COOKIE_HEADER_LENGTH_V1 = 8_192; +const MAX_COOKIE_NAME_LENGTH_V1 = 64; +const MAX_COOKIE_VALUE_LENGTH_V1 = 4_096; +const MAX_COOKIE_SEGMENTS_V1 = 64; export const REFRESH_COOKIE_NAME_V1 = 'databreeze_refresh'; export const CSRF_COOKIE_NAME_V1 = 'databreeze_csrf'; @@ -10,11 +14,11 @@ export interface CookieOptionsV1 { } function validCookieNameV1(name: string): boolean { - return COOKIE_NAME_PATTERN_V1.test(name); + return name.length <= MAX_COOKIE_NAME_LENGTH_V1 && COOKIE_NAME_PATTERN_V1.test(name); } function validCookieValueV1(value: string): boolean { - return COOKIE_VALUE_PATTERN_V1.test(value); + return value.length <= MAX_COOKIE_VALUE_LENGTH_V1 && COOKIE_VALUE_PATTERN_V1.test(value); } export function serializeCookieV1(name: string, value: string, options: CookieOptionsV1): string { @@ -52,9 +56,17 @@ export function clearCookieV1(name: string, options: Pick MAX_COOKIE_HEADER_LENGTH_V1 || + !validCookieNameV1(name) + ) { + return undefined; + } + const segments = rawCookie.split(';'); + if (segments.length > MAX_COOKIE_SEGMENTS_V1) return undefined; let found: string | undefined; - for (const segment of rawCookie.split(';')) { + for (const segment of segments) { const trimmed = segment.trim(); if (trimmed.length === 0) continue; const equals = trimmed.indexOf('='); diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts index 8b686e8f..9061e8c4 100644 --- a/services/api/test/features/iam/session-cookies.test.ts +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -49,6 +49,39 @@ void test('reads one exact cookie value and fails closed for ambiguity or malfor assert.equal(readCookieValueV1(undefined, REFRESH_COOKIE_NAME_V1), undefined); }); +void test('rejects cookie headers and fields beyond parser resource bounds', () => { + assert.equal( + readCookieValueV1( + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; padding=${'a'.repeat(8_192)}`, + REFRESH_COOKIE_NAME_V1, + ), + undefined, + ); + assert.equal( + readCookieValueV1( + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; ${Array.from({ length: 64 }, (_, index) => `c${index}=v`).join('; ')}`, + REFRESH_COOKIE_NAME_V1, + ), + undefined, + ); + assert.equal( + readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${'a'.repeat(4_097)}`, REFRESH_COOKIE_NAME_V1), + undefined, + ); + assert.throws( + () => serializeCookieV1('a'.repeat(65), token, { httpOnly: true, maxAgeSeconds: 1 }), + /Cookie name or value is invalid/, + ); + assert.throws( + () => + serializeCookieV1(REFRESH_COOKIE_NAME_V1, 'a'.repeat(4_097), { + httpOnly: true, + maxAgeSeconds: 1, + }), + /Cookie name or value is invalid/, + ); +}); + void test('creates deletion cookies without weakening the original security attributes', () => { assert.equal( clearCookieV1(REFRESH_COOKIE_NAME_V1, { httpOnly: true }), From 447b029adcb653a02a1c591f38254071a8294c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:57:49 +0700 Subject: [PATCH 22/59] fix(http): bound CSRF cookie parsing --- .../api/src/platform/http/csrf-protection.ts | 27 ++++++++++++++++-- .../platform/http/csrf-protection.test.ts | 28 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/services/api/src/platform/http/csrf-protection.ts b/services/api/src/platform/http/csrf-protection.ts index af209493..54b6d38b 100644 --- a/services/api/src/platform/http/csrf-protection.ts +++ b/services/api/src/platform/http/csrf-protection.ts @@ -28,6 +28,10 @@ const COOKIE_AUTH_NAMES = new Set([ 'databreeze_session', ]); const CSRF_COOKIE_NAME = 'databreeze_csrf'; +const MAX_COOKIE_HEADER_LENGTH = 8_192; +const MAX_COOKIE_NAME_LENGTH = 64; +const MAX_COOKIE_VALUE_LENGTH = 4_096; +const MAX_COOKIE_SEGMENTS = 64; function oneHeader( headers: CsrfRequestV1['headers'], @@ -51,11 +55,19 @@ function parseCookies(raw: string): { readonly values: ReadonlyMap; readonly duplicateNames: ReadonlySet; readonly malformed: boolean; + readonly resourceLimitExceeded: boolean; } { const values = new Map(); const duplicateNames = new Set(); let malformed = false; - for (const segment of raw.split(';')) { + if (raw.length > MAX_COOKIE_HEADER_LENGTH) { + return { values, duplicateNames, malformed, resourceLimitExceeded: true }; + } + const segments = raw.split(';'); + if (segments.length > MAX_COOKIE_SEGMENTS) { + return { values, duplicateNames, malformed, resourceLimitExceeded: true }; + } + for (const segment of segments) { const trimmed = segment.trim(); if (trimmed.length === 0) continue; const equals = trimmed.indexOf('='); @@ -65,14 +77,20 @@ function parseCookies(raw: string): { } const name = trimmed.slice(0, equals).trim(); const value = trimmed.slice(equals + 1).trim(); - if (!/^[A-Za-z0-9_]+$/u.test(name) || value.includes('\r') || value.includes('\n')) { + if ( + name.length > MAX_COOKIE_NAME_LENGTH || + value.length > MAX_COOKIE_VALUE_LENGTH || + !/^[A-Za-z0-9_]+$/u.test(name) || + value.includes('\r') || + value.includes('\n') + ) { malformed = true; continue; } if (values.has(name)) duplicateNames.add(name); values.set(name, value); } - return { values, duplicateNames, malformed }; + return { values, duplicateNames, malformed, resourceLimitExceeded: false }; } function hasCookieAuth(cookies: ReturnType): boolean { @@ -113,6 +131,9 @@ export function evaluateCsrfRequestV1( return Object.freeze({ accepted: false as const, code: 'CSRF_INVALID' as const }); const cookies = parseCookies(cookie.value); + if (cookies.resourceLimitExceeded) { + return Object.freeze({ accepted: false as const, code: 'CSRF_INVALID' as const }); + } if (!hasCookieAuth(cookies)) return Object.freeze({ accepted: true as const }); if (!originAccepted(request.headers, options)) { return Object.freeze({ accepted: false as const, code: 'ORIGIN_INVALID' as const }); diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts index 37adeaea..54c483bb 100644 --- a/services/api/test/platform/http/csrf-protection.test.ts +++ b/services/api/test/platform/http/csrf-protection.test.ts @@ -138,3 +138,31 @@ void test('fails closed for duplicate cookies and duplicate token headers', () = { accepted: false, code: 'CSRF_INVALID' }, ); }); + +void test('fails closed when cookie parsing exceeds resource bounds', () => { + const request = (cookie: string) => ({ + method: 'POST', + headers: { + cookie, + origin: 'https://app.databreeze.example', + 'x-csrf-token': token, + }, + }); + + assert.deepEqual( + evaluateCsrfRequestV1( + request(`databreeze_refresh=session-value; padding=${'a'.repeat(8_192)}`), + { allowedOrigins }, + ), + { accepted: false, code: 'CSRF_INVALID' }, + ); + assert.deepEqual( + evaluateCsrfRequestV1( + request( + `databreeze_refresh=session-value; ${Array.from({ length: 64 }, (_, index) => `c${index}=v`).join('; ')}`, + ), + { allowedOrigins }, + ), + { accepted: false, code: 'CSRF_INVALID' }, + ); +}); From 048d5bd5dcb67d586c4bd7d4ae1d5146cdbf5461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 12:59:56 +0700 Subject: [PATCH 23/59] fix(iam): enforce narrowest membership authority --- .../in-memory-iam-repository.adapter.ts | 24 ++++++-- .../adapter/prisma-iam-repository.adapter.ts | 17 +++++- .../iam/prisma-iam-repository.test.ts | 35 ++++++++++- .../features/iam/scoped-repository.test.ts | 59 +++++++++++++++++++ 4 files changed, 124 insertions(+), 11 deletions(-) diff --git a/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts index 9c58604c..3e5c0650 100644 --- a/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts @@ -15,6 +15,12 @@ function visibleInScope(context: TenantScopeV1, membership: TenantScopeV1): bool return tenantScopeContainsV1(context, membership) || tenantScopeContainsV1(membership, context); } +function scopeSpecificity(scope: TenantScopeV1): number { + if (scope.scopeType === 'project') return 3; + if (scope.scopeType === 'workspace') return 2; + return 1; +} + function cloneMemberships(source: readonly IamMembershipRecordV1[]): IamMembershipRecordV1[] { return source.map((membership) => Object.freeze({ ...membership, scope: { ...membership.scope } }), @@ -35,12 +41,18 @@ export class InMemoryIamRepositoryAdapter implements IamRepositoryPortV1 { principalId: StableIdentifierV1, ): Promise { await Promise.resolve(); - return this.memberships.find( - (membership) => - membership.principalId === principalId && - membership.status === 'ACTIVE' && - visibleInScope(context.tenantScope, membership.scope), - ); + return this.memberships + .filter( + (membership) => + membership.principalId === principalId && + membership.status === 'ACTIVE' && + tenantScopeContainsV1(membership.scope, context.tenantScope), + ) + .sort( + (left, right) => + scopeSpecificity(right.scope) - scopeSpecificity(left.scope) || + left.id.localeCompare(right.id), + )[0]; } async listMemberships(context: IamTenantContextV1): Promise { diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts index 7c163734..bc1b5268 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -117,6 +117,12 @@ function visibleInScope(context: TenantScopeV1, membership: TenantScopeV1): bool return tenantScopeContainsV1(context, membership) || tenantScopeContainsV1(membership, context); } +function scopeSpecificity(scope: TenantScopeV1): number { + if (scope.scopeType === 'project') return 3; + if (scope.scopeType === 'workspace') return 2; + return 1; +} + class PrismaIamTransactionAdapter implements IamTransactionPortV1 { public constructor(private readonly client: IamDatabaseClientV1) {} @@ -134,12 +140,17 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { }); return rows .map(membershipFromRow) - .find( + .filter( (membership) => membership.principalId === principalId && membership.status === 'ACTIVE' && - visibleInScope(context.tenantScope, membership.scope), - ); + tenantScopeContainsV1(membership.scope, context.tenantScope), + ) + .sort( + (left, right) => + scopeSpecificity(right.scope) - scopeSpecificity(left.scope) || + left.id.localeCompare(right.id), + )[0]; } public async listMemberships( diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index 78edb94a..0f360e1f 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -26,6 +26,7 @@ const organizationId = stable('1'); const workspaceId = stable('2'); const siblingWorkspaceId = stable('3'); const principalId = stable('4'); +const projectId = stable('6'); function context(scope: TenantScopeV1, expectedRevision?: number) { const result = createIamTenantContextV1({ @@ -43,9 +44,10 @@ function context(scope: TenantScopeV1, expectedRevision?: number) { function row( idValue: string, - scope: 'WORKSPACE' | 'ORGANIZATION', + scope: 'PROJECT' | 'WORKSPACE' | 'ORGANIZATION', workspace: string | null, roleId: string, + project: string | null = null, ): IamMembershipDatabaseRowV1 { return { id: idValue, @@ -54,7 +56,7 @@ function row( scopeType: scope, organizationId, workspaceId: workspace, - projectId: null, + projectId: project, roleId, status: 'ACTIVE', startsAt: null, @@ -133,6 +135,35 @@ void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and ); }); +void test('[IAM-003, IAM-014] Prisma membership authority chooses the narrowest containing scope', async () => { + const projectScope = { + scopeType: 'project', + organizationId, + workspaceId, + projectId, + } as const; + const { client } = createDatabase([ + row(id('09'), 'ORGANIZATION', null, 'owner'), + row(id('10'), 'WORKSPACE', workspaceId, 'viewer'), + row(id('11'), 'PROJECT', workspaceId, 'operator', projectId), + ]); + const repository = new PrismaIamRepositoryAdapter(client); + + assert.equal( + (await repository.findMembership(context(projectScope), principalId))?.roleId, + 'operator', + ); + + const descendantOnly = createDatabase([row(id('12'), 'WORKSPACE', workspaceId, 'owner')]); + assert.equal( + await new PrismaIamRepositoryAdapter(descendantOnly.client).findMembership( + context({ scopeType: 'organization', organizationId }), + principalId, + ), + undefined, + ); +}); + void test('[IAM-009, IAM-019] Prisma IAM writes require narrowing and enforce optimistic revisions', async () => { const { client, memberships, forceUpdateConflict } = createDatabase(); const repository = new PrismaIamRepositoryAdapter(client); diff --git a/services/api/test/features/iam/scoped-repository.test.ts b/services/api/test/features/iam/scoped-repository.test.ts index 86e036e6..a7dbaf1f 100644 --- a/services/api/test/features/iam/scoped-repository.test.ts +++ b/services/api/test/features/iam/scoped-repository.test.ts @@ -21,6 +21,7 @@ const organizationId = stable('1'); const workspaceId = stable('2'); const siblingWorkspaceId = stable('3'); const principalId = stable('4'); +const projectId = stable('6'); function context(scope: unknown, expectedRevision?: number) { const result = createIamTenantContextV1({ @@ -100,6 +101,64 @@ void test('[IAM-009, IAM-019] repository reads never cross sibling workspace sco ); }); +void test('[IAM-003, IAM-014] membership authority only flows downward and the narrowest role wins', async () => { + const repository = new InMemoryIamRepositoryAdapter(); + const organizationScope: TenantScopeV1 = { scopeType: 'organization', organizationId }; + const projectScope: TenantScopeV1 = { + scopeType: 'project', + organizationId, + workspaceId, + projectId, + }; + repository.seed([ + { + id: stable('30'), + principalId, + scope: organizationScope, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }, + { + id: stable('31'), + principalId, + scope: workspaceScope, + roleId: 'viewer', + status: 'ACTIVE', + revision: 1, + }, + { + id: stable('32'), + principalId, + scope: projectScope, + roleId: 'operator', + status: 'ACTIVE', + revision: 1, + }, + ]); + + assert.equal( + (await repository.findMembership(context(projectScope), principalId))?.roleId, + 'operator', + ); + + const descendantOnly = new InMemoryIamRepositoryAdapter(); + descendantOnly.seed([ + { + id: stable('33'), + principalId, + scope: workspaceScope, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }, + ]); + assert.equal( + await descendantOnly.findMembership(context(organizationScope), principalId), + undefined, + ); +}); + void test('[IAM-009, IAM-019] writes cannot broaden a scoped context and transactions roll back', async () => { const repository = new InMemoryIamRepositoryAdapter(); repository.seed([]); From c48aa8b453bc8279a4e1cad80eaaa170a64a8ce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:00:59 +0700 Subject: [PATCH 24/59] fix(iam): scope membership mutation lookup --- .../adapter/prisma-iam-repository.adapter.ts | 11 ++++--- .../iam/prisma-iam-repository.test.ts | 33 +++++++++++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts index bc1b5268..3c906e16 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -30,8 +30,8 @@ export interface IamMembershipDatabaseRowV1 { } interface IamMembershipDelegateV1 { - findUnique(input: { - readonly where: { readonly id: string }; + findFirst(input: { + readonly where: Readonly>; }): Promise; findMany(input: { readonly where: Readonly>; @@ -173,8 +173,11 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); const validated = validateMembershipV1({ ...membership, principalType: 'USER' }); if (!validated.accepted) throw new Error(`IAM_${validated.code}`); - const existingRow = await this.client.membershipIdentity.findUnique({ - where: { id: membership.id }, + const existingRow = await this.client.membershipIdentity.findFirst({ + where: { + id: membership.id, + organizationId: context.tenantScope.organizationId, + }, }); if (!existingRow) { if (context.expectedRevision !== undefined) throw new Error('IAM_REVISION_CONFLICT'); diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index 0f360e1f..64527aeb 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -69,13 +69,23 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { readonly client: IamDatabaseClientV1; readonly memberships: Map; readonly forceUpdateConflict: { value: boolean }; + readonly firstQueries: ReadonlyArray>>; } { const memberships = new Map(rows.map((value) => [value.id, value])); const forceUpdateConflict = { value: false }; + const firstQueries: Array>> = []; const client = { membershipIdentity: { - findUnique: async ({ where }: { readonly where: { readonly id: string } }) => - memberships.get(where.id) ?? null, + findFirst: async ({ where }: { readonly where: Readonly> }) => { + firstQueries.push(where); + return ( + [...memberships.values()].find((candidate) => + Object.entries(where).every( + ([key, value]) => candidate[key as keyof IamMembershipDatabaseRowV1] === value, + ), + ) ?? null + ); + }, findMany: async ({ where }: { readonly where: Readonly> }) => [...memberships.values()].filter((candidate) => Object.entries(where).every( @@ -112,7 +122,7 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { } }, } as unknown as IamDatabaseClientV1; - return { client, memberships, forceUpdateConflict }; + return { client, memberships, forceUpdateConflict, firstQueries }; } void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and hide siblings', async () => { @@ -234,3 +244,20 @@ void test('[IAM-009] Prisma IAM transaction rollback leaves no staged membership ); assert.equal(memberships.size, 0); }); + +void test('[IAM-009, IAM-019] Prisma membership mutation lookup includes tenant ancestry', async () => { + const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; + const { client, firstQueries } = createDatabase(); + const repository = new PrismaIamRepositoryAdapter(client); + + await repository.saveMembership(context(workspaceScope), { + id: stable('23'), + principalId, + scope: workspaceScope, + roleId: 'viewer', + status: 'ACTIVE', + revision: 1, + }); + + assert.deepEqual(firstQueries, [{ id: stable('23'), organizationId }]); +}); From 4f1b555827e0cc8048da3a143dab6da6de8ec354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:01:51 +0700 Subject: [PATCH 25/59] fix(iam): scope device identity lookups --- ...isma-device-identity-repository.adapter.ts | 41 ++++++++++++----- .../prisma-device-identity-repository.test.ts | 45 +++++++++++++++---- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts index 3e8025e2..8b16377e 100644 --- a/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts @@ -47,7 +47,7 @@ export interface DeviceEnrollmentChallengeDatabaseRowV1 { interface DelegateV1 { create(input: { readonly data: TCreate }): Promise; - findUnique(input: { readonly where: { readonly id: string } }): Promise; + findFirst(input: { readonly where: Readonly> }): Promise; findMany(input: { readonly where: Readonly>; readonly orderBy?: Readonly>; @@ -57,7 +57,7 @@ interface DelegateV1 { readonly data: TUpdate; }): Promise; updateMany?(input: { - readonly where: { readonly id: string; readonly revision: number }; + readonly where: Readonly>; readonly data: TUpdate; }): Promise<{ readonly count: number }>; } @@ -234,8 +234,8 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio challenge: DeviceEnrollmentChallengeV1, ): Promise { if (!organizationScope(context, challenge.organizationId)) throw new Error('SCOPE_DENIED'); - const existing = await this.client.deviceEnrollmentChallenge.findUnique({ - where: { id: challenge.id }, + const existing = await this.client.deviceEnrollmentChallenge.findFirst({ + where: { id: challenge.id, organizationId: context.tenantScope.organizationId }, }); if (!existing) { await this.client.deviceEnrollmentChallenge.create({ data: challengeData(challenge) }); @@ -251,7 +251,11 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio throw new Error('IMMUTABLE_CHALLENGE'); if (!this.client.deviceEnrollmentChallenge.updateMany) throw new Error('UPDATE_UNAVAILABLE'); const result = await this.client.deviceEnrollmentChallenge.updateMany({ - where: { id: challenge.id, revision: current.revision }, + where: { + id: challenge.id, + organizationId: context.tenantScope.organizationId, + revision: current.revision, + }, data: { status: challenge.status, revision: challenge.revision }, }); if (result.count !== 1) throw new Error('REVISION_CONFLICT'); @@ -261,16 +265,22 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio context: IamTenantContextV1, challengeId: StableIdentifierV1, ): Promise { - const row = await this.client.deviceEnrollmentChallenge.findUnique({ - where: { id: challengeId }, + if (context.tenantScope.scopeType !== 'organization') return undefined; + const row = await this.client.deviceEnrollmentChallenge.findFirst({ + where: { + id: challengeId, + organizationId: context.tenantScope.organizationId, + }, }); - if (!row || !organizationScope(context, row.organizationId)) return undefined; + if (!row) return undefined; return challengeFromRow(row); } public async saveDevice(context: IamTenantContextV1, device: DeviceIdentityV1): Promise { if (!organizationScope(context, device.organizationId)) throw new Error('SCOPE_DENIED'); - const existing = await this.client.deviceIdentity.findUnique({ where: { id: device.id } }); + const existing = await this.client.deviceIdentity.findFirst({ + where: { id: device.id, organizationId: context.tenantScope.organizationId }, + }); if (existing) { if (JSON.stringify(deviceFromRow(existing)) !== JSON.stringify(device)) throw new Error('IMMUTABLE_DEVICE'); @@ -283,8 +293,11 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio context: IamTenantContextV1, deviceId: StableIdentifierV1, ): Promise { - const row = await this.client.deviceIdentity.findUnique({ where: { id: deviceId } }); - if (!row || !organizationScope(context, row.organizationId)) return undefined; + if (context.tenantScope.scopeType !== 'organization') return undefined; + const row = await this.client.deviceIdentity.findFirst({ + where: { id: deviceId, organizationId: context.tenantScope.organizationId }, + }); + if (!row) return undefined; return deviceFromRow(row); } @@ -308,7 +321,11 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio if (device.revision !== expectedRevision + 1) throw new Error('INVALID_REVISION'); if (!this.client.deviceIdentity.updateMany) throw new Error('UPDATE_UNAVAILABLE'); const result = await this.client.deviceIdentity.updateMany({ - where: { id: device.id, revision: expectedRevision }, + where: { + id: device.id, + organizationId: context.tenantScope.organizationId, + revision: expectedRevision, + }, data: { publicKey: device.publicKey, status: device.status, diff --git a/services/api/test/features/iam/prisma-device-identity-repository.test.ts b/services/api/test/features/iam/prisma-device-identity-repository.test.ts index 475a3901..69761223 100644 --- a/services/api/test/features/iam/prisma-device-identity-repository.test.ts +++ b/services/api/test/features/iam/prisma-device-identity-repository.test.ts @@ -82,15 +82,23 @@ function device(): DeviceIdentityV1 { return result.value; } -function delegate(rows: Record[], forceRevisionConflict = false) { +function delegate( + rows: Record[], + forceRevisionConflict = false, + firstQueries?: Array>>, +) { return { create({ data }: { readonly data: Record }) { const persisted = { ...data }; rows.push(persisted); return Promise.resolve(persisted); }, - findUnique({ where }: { readonly where: { readonly id: string } }) { - return Promise.resolve(rows.find((row) => row['id'] === where.id) ?? null); + findFirst({ where }: { readonly where: Readonly> }) { + firstQueries?.push(where); + return Promise.resolve( + rows.find((row) => Object.entries(where).every(([key, value]) => row[key] === value)) ?? + null, + ); }, findMany({ where }: { readonly where: Readonly> }) { return Promise.resolve( @@ -113,12 +121,12 @@ function delegate(rows: Record[], forceRevisionConflict = false where, data, }: { - readonly where: { readonly id: string; readonly revision: number }; + readonly where: Readonly>; readonly data: Record; }) { if (forceRevisionConflict) return Promise.resolve({ count: 0 }); - const index = rows.findIndex( - (row) => row['id'] === where.id && row['revision'] === where.revision, + const index = rows.findIndex((row) => + Object.entries(where).every(([key, value]) => row[key] === value), ); if (index < 0) return Promise.resolve({ count: 0 }); rows[index] = { ...rows[index], ...data }; @@ -128,13 +136,20 @@ function delegate(rows: Record[], forceRevisionConflict = false } function client( - options: { readonly forceRevisionConflict?: boolean } = {}, + options: { + readonly forceRevisionConflict?: boolean; + readonly firstQueries?: Array>>; + } = {}, ): DeviceIdentityDatabaseClientV1 { const challengeRows: Record[] = []; const deviceRows: Record[] = []; const database = { - deviceEnrollmentChallenge: delegate(challengeRows, options.forceRevisionConflict), - deviceIdentity: delegate(deviceRows, options.forceRevisionConflict), + deviceEnrollmentChallenge: delegate( + challengeRows, + options.forceRevisionConflict, + options.firstQueries, + ), + deviceIdentity: delegate(deviceRows, options.forceRevisionConflict, options.firstQueries), async $transaction( work: (transaction: DeviceIdentityDatabaseClientV1) => Promise, ) { @@ -197,3 +212,15 @@ void test('[IAM-007, IAM-021] Prisma device identity transitions reject database }; await assert.rejects(repository.replaceDevice(context(), active, 1), /REVISION_CONFLICT/u); }); + +void test('[IAM-009, IAM-019] Prisma device lookups bind identifiers to organization scope', async () => { + const firstQueries: Array>> = []; + const repository = new PrismaDeviceIdentityRepositoryAdapter(client({ firstQueries })); + await repository.saveChallenge(context(), challenge()); + await repository.saveDevice(context(), device()); + await repository.findChallenge(context(), stable(challengeId)); + await repository.findDevice(context(), stable(deviceId)); + + assert.equal(firstQueries.length, 4); + for (const query of firstQueries) assert.equal(query['organizationId'], organizationId); +}); From ead1d273f89b1869559a1585e0129a1805622e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:03:19 +0700 Subject: [PATCH 26/59] fix(aud): map read outages to unavailable --- .../src/features/aud/api/audit.controller.ts | 22 +++++++++-- .../aud/application/audit-problem.error.ts | 6 +++ .../platform/http/problem-details.filter.ts | 10 +++++ services/api/test/http-contract.test.ts | 38 +++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 services/api/src/features/aud/application/audit-problem.error.ts diff --git a/services/api/src/features/aud/api/audit.controller.ts b/services/api/src/features/aud/api/audit.controller.ts index 75c6b9bb..43b6269f 100644 --- a/services/api/src/features/aud/api/audit.controller.ts +++ b/services/api/src/features/aud/api/audit.controller.ts @@ -1,5 +1,10 @@ import { Controller, Get, Inject, Req } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; import { AUDIT_REPOSITORY_PORT, @@ -9,6 +14,7 @@ import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; +import { AuditProblemError } from '../application/audit-problem.error.js'; @ApiTags('audit') @ApiBearerAuth() @@ -21,15 +27,25 @@ export class AuditController { @Get('events') @ApiOperation({ summary: 'List immutable audit events visible to the caller' }) + @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' }) async events(@Req() request: unknown): Promise { const context = await this.requestContext.resolve(request); - return this.repository.listEvents(context); + try { + return await this.repository.listEvents(context); + } catch { + throw new AuditProblemError('AUDIT_UNAVAILABLE'); + } } @Get('seals') @ApiOperation({ summary: 'List verified audit seals visible to the caller' }) + @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' }) async seals(@Req() request: unknown): Promise { const context = await this.requestContext.resolve(request); - return this.repository.listSeals(context); + try { + return await this.repository.listSeals(context); + } catch { + throw new AuditProblemError('AUDIT_UNAVAILABLE'); + } } } diff --git a/services/api/src/features/aud/application/audit-problem.error.ts b/services/api/src/features/aud/application/audit-problem.error.ts new file mode 100644 index 00000000..3dc235a8 --- /dev/null +++ b/services/api/src/features/aud/application/audit-problem.error.ts @@ -0,0 +1,6 @@ +export class AuditProblemError extends Error { + public constructor(readonly code: 'AUDIT_UNAVAILABLE') { + super(code); + this.name = 'AuditProblemError'; + } +} diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index d4f77a47..41c82576 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -12,6 +12,7 @@ import { SessionProblemError } from '../../features/iam/application/session-prob import { MfaProblemError } from '../../features/iam/application/mfa-problem.error.js'; import { EntitlementProblemError } from '../../features/bua/application/entitlement-problem.error.js'; import { DeviceIdentityProblemError } from '../../features/iam/application/device-identity-problem.error.js'; +import { AuditProblemError } from '../../features/aud/application/audit-problem.error.js'; import { RequestTenantContextProblemError } from './session-tenant-context.adapter.js'; import { NotReadyError } from '../../features/system/application/not-ready.error.js'; import { InputValidationException } from './input-validation.exception.js'; @@ -95,6 +96,15 @@ function describe(error: unknown, correlationId: string): ProblemInput { status, }; } + if (error instanceof AuditProblemError) { + return { + code: error.code, + correlationId, + messageKey: 'api.error.audit_unavailable', + retryable: true, + status: HttpStatus.SERVICE_UNAVAILABLE, + }; + } if (error instanceof RequestTenantContextProblemError) { const invalidContext = error.code === 'CONTEXT_INVALID'; const unavailable = error.code === 'AUTHENTICATION_UNAVAILABLE'; diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index b1c75488..91bd14cd 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -9,6 +9,7 @@ import { createApiApplication } from '../src/bootstrap.js'; import { createIamTenantContextV1 } from '../src/features/iam/application/tenant-context.js'; import { InMemoryMfaRepositoryAdapter } from '../src/features/iam/adapter/in-memory-mfa-repository.adapter.js'; import { MfaService } from '../src/features/iam/application/mfa.service.js'; +import { InMemoryAuditRepositoryAdapter } from '../src/features/aud/adapter/in-memory-audit-repository.adapter.js'; interface InjectResponse { readonly body: string; @@ -677,6 +678,43 @@ void test('protected artifact reads derive tenant scope from an authenticated ac ); }); +void test('audit read outages return retryable service-unavailable problems', async () => { + const auditRepository = Object.assign(new InMemoryAuditRepositoryAdapter(), { + listEvents: () => Promise.reject(new Error(`database ${leakedMarker}`)), + listSeals: () => Promise.reject(new Error(`database ${leakedMarker}`)), + }); + const principal = { + userId: '00000000-0000-4000-8000-000000000001', + organizationId: '00000000-0000-4000-8000-000000000002', + workspaceId: '00000000-0000-4000-8000-000000000003', + securityEpoch: 1, + mfaRequired: false, + } as const; + await withApp( + { + auditRepository, + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(principal), + findPrincipalByAccessToken: () => Promise.resolve(principal), + }, + }, + async (app) => { + for (const url of ['/v1/audit/events', '/v1/audit/seals']) { + const response = await app.inject({ + method: 'GET', + url, + headers: { authorization: 'Bearer audit-access-token-123456789' }, + }); + assertProblem(response, 503, 'AUDIT_UNAVAILABLE'); + assert.doesNotMatch(response.body, new RegExp(leakedMarker)); + } + }, + ); +}); + void test('MFA HTTP lifecycle derives the user from the authenticated tenant context and returns redacted state', async () => { const actorId = '00000000-0000-4000-8000-000000000001'; const mfaService = new MfaService(new InMemoryMfaRepositoryAdapter(), { From ab1e2a6baeed7f1f375a20bddcd6eda25953e98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:04:11 +0700 Subject: [PATCH 27/59] fix(aud): scope event identity lookup --- .../prisma-audit-repository.adapter.ts | 10 +++--- .../aud/prisma-audit-repository.test.ts | 35 +++++++++++++++---- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts index 1eb63f71..5a717d10 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -73,9 +73,6 @@ interface AuditSealCreateDataV1 extends Omit; - findUnique(input: { - readonly where: { readonly id: string }; - }): Promise; findFirst(input: { readonly where: Readonly>; readonly orderBy?: { readonly sequence: 'asc' | 'desc' }; @@ -284,8 +281,11 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { ): Promise { if (!tenantScopeContainsV1(context.tenantScope, event.tenantScope)) throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); - const existing = await this.client.auditEventRecord.findUnique({ - where: { id: event.eventId }, + const existing = await this.client.auditEventRecord.findFirst({ + where: { + id: event.eventId, + organizationId: context.tenantScope.organizationId, + }, }); if (existing !== null) { const current = persistedEvent(existing); diff --git a/services/api/test/features/aud/prisma-audit-repository.test.ts b/services/api/test/features/aud/prisma-audit-repository.test.ts index 40d3006e..273ebad2 100644 --- a/services/api/test/features/aud/prisma-audit-repository.test.ts +++ b/services/api/test/features/aud/prisma-audit-repository.test.ts @@ -29,16 +29,16 @@ function context(workspace = workspaceId, idempotencyKey = 'audit') { return result.value; } -function delegate>(rows: TRow[]) { +function delegate>( + rows: TRow[], + firstQueries: Array>>, +) { return { create({ data }: { readonly data: TRow }) { const persisted = { ...data }; rows.push(persisted); return Promise.resolve(persisted); }, - findUnique({ where }: { readonly where: { readonly id: string } }) { - return Promise.resolve(rows.find((row) => row['id'] === where.id) ?? null); - }, findFirst({ where, orderBy, @@ -46,6 +46,7 @@ function delegate>(rows: TRow[]) { readonly where: Readonly>; readonly orderBy?: Readonly>; }) { + firstQueries.push(where); const matching = rows.filter((row) => Object.entries(where).every(([key, value]) => row[key] === value), ); @@ -84,12 +85,14 @@ function delegate>(rows: TRow[]) { }; } -function client(): AuditDatabaseClientV1 { +function client( + firstQueries: Array>> = [], +): AuditDatabaseClientV1 { const eventRows: Record[] = []; const sealRows: Record[] = []; const database = { - auditEventRecord: delegate(eventRows), - auditSealRecord: delegate(sealRows), + auditEventRecord: delegate(eventRows, firstQueries), + auditSealRecord: delegate(sealRows, firstQueries), async $transaction( work: (transaction: AuditDatabaseClientV1) => Promise, ): Promise { @@ -180,3 +183,21 @@ void test('[AUD-002] Prisma audit transactions do not retain an event when the u ); assert.equal((await repository.listEvents(context(workspaceId, 'after'))).length, 0); }); + +void test('[AUD-003, IAM-009] Prisma audit event identity checks include tenant scope', async () => { + const firstQueries: Array>> = []; + const repository = new PrismaAuditRepositoryAdapter(client(firstQueries), digest); + const service = new AuditLedgerService(repository, digest); + const eventId = '00000000-0000-4000-8000-000000000125'; + + const appended = await service.append( + context(workspaceId, 'event-scoped'), + input(eventId, 'job.started'), + ); + assert.equal(appended.accepted, true); + assert.ok( + firstQueries.some( + (query) => query['id'] === eventId && query['organizationId'] === organizationId, + ), + ); +}); From e86f7ad1b8927038f2b74b01085f6a6f6dcd1f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:05:21 +0700 Subject: [PATCH 28/59] fix(bua): scope entitlement identity lookups --- .../prisma-entitlement-repository.adapter.ts | 46 ++++++++++++---- .../bua/prisma-entitlement-repository.test.ts | 53 ++++++++++++++++--- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts index b61f13f3..4fa88a19 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -120,6 +120,7 @@ interface DelegateV1 { findUnique(input: { readonly where: { readonly id?: string; readonly planCode?: string }; }): Promise; + findFirst(input: { readonly where: Readonly> }): Promise; findMany(input: { readonly where: Readonly>; readonly orderBy?: Readonly>; @@ -129,7 +130,7 @@ interface DelegateV1 { readonly data: Readonly>; }): Promise; updateMany?(input: { - readonly where: { readonly id: string; readonly revision: number }; + readonly where: Readonly>; readonly data: Readonly>; }): Promise<{ readonly count: number }>; } @@ -487,8 +488,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV : { scopeType: 'organization' as const, organizationId: snapshot.organizationId }; if (!tenantScopeContainsV1(context.tenantScope, scope)) throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); - const existing = await this.client.entitlementSnapshotRecord.findUnique({ - where: { id: snapshot.snapshotId }, + const existing = await this.client.entitlementSnapshotRecord.findFirst({ + where: { + id: snapshot.snapshotId, + organizationId: snapshot.organizationId, + scopeKey: scopeKey(scope), + }, }); if (existing !== null) { if (!sameEntitlementSnapshotV1(persistedSnapshot(existing), snapshot)) @@ -502,8 +507,16 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV context: IamTenantContextV1, snapshotId: EntitlementSnapshotV1['snapshotId'], ): Promise { - const row = await this.client.entitlementSnapshotRecord.findUnique({ - where: { id: snapshotId }, + const workspaceId = + context.tenantScope.scopeType === 'organization' + ? undefined + : context.tenantScope.workspaceId; + const row = await this.client.entitlementSnapshotRecord.findFirst({ + where: { + id: snapshotId, + organizationId: context.tenantScope.organizationId, + ...(workspaceId === undefined ? {} : { OR: [{ workspaceId: null }, { workspaceId }] }), + }, }); if (row === null) return undefined; const snapshot = persistedSnapshot(row); @@ -568,8 +581,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV for (const entry of state.entries) { if (!tenantScopeContainsV1(context.tenantScope, entry.tenantScope)) throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); - const existing = await this.client.usageLedgerEntryRecord.findUnique({ - where: { id: entry.entryId }, + const existing = await this.client.usageLedgerEntryRecord.findFirst({ + where: { + id: entry.entryId, + organizationId: entry.tenantScope.organizationId, + scopeKey: scopeKey(entry.tenantScope), + }, }); if (existing !== null) { if (!sameUsageEntryV1(persistedEntry(existing), entry)) @@ -581,8 +598,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV for (const reservation of state.reservations) { if (!tenantScopeContainsV1(context.tenantScope, reservation.tenantScope)) throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); - const existing = await this.client.usageReservationRecord.findUnique({ - where: { id: reservation.reservationId }, + const existing = await this.client.usageReservationRecord.findFirst({ + where: { + id: reservation.reservationId, + organizationId: reservation.tenantScope.organizationId, + scopeKey: scopeKey(reservation.tenantScope), + }, }); if (existing === null) { await this.client.usageReservationRecord.create({ @@ -600,7 +621,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV throw new Error('BUA_RESERVATION_CONFLICT'); if (!this.client.usageReservationRecord.updateMany) throw new Error('BUA_UPDATE_UNAVAILABLE'); const result = await this.client.usageReservationRecord.updateMany({ - where: { id: reservation.reservationId, revision: current.revision }, + where: { + id: reservation.reservationId, + organizationId: reservation.tenantScope.organizationId, + scopeKey: scopeKey(reservation.tenantScope), + revision: current.revision, + }, data: { status: reservation.status, revision: reservation.revision, updatedAt: new Date() }, }); if (result.count !== 1) throw new Error('BUA_RESERVATION_CONFLICT'); diff --git a/services/api/test/features/bua/prisma-entitlement-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-repository.test.ts index c13d7eb1..7f689d58 100644 --- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -75,7 +75,20 @@ function snapshot(): EntitlementSnapshotV1 { function delegate>( rows: TRow[], forceRevisionConflict = false, + firstQueries?: Array>>, ) { + const matches = (row: TRow, where: Readonly>): boolean => + Object.entries(where).every(([key, value]) => { + if (key === 'OR' && Array.isArray(value)) { + return value.some( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + matches(row, candidate as Readonly>), + ); + } + return row[key] === value; + }); return { create({ data }: { readonly data: TRow }) { const persisted = { ...data }; @@ -92,6 +105,10 @@ function delegate>( rows.find((row) => row['id'] === key || row['planCode'] === key) ?? null, ); }, + findFirst({ where }: { readonly where: Readonly> }) { + firstQueries?.push(where); + return Promise.resolve(rows.find((row) => matches(row, where)) ?? null); + }, findMany({ where, orderBy, @@ -132,9 +149,7 @@ function delegate>( readonly data: Record; }) { if (forceRevisionConflict) return Promise.resolve({ count: 0 }); - const index = rows.findIndex( - (row) => row['id'] === where.id && row['revision'] === where.revision, - ); + const index = rows.findIndex((row) => matches(row, where)); if (index < 0) return Promise.resolve({ count: 0 }); rows[index] = { ...rows[index], ...data } as TRow; return Promise.resolve({ count: 1 }); @@ -143,7 +158,10 @@ function delegate>( } function client( - options: { readonly forceRevisionConflict?: boolean } = {}, + options: { + readonly forceRevisionConflict?: boolean; + readonly firstQueries?: Array>>; + } = {}, ): EntitlementDatabaseClientV1 { const planRows: Record[] = []; const snapshotRows: Record[] = []; @@ -151,9 +169,13 @@ function client( const reservationRows: Record[] = []; const database = { entitlementPlanRecord: delegate(planRows), - entitlementSnapshotRecord: delegate(snapshotRows), - usageLedgerEntryRecord: delegate(entryRows), - usageReservationRecord: delegate(reservationRows, options.forceRevisionConflict), + entitlementSnapshotRecord: delegate(snapshotRows, false, options.firstQueries), + usageLedgerEntryRecord: delegate(entryRows, false, options.firstQueries), + usageReservationRecord: delegate( + reservationRows, + options.forceRevisionConflict, + options.firstQueries, + ), async $transaction( work: (transaction: EntitlementDatabaseClientV1) => Promise, ): Promise { @@ -269,3 +291,20 @@ void test('[BUA-012] Prisma entitlement adapter rejects a reservation settlement /BUA_RESERVATION_CONFLICT/u, ); }); + +void test('[BUA-003, BUA-004, IAM-009] Prisma entitlement identity lookups include tenant scope', async () => { + const firstQueries: Array>> = []; + const repository = new PrismaEntitlementRepositoryAdapter(client({ firstQueries })); + await repository.saveSnapshot(context(workspaceId, 'scope-snapshot'), snapshot()); + await repository.findSnapshot(context(workspaceId, 'scope-read'), snapshot().snapshotId); + const service = new EntitlementAdmissionService(repository); + const admitted = await service.admit( + context(workspaceId, 'scope-admit'), + admissionInput('scope-admit', '1'), + ); + assert.equal(admitted.accepted, true); + + const tenantQueries = firstQueries.filter((query) => query['id'] !== undefined); + assert.ok(tenantQueries.length >= 4); + for (const query of tenantQueries) assert.equal(query['organizationId'], organizationId); +}); From 784fa32604ccb942c6b7ab9e8653744419ae7b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:07:10 +0700 Subject: [PATCH 29/59] fix(iam): propagate session authority outages --- .../prisma-session-lifecycle.adapter.ts | 120 ++++++++---------- .../iam/prisma-session-lifecycle.test.ts | 26 ++++ 2 files changed, 82 insertions(+), 64 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts index e5de3a6c..cfff05b2 100644 --- a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -444,16 +444,12 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { accessTokenInput: unknown, ): Promise { if (typeof accessTokenInput !== 'string' || accessTokenInput.length < 80) return undefined; - try { - const row = await this.client.accessTokenRecord.findUnique({ - where: { tokenDigest: digestToken(accessTokenInput) }, - }); - if (!row || row.status !== 'ACTIVE' || row.expiresAt.getTime() <= this.clock().getTime()) - return undefined; - return this.findPrincipal(row.sessionId); - } catch { + const row = await this.client.accessTokenRecord.findUnique({ + where: { tokenDigest: digestToken(accessTokenInput) }, + }); + if (!row || row.status !== 'ACTIVE' || row.expiresAt.getTime() <= this.clock().getTime()) return undefined; - } + return this.findPrincipal(row.sessionId); } public async findPrincipal( @@ -462,62 +458,58 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { if (typeof sessionIdInput !== 'string') return undefined; const parsed = parseStableIdentifierV1(sessionIdInput); if (!parsed.accepted) return undefined; - try { - const sessionRow = await this.client.sessionRecord.findUnique({ - where: { id: parsed.value }, - }); - if (!sessionRow) return undefined; - const session = sessionFromRow(sessionRow); - const now = Date.parse(this.clock().toISOString()); - if ( - session.status !== 'ACTIVE' || - now >= Date.parse(session.inactivityExpiresAt) || - now >= Date.parse(session.absoluteExpiresAt) - ) - return undefined; - const user = await this.client.userIdentity.findUnique({ where: { id: session.userId } }); - if (!user || user.status !== 'ACTIVE' || user.id !== session.userId) return undefined; - if (!Number.isSafeInteger(user.securityEpoch) || user.securityEpoch < 1) return undefined; - const memberships = await this.client.membershipIdentity.findMany({ - where: { principalId: session.userId, status: 'ACTIVE' }, - }); - const membership = memberships.find( - (candidate) => - candidate.principalId === session.userId && - candidate.scopeType === 'WORKSPACE' && - candidate.projectId === null && - parseStableIdentifierV1(candidate.organizationId).accepted && - parseStableIdentifierV1(candidate.workspaceId).accepted, - ); - if (!membership || !membership.workspaceId) return undefined; - const organizationId = parseStableIdentifierV1(membership.organizationId); - const workspaceId = parseStableIdentifierV1(membership.workspaceId); - if (!organizationId.accepted || !workspaceId.accepted) return undefined; - const [organization, workspace, factors] = await Promise.all([ - this.client.organizationIdentity.findUnique({ where: { id: organizationId.value } }), - this.client.workspaceIdentity.findUnique({ where: { id: workspaceId.value } }), - this.client.mfaFactor.findMany({ where: { userId: session.userId, status: 'ACTIVE' } }), - ]); - if ( - !organization || - organization.id !== organizationId.value || - organization.status !== 'ACTIVE' || - !workspace || - workspace.id !== workspaceId.value || - workspace.organizationId !== organizationId.value || - workspace.status !== 'ACTIVE' - ) - return undefined; - return Object.freeze({ - userId: session.userId, - organizationId: organizationId.value, - workspaceId: workspaceId.value, - securityEpoch: user.securityEpoch, - mfaRequired: factors.length > 0, - }); - } catch { + const sessionRow = await this.client.sessionRecord.findUnique({ + where: { id: parsed.value }, + }); + if (!sessionRow) return undefined; + const session = sessionFromRow(sessionRow); + const now = Date.parse(this.clock().toISOString()); + if ( + session.status !== 'ACTIVE' || + now >= Date.parse(session.inactivityExpiresAt) || + now >= Date.parse(session.absoluteExpiresAt) + ) return undefined; - } + const user = await this.client.userIdentity.findUnique({ where: { id: session.userId } }); + if (!user || user.status !== 'ACTIVE' || user.id !== session.userId) return undefined; + if (!Number.isSafeInteger(user.securityEpoch) || user.securityEpoch < 1) return undefined; + const memberships = await this.client.membershipIdentity.findMany({ + where: { principalId: session.userId, status: 'ACTIVE' }, + }); + const membership = memberships.find( + (candidate) => + candidate.principalId === session.userId && + candidate.scopeType === 'WORKSPACE' && + candidate.projectId === null && + parseStableIdentifierV1(candidate.organizationId).accepted && + parseStableIdentifierV1(candidate.workspaceId).accepted, + ); + if (!membership || !membership.workspaceId) return undefined; + const organizationId = parseStableIdentifierV1(membership.organizationId); + const workspaceId = parseStableIdentifierV1(membership.workspaceId); + if (!organizationId.accepted || !workspaceId.accepted) return undefined; + const [organization, workspace, factors] = await Promise.all([ + this.client.organizationIdentity.findUnique({ where: { id: organizationId.value } }), + this.client.workspaceIdentity.findUnique({ where: { id: workspaceId.value } }), + this.client.mfaFactor.findMany({ where: { userId: session.userId, status: 'ACTIVE' } }), + ]); + if ( + !organization || + organization.id !== organizationId.value || + organization.status !== 'ACTIVE' || + !workspace || + workspace.id !== workspaceId.value || + workspace.organizationId !== organizationId.value || + workspace.status !== 'ACTIVE' + ) + return undefined; + return Object.freeze({ + userId: session.userId, + organizationId: organizationId.value, + workspaceId: workspaceId.value, + securityEpoch: user.securityEpoch, + mfaRequired: factors.length > 0, + }); } } diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index f4eee384..7e71e0b8 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -211,3 +211,29 @@ void test('[IAM-005] revocation is idempotent and hides session principals after assert.equal(await adapter.findPrincipal(session.sessionId), undefined); assert.equal(await adapter.findPrincipalByAccessToken(session.accessToken), undefined); }); + +void test('[IAM-005] session authority database failures propagate to the HTTP availability boundary', async () => { + const accessDatabase = createDatabase(); + const accessAdapter = new PrismaSessionLifecycleAdapter(accessDatabase.client); + const accessSession = await accessAdapter.issue(principal, 'web'); + const accessDelegate = accessDatabase.client.accessTokenRecord as unknown as { + findUnique(input: unknown): Promise; + }; + accessDelegate.findUnique = () => Promise.reject(new Error('access database unavailable')); + await assert.rejects( + accessAdapter.findPrincipalByAccessToken(accessSession.accessToken), + /access database unavailable/u, + ); + + const sessionDatabase = createDatabase(); + const sessionAdapter = new PrismaSessionLifecycleAdapter(sessionDatabase.client); + const session = await sessionAdapter.issue(principal, 'desktop'); + const sessionDelegate = sessionDatabase.client.sessionRecord as unknown as { + findUnique(input: unknown): Promise; + }; + sessionDelegate.findUnique = () => Promise.reject(new Error('session database unavailable')); + await assert.rejects( + sessionAdapter.findPrincipal(session.sessionId), + /session database unavailable/u, + ); +}); From 7b628f21305ee8b43f94d9f55eb39bb224e4ecdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:10:10 +0700 Subject: [PATCH 30/59] fix(iam): bind sessions to tenant scope --- packages/domain/src/identity/v1.ts | 16 ++++++- packages/domain/test/identity-v1.test.mjs | 2 + .../migration.sql | 9 ++++ services/api/prisma/schema/iam.prisma | 3 ++ .../in-memory-session-lifecycle.adapter.ts | 2 + .../prisma-session-lifecycle.adapter.ts | 26 +++++++--- .../iam/prisma-session-lifecycle.test.ts | 47 ++++++++++++++----- 7 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 services/api/prisma/migrations/20260803010000_iam_session_scope_binding/migration.sql diff --git a/packages/domain/src/identity/v1.ts b/packages/domain/src/identity/v1.ts index 17fa79b9..f45324f3 100644 --- a/packages/domain/src/identity/v1.ts +++ b/packages/domain/src/identity/v1.ts @@ -95,6 +95,8 @@ export interface SessionRecordV1 { readonly schemaVersion: typeof IDENTITY_SCHEMA_VERSION_V1; readonly sessionId: StableIdentifierV1; readonly userId: StableIdentifierV1; + readonly organizationId: StableIdentifierV1; + readonly workspaceId: StableIdentifierV1; readonly familyId: StableIdentifierV1; readonly issuedAt: StrictUtcTimestampV1; readonly accessExpiresAt: StrictUtcTimestampV1; @@ -420,13 +422,21 @@ export function checkOwnerRemovalV1( export function createSessionRecordV1(input: { readonly sessionId: unknown; readonly userId: unknown; + readonly organizationId: unknown; + readonly workspaceId: unknown; readonly familyId: unknown; readonly issuedAt: unknown; readonly accessExpiresAt: unknown; readonly inactivityExpiresAt: unknown; readonly absoluteExpiresAt: unknown; }): IdentityResultV1 { - const ids = [stableId(input.sessionId), stableId(input.userId), stableId(input.familyId)]; + const ids = [ + stableId(input.sessionId), + stableId(input.userId), + stableId(input.organizationId), + stableId(input.workspaceId), + stableId(input.familyId), + ]; const times = [ timestamp(input.issuedAt), timestamp(input.accessExpiresAt), @@ -451,7 +461,9 @@ export function createSessionRecordV1(input: { schemaVersion: 1, sessionId: ids[0] as StableIdentifierV1, userId: ids[1] as StableIdentifierV1, - familyId: ids[2] as StableIdentifierV1, + organizationId: ids[2] as StableIdentifierV1, + workspaceId: ids[3] as StableIdentifierV1, + familyId: ids[4] as StableIdentifierV1, issuedAt, accessExpiresAt, inactivityExpiresAt, diff --git a/packages/domain/test/identity-v1.test.mjs b/packages/domain/test/identity-v1.test.mjs index 1c31128b..a539a164 100644 --- a/packages/domain/test/identity-v1.test.mjs +++ b/packages/domain/test/identity-v1.test.mjs @@ -123,6 +123,8 @@ test('[IAM-005, IAM-012] session access lifetime and fresh step-up are bounded', const session = createSessionRecordV1({ sessionId: id('30'), userId: id('1'), + organizationId: id('2'), + workspaceId: id('3'), familyId: id('31'), issuedAt: createdAt, accessExpiresAt: '2026-01-01T00:15:00.000Z', diff --git a/services/api/prisma/migrations/20260803010000_iam_session_scope_binding/migration.sql b/services/api/prisma/migrations/20260803010000_iam_session_scope_binding/migration.sql new file mode 100644 index 00000000..2002ffd2 --- /dev/null +++ b/services/api/prisma/migrations/20260803010000_iam_session_scope_binding/migration.sql @@ -0,0 +1,9 @@ +-- IAM-005/IAM-019: bind every new session to the exact tenant ancestry selected at sign-in. +-- This repository has no production or legacy data migration. Existing development databases +-- with active sessions must be recreated because guessing tenant scope would be unsafe. +ALTER TABLE "iam"."sessions" + ADD COLUMN "organization_id" UUID NOT NULL, + ADD COLUMN "workspace_id" UUID NOT NULL; + +CREATE INDEX "sessions_scope_user_status_idx" + ON "iam"."sessions"("organization_id", "workspace_id", "user_id", "status"); diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index 0dd1e5b9..b75cc8db 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -92,6 +92,8 @@ model MembershipIdentity { model SessionRecord { id String @id @db.Uuid userId String @map("user_id") @db.Uuid + organizationId String @map("organization_id") @db.Uuid + workspaceId String @map("workspace_id") @db.Uuid familyId String @map("family_id") @db.Uuid issuedAt DateTime @map("issued_at") @db.Timestamptz(6) accessExpiresAt DateTime @map("access_expires_at") @db.Timestamptz(6) @@ -102,6 +104,7 @@ model SessionRecord { revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) @@index([userId, status], map: "sessions_user_status_idx") + @@index([organizationId, workspaceId, userId, status], map: "sessions_scope_user_status_idx") @@index([familyId], map: "sessions_family_idx") @@map("sessions") @@schema("iam") diff --git a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts index 1030359a..37cad50a 100644 --- a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts @@ -92,6 +92,8 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { const created = createSessionRecordV1({ sessionId: sessionIdentifier, userId: principal.userId, + organizationId: principal.organizationId, + workspaceId: principal.workspaceId, familyId: familyIdentifier, issuedAt: now.toISOString(), accessExpiresAt: addSeconds(now, ACCESS_TOKEN_SECONDS_V1), diff --git a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts index cfff05b2..468cc9e1 100644 --- a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -26,6 +26,8 @@ import type { export interface SessionRecordDatabaseRowV1 { readonly id: string; readonly userId: string; + readonly organizationId: string; + readonly workspaceId: string; readonly familyId: string; readonly issuedAt: Date; readonly accessExpiresAt: Date; @@ -183,6 +185,8 @@ function sessionFromRow(row: SessionRecordDatabaseRowV1): SessionRecordV1 { const created = createSessionRecordV1({ sessionId: row.id, userId: row.userId, + organizationId: row.organizationId, + workspaceId: row.workspaceId, familyId: row.familyId, issuedAt: timestamp(row.issuedAt), accessExpiresAt: timestamp(row.accessExpiresAt), @@ -245,6 +249,8 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { const created = createSessionRecordV1({ sessionId, userId: principal.userId, + organizationId: principal.organizationId, + workspaceId: principal.workspaceId, familyId, issuedAt: now.toISOString(), accessExpiresAt: addSeconds(now, ACCESS_TOKEN_SECONDS_V1), @@ -261,6 +267,8 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { data: { id: record.sessionId, userId: record.userId, + organizationId: record.organizationId, + workspaceId: record.workspaceId, familyId: record.familyId, issuedAt: new Date(record.issuedAt), accessExpiresAt: new Date(record.accessExpiresAt), @@ -474,19 +482,23 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { if (!user || user.status !== 'ACTIVE' || user.id !== session.userId) return undefined; if (!Number.isSafeInteger(user.securityEpoch) || user.securityEpoch < 1) return undefined; const memberships = await this.client.membershipIdentity.findMany({ - where: { principalId: session.userId, status: 'ACTIVE' }, + where: { + principalId: session.userId, + organizationId: session.organizationId, + status: 'ACTIVE', + }, }); const membership = memberships.find( (candidate) => candidate.principalId === session.userId && - candidate.scopeType === 'WORKSPACE' && + candidate.organizationId === session.organizationId && candidate.projectId === null && - parseStableIdentifierV1(candidate.organizationId).accepted && - parseStableIdentifierV1(candidate.workspaceId).accepted, + ((candidate.scopeType === 'ORGANIZATION' && candidate.workspaceId === null) || + (candidate.scopeType === 'WORKSPACE' && candidate.workspaceId === session.workspaceId)), ); - if (!membership || !membership.workspaceId) return undefined; - const organizationId = parseStableIdentifierV1(membership.organizationId); - const workspaceId = parseStableIdentifierV1(membership.workspaceId); + if (!membership) return undefined; + const organizationId = parseStableIdentifierV1(session.organizationId); + const workspaceId = parseStableIdentifierV1(session.workspaceId); if (!organizationId.accepted || !workspaceId.accepted) return undefined; const [organization, workspace, factors] = await Promise.all([ this.client.organizationIdentity.findUnique({ where: { id: organizationId.value } }), diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index 7e71e0b8..42b5f762 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -26,10 +26,23 @@ function createDatabase(): { readonly sessions: Map; readonly refreshTokens: Map; readonly accessTokens: Map; + readonly membershipQueries: ReadonlyArray>>; } { const sessions = new Map(); const refreshTokens = new Map(); const accessTokens = new Map(); + const membershipQueries: Array>> = []; + const membershipRows = [ + { + id: '00000000-0000-4000-8000-000000000004', + principalId: userId, + organizationId, + workspaceId, + projectId: null, + scopeType: 'WORKSPACE', + status: 'ACTIVE', + }, + ]; const client = { sessionRecord: { create: async ({ data }: { readonly data: SessionRecordDatabaseRowV1 }) => { @@ -118,17 +131,12 @@ function createDatabase(): { findUnique: async () => ({ id: userId, status: 'ACTIVE', securityEpoch: 4 }), }, membershipIdentity: { - findMany: async () => [ - { - id: '00000000-0000-4000-8000-000000000004', - principalId: userId, - organizationId, - workspaceId, - projectId: null, - scopeType: 'WORKSPACE', - status: 'ACTIVE', - }, - ], + findMany: async ({ where }: { readonly where: Readonly> }) => { + membershipQueries.push(where); + return membershipRows.filter((row) => + Object.entries(where).every(([key, value]) => row[key as keyof typeof row] === value), + ); + }, }, workspaceIdentity: { findUnique: async () => ({ id: workspaceId, organizationId, status: 'ACTIVE' }), @@ -143,7 +151,7 @@ function createDatabase(): { work: (transaction: SessionLifecycleDatabaseClientV1) => Promise, ) => work(client), } as unknown as SessionLifecycleDatabaseClientV1; - return { client, sessions, refreshTokens, accessTokens }; + return { client, sessions, refreshTokens, accessTokens, membershipQueries }; } void test('[IAM-005, IAM-006] Prisma sessions persist opaque bounded access and refresh credentials', async () => { @@ -237,3 +245,18 @@ void test('[IAM-005] session authority database failures propagate to the HTTP a /session database unavailable/u, ); }); + +void test('[IAM-005, IAM-019] persisted sessions retain the exact sign-in tenant scope', async () => { + const { client, sessions, membershipQueries } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client); + const session = await adapter.issue(principal, 'web'); + + assert.equal(sessions.get(session.sessionId)?.organizationId, organizationId); + assert.equal(sessions.get(session.sessionId)?.workspaceId, workspaceId); + assert.equal((await adapter.findPrincipal(session.sessionId))?.workspaceId, workspaceId); + assert.deepEqual(membershipQueries.at(-1), { + principalId: userId, + organizationId, + status: 'ACTIVE', + }); +}); From 12b8c3a0564c8e2c4e41049924054aba36b5a3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:15:46 +0700 Subject: [PATCH 31/59] docs(traceability): reconcile identity foundation coverage --- ...t-entitlement-reconciliation-2026-08-03.md | 29 + docs/plans/requirement-traceability.json | 1638 +++++++---------- 2 files changed, 710 insertions(+), 957 deletions(-) create mode 100644 docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md diff --git a/docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md b/docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md new file mode 100644 index 00000000..f5b99d78 --- /dev/null +++ b/docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md @@ -0,0 +1,29 @@ +# Identity, audit, and entitlement reconciliation — 2026-08-03 + +## Scope + +This evidence record covers the 30-commit `feat/foundation-identity-reconciliation` batch based on `dev`. The batch hardens existing IAM, AUD, and BUA foundations; it does not claim completion of Plan 020 or any production release gate. + +## Implemented in this batch + +- IAM persistence now compares only owned immutable fields, scopes membership and Device lookups before row materialization, applies optimistic revisions to MFA state, binds sessions to the exact sign-in organization/workspace, expires access credentials, separates rejected credentials from authority outages, authorizes sign-out ownership, and bounds cookie/CSRF parsing. +- Membership authority flows only from a containing tenant scope, and the narrowest applicable membership wins. A project or workspace membership cannot authorize its parent or a sibling. +- AUD append paths use scoped, bounded replay/latest lookups; immutable comparisons ignore persistence metadata; read outages return safe retryable `AUDIT_UNAVAILABLE` problems. +- BUA tenant-owned identity lookups and usage reads are scope-bound; immutable comparisons ignore persistence metadata; reservations allow only one terminal transition; API failures return stable Problem Details. + +## Conservative requirement state + +The traceability manifest marks only requirements with concrete implementation and tests as `partial`. All remain `not-verified`; no P0/P1 release status is promoted. Requirements whose primary behavior is absent—such as invitations, service accounts, account recovery, audit exports/legal holds, commercial billing reconciliation, and usage exports—remain `planned`. + +## Evidence + +- Domain tests: `packages/domain/test/identity-v1.test.mjs`, `packages/domain/test/audit-v1.test.mjs`, `packages/domain/test/entitlements-v1.test.mjs`, and tenant/authorization/CSRF/MFA suites. +- API tests: `services/api/test/features/iam/`, `services/api/test/features/aud/`, `services/api/test/features/bua/`, `services/api/test/platform/http/`, and `services/api/test/http-contract.test.ts`. +- Persistence: `services/api/prisma/schema/iam.prisma`, `services/api/prisma/schema/aud.prisma`, `services/api/prisma/schema/bua.prisma`, and the ordered IAM session-scope migration. +- Focused verification passed throughout the batch, including TypeScript compilation, Prisma schema validation, 122 domain tests, and the affected API suites. + +## Remaining gates + +- Plan 020 still requires invitations, ownership transfer workflows, service accounts, signed offline authorization issuance, full permission enforcement, audit action-definition governance, signed independent seals, legal holds/retention/export/restore, provider-independent subscriptions, offline entitlement issuance, reconciliation/exports, client administration surfaces, and real PostgreSQL/backup/security evidence. +- FND-003 remains blocked only on live Docker daemon evidence; this batch does not change its status. +- The feature PR targets `dev` without CodeRabbit. CodeRabbit remains reserved for the later `dev` to `main` promotion PR and is invoked once there. diff --git a/docs/plans/requirement-traceability.json b/docs/plans/requirement-traceability.json index aebe5bba..0934124d 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -731,26 +731,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -762,26 +756,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -793,26 +781,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -824,26 +806,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -855,26 +831,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -886,26 +856,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -917,26 +881,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -948,26 +906,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -979,26 +931,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1010,26 +956,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1041,26 +981,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1072,26 +1006,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1103,23 +1031,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1134,26 +1056,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1165,26 +1081,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1196,23 +1106,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1227,23 +1131,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1258,23 +1156,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1289,26 +1181,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1320,23 +1206,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1351,23 +1231,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1382,23 +1256,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1413,23 +1281,17 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1444,26 +1306,20 @@ "primaryTask": "Task 2: AUD immutable ledger", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/audit/v1.ts", + "services/api/src/features/aud/", + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/audit-v1.test.mjs", + "services/api/test/features/aud/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" @@ -1475,26 +1331,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1506,26 +1356,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1537,26 +1381,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1568,26 +1406,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1599,26 +1431,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1630,23 +1456,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1661,26 +1481,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1692,26 +1506,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1723,23 +1531,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1754,23 +1556,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1785,23 +1581,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1816,26 +1606,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1847,23 +1631,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1878,23 +1656,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1909,26 +1681,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" @@ -1940,23 +1706,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -1971,23 +1731,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -2002,23 +1756,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -2033,23 +1781,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -2064,23 +1806,17 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -2095,26 +1831,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -2126,26 +1856,20 @@ "primaryTask": "Task 3: BUA billing and usage", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/entitlements/v1.ts", + "services/api/src/features/bua/", + "services/api/prisma/schema/bua.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/entitlements-v1.test.mjs", + "services/api/test/features/bua/", + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9225,26 +8949,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9256,26 +8980,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9287,26 +9011,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9318,26 +9042,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9349,26 +9073,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9380,26 +9104,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9411,26 +9135,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9442,26 +9166,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9473,26 +9197,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9504,23 +9228,23 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -9535,26 +9259,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9566,26 +9290,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9597,23 +9321,23 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -9628,26 +9352,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" @@ -9659,23 +9383,23 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -9690,26 +9414,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" @@ -9721,23 +9445,23 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -9752,23 +9476,23 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "planned", "coverage": "planned", @@ -9783,26 +9507,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9814,26 +9538,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9845,26 +9569,26 @@ "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [], "codePaths": [ - "services/api/src/features/identity-audit-entitlements/{domain,application,adapter,api}/", - "services/api/prisma/schema/identity-audit-entitlements.prisma", - "packages/contracts/schemas/v1/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/", - "apps/desktop/src/features/identity-audit-entitlements/", - "apps/android/app/src/main/kotlin/com/databreeze/identityauditentitlements/", - "services/engine/src/databreeze_engine/processors/identity-audit-entitlements/" + "packages/domain/src/identity/v1.ts", + "packages/domain/src/permissions/v1.ts", + "packages/domain/src/authorization/v1.ts", + "packages/domain/src/mfa/v1.ts", + "packages/domain/src/csrf/v1.ts", + "services/api/src/features/iam/", + "services/api/prisma/schema/iam.prisma" ], "testPaths": [ - "services/api/test/features/identity-audit-entitlements/", - "apps/web/src/features/identity-audit-entitlements/__tests__/", - "services/engine/tests/processors/identity-audit-entitlements/" + "packages/domain/test/identity-v1.test.mjs", + "packages/domain/test/permissions-v1.test.mjs", + "services/api/test/features/iam/", + "services/api/test/platform/http/session-tenant-context.test.ts", + "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" From 2c12a911210a0d92f381f114dbe39a4ea037f247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:15:53 +0700 Subject: [PATCH 32/59] docs(git): enforce 30 to 50 commit PR slices --- docs/plans/000-platform-program.md | 2 +- docs/plans/003-luna-handoff-runbook.md | 2 +- docs/plans/004-luna-max-execution-plan.md | 44 +++++++++---------- docs/plans/execution-orchestration.json | 36 +++++++-------- .../src/check-execution-orchestration.mjs | 6 +-- .../test/execution-orchestration.test.mjs | 6 +-- 6 files changed, 48 insertions(+), 48 deletions(-) diff --git a/docs/plans/000-platform-program.md b/docs/plans/000-platform-program.md index f9d652b6..df6dc4b7 100644 --- a/docs/plans/000-platform-program.md +++ b/docs/plans/000-platform-program.md @@ -43,7 +43,7 @@ Child plans are written and approved before their product slice begins. Each nam docs/plans/requirement-traceability.json records all 611 IDs with requested trace fields, one primary plan/task, release status, and verified paths only after evidence exists. -`docs/plans/004-luna-max-execution-plan.md` packages the unfinished orchestration tasks into 15 dependency-safe delivery batches. Normal batches contain 30–99 atomic commits, target about 70, keep promotion diffs at or below 260 changed files, and use the `feat/*` or `fix/*` → `dev` → reviewed `main` flow below. +`docs/plans/004-luna-max-execution-plan.md` packages the unfinished orchestration tasks into 15 dependency-safe delivery batches. A normal PR slice contains 30–50 atomic commits; an exceptional completed-task boundary may extend to 79 but never to CodeRabbit's 100-commit limit. Promotion diffs stay at or below 260 changed files and use the `feat/*` or `fix/*` → `dev` → reviewed `main` flow below. ## Branch, commit, and review policy diff --git a/docs/plans/003-luna-handoff-runbook.md b/docs/plans/003-luna-handoff-runbook.md index 80fdefce..ab799071 100644 --- a/docs/plans/003-luna-handoff-runbook.md +++ b/docs/plans/003-luna-handoff-runbook.md @@ -91,7 +91,7 @@ For each `#### TASK-ID —` item in `002-complete-execution-orchestration.md`: 10. Inspect generated/runtime debris before commit. Do not commit `.venv`, `node_modules`, Gradle state, build output, logs, caches, secrets, local databases, Terraform state, or test reports unless the repository explicitly tracks a sanitized fixture. 11. Commit one independently reversible outcome with a semantic message. Do not combine contracts, an unrelated fix, and a different feature just to increase commit count. -12. Recount the active batch against its base. Do not open a normal PR below 30 commits; target about 70, stop accepting new tasks at 90, and never exceed 99. Split before the promotion diff reaches 280 changed files; the packet target is 260. +12. Recount the active PR slice against its base. Do not open below 30 commits; target 30–50, stop accepting new tasks at 50, and split at the next completed-task boundary. An exceptional boundary must never exceed 79 commits, preserving margin below CodeRabbit's 100-commit limit. Split before the promotion diff reaches 280 changed files; the packet target is 260. 13. Push after each stable task boundary. Update the ledger/checkpoint only with verified facts and leave a handoff record if stopping. ## Pull-request and CodeRabbit protocol diff --git a/docs/plans/004-luna-max-execution-plan.md b/docs/plans/004-luna-max-execution-plan.md index 4b4f54bd..0bb9e8be 100644 --- a/docs/plans/004-luna-max-execution-plan.md +++ b/docs/plans/004-luna-max-execution-plan.md @@ -17,7 +17,7 @@ - Workers, Desktop, and Android accept signed typed actions and scoped handles only; they never receive arbitrary commands, unrestricted paths, or database credentials. - Vietnamese is the complete default locale and English is complete for every delivered client slice. - Requirement status is evidence-based: merged code is not automatically `verified` or `released`. -- Normal feature PRs contain at least 30 commits, target about 70, and remain below 100. Empty, padding, or artificially split commits are forbidden. +- Normal feature PR slices contain 30–50 commits. If an atomic task crosses 50, finish that task and split immediately; the exceptional ceiling is 79, preserving margin below CodeRabbit's 100-commit limit. Empty, padding, or artificially split commits are forbidden. - Feature/fix PRs target `dev` without CodeRabbit. The corresponding `dev` to `main` promotion receives exactly one full CodeRabbit review after hosted checks are otherwise ready. - Keep the promotion diff at or below 260 changed files, leaving safety margin under the 280-file review stop gate. - Never run package-manager commands concurrently in the same worktree. `pnpm install`, checks, tests, and builds share `node_modules` and execute sequentially there. @@ -36,7 +36,7 @@ This checkpoint was reconciled on 2026-08-02 after the latest promotion: | Last promotion PR | PR #20, `dev` to `main` | | Promotion review fixes | PRs #21, #22, and #23 back to `dev` | | Open PRs observed | None | -| Requirement ledger | 611 total: 608 `planned`, 3 `partial`, 0 `verified` | +| Requirement ledger | 611 total: 565 `planned`, 46 `partial`, 0 `verified` | | Next orchestration task | `FND-003` | | Active delivery batch | `B01` | @@ -60,27 +60,27 @@ Some early child plans contain generic aggregate `Paths` examples. Do not create ## 3. Delivery-batch map -Each batch is one normal integration PR and one promotion PR unless the changed-file safety gate forces a split. Commit ranges are planning budgets, not quotas. If a coherent batch finishes below 30 commits, keep the branch open and continue the next compatible task; do not open a small PR merely to reset the counter. +Each batch may require multiple normal integration PR slices before its exit gate passes. Every slice is followed by its own promotion PR. Commit ranges are review budgets, not quotas. If a coherent slice finishes below 30 commits, keep the branch open and continue the next compatible task; do not create padding merely to reset the counter. | Batch | Branch | Tasks | Dependencies | Commit budget | Exit gate | |---|---|---|---|---|---| -| `B01` | `feat/foundation-identity-completion` | `FND-003..007`, all Plan 020 tasks | Verified `FND-001/002` | 50–85, target 70 | Foundation external gates recorded; IAM/AUD/BUA obligations reconciled and completed | -| `B02` | `feat/artifacts-datasets-completion` | All Plan 030 tasks | `B01` | 40–75, target 65 | Immutable artifact/evidence/dataset foundations verified | -| `B03` | `feat/jobs-processing-completion` | All Plan 040 tasks | `B02` | 45–80, target 70 | Signed typed jobs execute locally/cloud with approvals and durable recovery | -| `B04` | `feat/devices-sync-completion` | All Plan 050 tasks | `B03` | 45–80, target 70 | Desktop/Android sync, offline, conflict, transfer, and revocation gates pass | -| `B05` | `feat/collaboration-integrations` | All Plan 060 tasks | `B04` | 45–80, target 70 | Notifications, collaboration, public API, connectors, and webhooks pass | -| `B06` | `feat/dogfood-autopilot-core` | `DOG-001..007`, `FA-001..003` | `B05` | 45–75, target 65 | Ten-condition dogfood record accepted; safe Autopilot intake/routing exists | -| `B07` | `feat/autopilot-spreadsheet-auditor` | `FA-004..007`, `SA-001..007` | `B06` | 50–85, target 70 | Folder Autopilot and Spreadsheet Auditor P0/P1 gates pass | -| `B08` | `feat/quote-invoice-intelligence` | `QI-001..007`, then `ILD-001..007` | `B06` | 60–90, target 75 | Quote Intelligence and Invoice Leak Detector P0/P1 gates pass | -| `B09` | `feat/operations-capture` | `OC-001..008` | `B06` | 40–75, target 65 | Offline native capture, immutable submission, supervision, and reconciliation pass | -| `B10` | `feat/client-report-factory` | `CRF-001..007` | `B07`, `B08` | 40–75, target 65 | Evidence-linked multi-format reports and revocable sharing pass | -| `B11` | `feat/private-data-analyst` | `PDA-001..008` | `B09`, `B10` | 45–80, target 70 | Deterministic governed analysis and optional-AI boundaries pass | -| `B12` | `feat/migration-quality-suite` | `MR-001..007`, then `DQG-001..008` | `B08`, `B11` | 65–95, target 80 | Migration Ready and Data Quality Guard P0/P1 gates pass | -| `B13` | `feat/embedded-importer` | `EI-001..007` | `B05` | 35–70, target 60 | Hosted importer and outbound-only local gateway pass hostile tests | -| `B14` | `feat/production-readiness` | `GA-001..012` | `B12`, `B13` | 60–90, target 75 | Every P0/P1 requirement is verified and coordinated GA is released | -| `B15` | `feat/post-ga-extensions` | `P2-001..004` | `B14` | 35–70, target 60 | All 13 P2 requirements are opt-in, revocable, and verified | - -The machine-readable `deliveryBatches` array is authoritative for exact task membership. Its checker rejects missing or duplicate task ownership, dependency cycles, a batch below the 30-commit minimum, a maximum of 100 or more, and an active batch that does not contain `nextTaskId`. +| `B01` | `feat/foundation-identity-reconciliation` | `FND-003..007`, all Plan 020 tasks | Verified `FND-001/002` | 30–50 target; exceptional ceiling 79 | Foundation external gates recorded; IAM/AUD/BUA obligations reconciled and completed | +| `B02` | `feat/artifacts-datasets-completion` | All Plan 030 tasks | `B01` | 30–50 target; exceptional ceiling 79 | Immutable artifact/evidence/dataset foundations verified | +| `B03` | `feat/jobs-processing-completion` | All Plan 040 tasks | `B02` | 30–50 target; exceptional ceiling 79 | Signed typed jobs execute locally/cloud with approvals and durable recovery | +| `B04` | `feat/devices-sync-completion` | All Plan 050 tasks | `B03` | 30–50 target; exceptional ceiling 79 | Desktop/Android sync, offline, conflict, transfer, and revocation gates pass | +| `B05` | `feat/collaboration-integrations` | All Plan 060 tasks | `B04` | 30–50 target; exceptional ceiling 79 | Notifications, collaboration, public API, connectors, and webhooks pass | +| `B06` | `feat/dogfood-autopilot-core` | `DOG-001..007`, `FA-001..003` | `B05` | 30–50 target; exceptional ceiling 79 | Ten-condition dogfood record accepted; safe Autopilot intake/routing exists | +| `B07` | `feat/autopilot-spreadsheet-auditor` | `FA-004..007`, `SA-001..007` | `B06` | 30–50 target; exceptional ceiling 79 | Folder Autopilot and Spreadsheet Auditor P0/P1 gates pass | +| `B08` | `feat/quote-invoice-intelligence` | `QI-001..007`, then `ILD-001..007` | `B06` | 30–50 target; exceptional ceiling 79 | Quote Intelligence and Invoice Leak Detector P0/P1 gates pass | +| `B09` | `feat/operations-capture` | `OC-001..008` | `B06` | 30–50 target; exceptional ceiling 79 | Offline native capture, immutable submission, supervision, and reconciliation pass | +| `B10` | `feat/client-report-factory` | `CRF-001..007` | `B07`, `B08` | 30–50 target; exceptional ceiling 79 | Evidence-linked multi-format reports and revocable sharing pass | +| `B11` | `feat/private-data-analyst` | `PDA-001..008` | `B09`, `B10` | 30–50 target; exceptional ceiling 79 | Deterministic governed analysis and optional-AI boundaries pass | +| `B12` | `feat/migration-quality-suite` | `MR-001..007`, then `DQG-001..008` | `B08`, `B11` | 30–50 target; exceptional ceiling 79 | Migration Ready and Data Quality Guard P0/P1 gates pass | +| `B13` | `feat/embedded-importer` | `EI-001..007` | `B05` | 30–50 target; exceptional ceiling 79 | Hosted importer and outbound-only local gateway pass hostile tests | +| `B14` | `feat/production-readiness` | `GA-001..012` | `B12`, `B13` | 30–50 target; exceptional ceiling 79 | Every P0/P1 requirement is verified and coordinated GA is released | +| `B15` | `feat/post-ga-extensions` | `P2-001..004` | `B14` | 30–50 target; exceptional ceiling 79 | All 13 P2 requirements are opt-in, revocable, and verified | + +The machine-readable `deliveryBatches` array is authoritative for exact task membership. Its checker rejects missing or duplicate task ownership, dependency cycles, a PR-slice minimum below 30, an exceptional maximum above 79, and an active batch that does not contain `nextTaskId`. ## 4. Parallel execution and integration ownership @@ -118,7 +118,7 @@ Typical reversible commits inside a task are: canonical contract, domain behavio ## 6. PR and promotion algorithm 1. Count commits and changed files against the batch base before opening anything. -2. Do not open the normal PR below 30 commits. At 60–75 commits, finish the current atomic task and prepare the PR. At 90 commits, stop accepting new tasks. At 99 commits, the branch is at the hard boundary and must not receive another commit before scope is split or promoted. +2. Do not open the normal PR below 30 commits. At 30–50 commits, finish the current atomic task and prepare the PR. At 50, stop accepting new tasks and split at the next completed-task boundary. An exceptional boundary must never exceed 79 commits. 3. If the branch exceeds 260 changed files, split at a completed task boundary before review. Do not split a migration from its code/tests or a canonical schema from generated consumers. 4. Open `feat/*` or `fix/*` to `dev`. Run hosted checks and merge with a merge commit that preserves atomic commits. Do not invoke CodeRabbit. 5. Immediately open `dev` to `main`. When otherwise ready, request one full CodeRabbit review and record the invocation. @@ -129,7 +129,7 @@ Focused promotion-gate fixes may use a smaller PR to `dev` because they close an ## 7. First Luna Max session -The active branch is `feat/foundation-identity-completion`, based on `origin/dev` at `783a4710c0aa2a2808d78ad7f0643e6731150bd7`. Its first commit is this orchestration update; continue on the same branch until `B01` reaches a coherent 50–85 commit boundary. +The active B01 PR slice is `feat/foundation-identity-reconciliation`, based on the fetched `origin/dev` merge checkpoint. Continue B01 through additional branches after each 30–50 commit slice; do not claim the batch complete until its exit gate passes. Run these commands sequentially: diff --git a/docs/plans/execution-orchestration.json b/docs/plans/execution-orchestration.json index 37be5e90..9f334dfd 100644 --- a/docs/plans/execution-orchestration.json +++ b/docs/plans/execution-orchestration.json @@ -40,8 +40,8 @@ }, "commitBudget": { "preferredMinimum": 30, - "preferredMaximum": 70, - "hardMaximum": 99 + "preferredMaximum": 50, + "hardMaximum": 79 } }, "statusVocabulary": [ @@ -144,10 +144,10 @@ { "batchId": "B01", "name": "Foundation verification and identity completion", - "branch": "feat/foundation-identity-completion", + "branch": "feat/foundation-identity-reconciliation", "dependencies": [], "status": "in-progress", - "commitBudget": { "minimum": 30, "target": 70, "maximum": 85 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": [ "FND-003", @@ -175,7 +175,7 @@ "branch": "feat/artifacts-datasets-completion", "dependencies": ["B01"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["IAE-001", "IAE-002", "IAE-003", "IAE-004", "IAE-005", "DSM-001", "DSM-002", "DSM-003", "IAE-006", "IAE-007"], "exitGate": "Local, Hybrid, and Cloud artifact, evidence, dataset, definition, retention, and deletion gates pass." @@ -186,7 +186,7 @@ "branch": "feat/jobs-processing-completion", "dependencies": ["B02"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["JRA-001", "JRA-002", "JRA-003", "JRA-004", "JRA-005", "JRA-006", "JRA-007", "JRA-008", "JRA-009", "JRA-010", "JRA-011"], "exitGate": "The same signed typed action executes locally or in cloud with durable admission, evidence, approval, recovery, usage, and audit outcomes." @@ -197,7 +197,7 @@ "branch": "feat/devices-sync-completion", "dependencies": ["B03"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["DSO-001", "DSO-002", "DSO-003", "DSO-004", "DSO-005", "DSO-006", "DSO-007", "DSO-008", "DSO-009", "DSO-010"], "exitGate": "Desktop and Android operate offline, resume idempotently, expose conflicts, preserve data modes, and fail closed after revocation." @@ -208,7 +208,7 @@ "branch": "feat/collaboration-integrations", "dependencies": ["B04"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["NCO-001", "NCO-002", "NCO-003", "NCO-004", "INT-001", "INT-002", "INT-003", "INT-004", "NCO-005", "INT-005"], "exitGate": "Collaboration and external access use shared contracts and replaceable adapters without restricted or undocumented APIs." @@ -219,7 +219,7 @@ "branch": "feat/dogfood-autopilot-core", "dependencies": ["B05"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["DOG-001", "DOG-002", "DOG-003", "DOG-004", "DOG-005", "DOG-006", "DOG-007", "FA-001", "FA-002", "FA-003"], "exitGate": "All ten dogfood conditions pass and Autopilot has governed bindings, routing, watchers, and reconciliation." @@ -230,7 +230,7 @@ "branch": "feat/autopilot-spreadsheet-auditor", "dependencies": ["B06"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 70, "maximum": 85 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["FA-004", "FA-005", "FA-006", "FA-007", "SA-001", "SA-002", "SA-003", "SA-004", "SA-005", "SA-006", "SA-007"], "exitGate": "Folder Autopilot and Spreadsheet Auditor P0/P1 requirements are verified without mutating originals." @@ -241,7 +241,7 @@ "branch": "feat/quote-invoice-intelligence", "dependencies": ["B06"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 75, "maximum": 90 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["QI-001", "QI-002", "QI-003", "QI-004", "QI-005", "QI-006", "QI-007", "ILD-001", "ILD-002", "ILD-003", "ILD-004", "ILD-005", "ILD-006", "ILD-007"], "exitGate": "Quote Intelligence and Invoice Leak Detector P0/P1 requirements are verified with exact source evidence and no vendor API dependency." @@ -252,7 +252,7 @@ "branch": "feat/operations-capture", "dependencies": ["B06"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["OC-001", "OC-002", "OC-003", "OC-004", "OC-005", "OC-006", "OC-007", "OC-008"], "exitGate": "Offline native capture, immutable submissions, correction, supervision, and Desktop reconciliation pass P0/P1 gates." @@ -263,7 +263,7 @@ "branch": "feat/client-report-factory", "dependencies": ["B07", "B08"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["CRF-001", "CRF-002", "CRF-003", "CRF-004", "CRF-005", "CRF-006", "CRF-007"], "exitGate": "Evidence-linked multi-format reports, review, release, scheduling, and revocable sharing pass P0/P1 gates." @@ -274,7 +274,7 @@ "branch": "feat/private-data-analyst", "dependencies": ["B09", "B10"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["PDA-001", "PDA-002", "PDA-003", "PDA-004", "PDA-005", "PDA-006", "PDA-007", "PDA-008"], "exitGate": "Governed analysis is deterministic and reproducible; optional AI can propose but never supply numeric truth." @@ -285,7 +285,7 @@ "branch": "feat/migration-quality-suite", "dependencies": ["B08", "B11"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 80, "maximum": 95 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["MR-001", "MR-002", "MR-003", "MR-004", "MR-005", "MR-006", "MR-007", "DQG-001", "DQG-002", "DQG-003", "DQG-004", "DQG-005", "DQG-006", "DQG-007", "DQG-008"], "exitGate": "Migration Ready and Data Quality Guard P0/P1 requirements are verified with export-first, immutable, evidence-backed behavior." @@ -296,7 +296,7 @@ "branch": "feat/embedded-importer", "dependencies": ["B05"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 60, "maximum": 70 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["EI-001", "EI-002", "EI-003", "EI-004", "EI-005", "EI-006", "EI-007"], "exitGate": "Hosted importer and outbound-only Desktop gateway pass tenant, origin, upload, replay, accessibility, and local/cloud parity gates." @@ -307,7 +307,7 @@ "branch": "feat/production-readiness", "dependencies": ["B12", "B13"], "status": "planned", - "commitBudget": { "minimum": 30, "target": 75, "maximum": 90 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["GA-001", "GA-002", "GA-003", "GA-004", "GA-005", "GA-006", "GA-007", "GA-008", "GA-009", "GA-010", "GA-011", "GA-012"], "exitGate": "Every P0/P1 requirement is verified, artifacts are signed and recoverable, and the coordinated GA release is observed and recorded." @@ -318,7 +318,7 @@ "branch": "feat/post-ga-extensions", "dependencies": ["B14"], "status": "post-ga-planned", - "commitBudget": { "minimum": 30, "target": 60, "maximum": 70 }, + "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": ["P2-001", "P2-002", "P2-003", "P2-004"], "exitGate": "All 13 P2 requirements are opt-in, disabled by default, provider-exitable, revocable, and verified." diff --git a/tools/repo-cli/src/check-execution-orchestration.mjs b/tools/repo-cli/src/check-execution-orchestration.mjs index 6ee2c595..ddab9773 100644 --- a/tools/repo-cli/src/check-execution-orchestration.mjs +++ b/tools/repo-cli/src/check-execution-orchestration.mjs @@ -25,7 +25,7 @@ const expectedPlans = new Map([ ]); const expectedPriorityTotals = { P0: 444, P1: 154, P2: 13 }; const expectedReviewPolicy = { - commitBudget: { hardMaximum: 99, preferredMaximum: 70, preferredMinimum: 30 }, + commitBudget: { hardMaximum: 79, preferredMaximum: 50, preferredMinimum: 30 }, featurePullRequest: { base: 'dev', codeRabbit: false, mergeAfterHostedChecks: true }, promotionPullRequest: { base: 'main', @@ -160,8 +160,8 @@ function validateDeliveryBatches({ ledger, plans, taskIds, taskToPlan, diagnosti ) { diagnostics.push(`batch ${batch.batchId} commit target is outside its budget`); } - if (!Number.isInteger(budget.maximum) || budget.maximum >= 100) { - diagnostics.push(`batch ${batch.batchId} commit maximum must remain below 100`); + if (!Number.isInteger(budget.maximum) || budget.maximum > 79) { + diagnostics.push(`batch ${batch.batchId} exceptional commit maximum must not exceed 79`); } if ( !Number.isInteger(batch.maximumChangedFiles) || diff --git a/tools/repo-cli/test/execution-orchestration.test.mjs b/tools/repo-cli/test/execution-orchestration.test.mjs index ab338bd8..d0ebbc11 100644 --- a/tools/repo-cli/test/execution-orchestration.test.mjs +++ b/tools/repo-cli/test/execution-orchestration.test.mjs @@ -142,8 +142,8 @@ test('handoff policy preserves the requested dev and main review flow', () => { }); assert.deepEqual(ledger.reviewPolicy.commitBudget, { preferredMinimum: 30, - preferredMaximum: 70, - hardMaximum: 99, + preferredMaximum: 50, + hardMaximum: 79, }); }); @@ -167,7 +167,7 @@ test('delivery batches cover every unfinished task once within review budgets', assert.ok(batch.commitBudget.minimum >= 30); assert.ok(batch.commitBudget.target >= batch.commitBudget.minimum); assert.ok(batch.commitBudget.target <= batch.commitBudget.maximum); - assert.ok(batch.commitBudget.maximum < 100); + assert.ok(batch.commitBudget.maximum <= 79); assert.ok(batch.maximumChangedFiles <= 260); } const activeBatch = ledger.deliveryBatches.find( From 8e9d753842a3895fc147538d2cd82d3e317c5c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:17:12 +0700 Subject: [PATCH 33/59] style(iam): format bootstrap repository --- .../prisma-identity-bootstrap-repository.adapter.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts index 6a35b8c3..350fdc55 100644 --- a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts @@ -93,10 +93,7 @@ function valuesEqual(left: unknown, right: unknown): boolean { return left === right; } -function ownedFieldsMatch( - existing: TRow, - expected: TRow, -): boolean { +function ownedFieldsMatch(existing: TRow, expected: TRow): boolean { const existingRecord = existing as Record; const expectedRecord = expected as Record; return Object.keys(expectedRecord).every((key) => @@ -305,8 +302,7 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran ): Promise { const existing = await delegate.findUnique({ where: { id: expected.id } }); if (existing) { - if (!ownedFieldsMatch(existing, expected)) - throw new Error('IAM_BOOTSTRAP_CONFLICT'); + if (!ownedFieldsMatch(existing, expected)) throw new Error('IAM_BOOTSTRAP_CONFLICT'); return; } await delegate.create({ data: expected }); From 47db553184e1567a8e63840df4d3bfde7ee077cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:28:44 +0700 Subject: [PATCH 34/59] test(api): register session scope migration --- services/api/test/prisma-foundation.test.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 6d13ca9f..fc538be6 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -122,6 +122,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802290000_dsm_export_manifests', '20260802300000_sa_spreadsheet_audits', '20260803000000_iae_lineage_uniqueness', + '20260803010000_iam_session_scope_binding', 'migration_lock.toml', ]); const migration = await readFile( @@ -502,4 +503,19 @@ test('the schema diff and centrally ordered migration inventory establish platfo lineageUniquenessMigration, /CREATE UNIQUE INDEX "artifact_lineage_derived_version_key"/, ); + const sessionScopeMigration = await readFile( + path.join(migrationsDirectory, inventory[33], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'ALTER TABLE "iam"."sessions"', + 'ADD COLUMN "organization_id" UUID NOT NULL', + 'ADD COLUMN "workspace_id" UUID NOT NULL', + 'CREATE INDEX "sessions_scope_user_status_idx"', + ]) { + assert.match( + sessionScopeMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From d323c02994c177ffbec080fa025607491dd58ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:34:18 +0700 Subject: [PATCH 35/59] test(web): tolerate lazy route startup under load --- apps/web/test/error-privacy-query.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/test/error-privacy-query.test.tsx b/apps/web/test/error-privacy-query.test.tsx index bf103e31..ec1a1f33 100644 --- a/apps/web/test/error-privacy-query.test.tsx +++ b/apps/web/test/error-privacy-query.test.tsx @@ -13,7 +13,9 @@ describe('safe localized recovery', () => { const router = createAppRouter({ initialEntries: ['/vi-VN/debug/route-error'] }); render(); - expect(await screen.findByRole('heading', { name: 'Không thể mở khu vực này' })).toBeTruthy(); + expect( + await screen.findByRole('heading', { name: 'Không thể mở khu vực này' }, { timeout: 5_000 }), + ).toBeTruthy(); expect(screen.queryByText(/internal tenant detail/u)).toBeNull(); consoleError.mockRestore(); }); From 5d098430463ce38d56d79ed9129c873a67fdc063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 13:43:41 +0700 Subject: [PATCH 36/59] fix(aud): preserve success responses in OpenAPI --- services/api/openapi/v1.json | 26 +++++++++++++++++++ .../src/features/aud/api/audit.controller.ts | 3 +++ services/api/test/openapi.test.ts | 6 +++++ 3 files changed, 35 insertions(+) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 3d35a340..d41c6328 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -6804,6 +6804,19 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Audit persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } } }, "security": [{ "bearer": [] }], @@ -6872,6 +6885,19 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Audit persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } } }, "security": [{ "bearer": [] }], diff --git a/services/api/src/features/aud/api/audit.controller.ts b/services/api/src/features/aud/api/audit.controller.ts index 43b6269f..668709c8 100644 --- a/services/api/src/features/aud/api/audit.controller.ts +++ b/services/api/src/features/aud/api/audit.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Inject, Req } from '@nestjs/common'; import { ApiBearerAuth, + ApiOkResponse, ApiOperation, ApiServiceUnavailableResponse, ApiTags, @@ -27,6 +28,7 @@ export class AuditController { @Get('events') @ApiOperation({ summary: 'List immutable audit events visible to the caller' }) + @ApiOkResponse() @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' }) async events(@Req() request: unknown): Promise { const context = await this.requestContext.resolve(request); @@ -39,6 +41,7 @@ export class AuditController { @Get('seals') @ApiOperation({ summary: 'List verified audit seals visible to the caller' }) + @ApiOkResponse() @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' }) async seals(@Req() request: unknown): Promise { const context = await this.requestContext.resolve(request); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index b2040d55..cf1a55d7 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -193,6 +193,12 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, } } + for (const path of ['/v1/audit/events', '/v1/audit/seals'] as const) { + const auditRead = firstDocument.paths[path]?.get as OperationLike | undefined; + assert.ok(auditRead?.responses['200'], `${path} must document its successful response`); + assert.ok(auditRead.responses['503'], `${path} must document audit persistence outages`); + } + const served = await first.app.inject({ method: 'GET', url: '/v1/openapi.json' }); assert.equal(served.statusCode, 200); assert.deepEqual(served.json(), firstDocument); From ccbb9d359d7a2cb89e8874e3abcf4a3b87faa35d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:19:31 +0700 Subject: [PATCH 37/59] fix(bua): preserve project usage scopes --- .../migration.sql | 14 +++++++ services/api/prisma/schema/bua.prisma | 6 ++- .../prisma-entitlement-repository.adapter.ts | 31 ++++++++------ .../bua/prisma-entitlement-repository.test.ts | 42 +++++++++++++++++++ 4 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 services/api/prisma/migrations/20260803020000_bua_project_usage_scope/migration.sql 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/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts index 4fa88a19..21cf0e9a 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 @@ -77,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; @@ -93,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; @@ -182,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') @@ -301,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 || @@ -332,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 || @@ -401,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), @@ -418,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, @@ -436,10 +445,12 @@ function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { function inheritedUsageScopeKeys(scope: TenantScopeV1): readonly string[] | undefined { if (scope.scopeType === 'organization') return undefined; - return Object.freeze([ + 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 { @@ -559,16 +570,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV return Object.freeze({ entries: Object.freeze( entryRows - .filter((row) => - visible(context.tenantScope, persistedScope({ ...row, projectId: null })), - ) + .filter((row) => visible(context.tenantScope, persistedScope(row))) .map(persistedEntry), ), reservations: Object.freeze( reservationRows - .filter((row) => - visible(context.tenantScope, persistedScope({ ...row, projectId: null })), - ) + .filter((row) => visible(context.tenantScope, persistedScope(row))) .map(persistedReservation), ), }); 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 7f689d58..96b6d12b 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', @@ -235,6 +249,34 @@ 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-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()); From a8a5a47272e9c6ff53071ee089e25b2c55a0cd72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:20:15 +0700 Subject: [PATCH 38/59] fix(bua): transact direct usage persistence --- .../prisma-entitlement-repository.adapter.ts | 4 +++- .../bua/prisma-entitlement-repository.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) 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 21cf0e9a..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 @@ -677,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/test/features/bua/prisma-entitlement-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-repository.test.ts index 96b6d12b..520cea03 100644 --- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -175,6 +175,7 @@ function client( options: { readonly forceRevisionConflict?: boolean; readonly firstQueries?: Array>>; + readonly transactionCalls?: { value: number }; } = {}, ): EntitlementDatabaseClientV1 { const planRows: Record[] = []; @@ -193,6 +194,7 @@ function client( async $transaction( work: (transaction: EntitlementDatabaseClientV1) => Promise, ): Promise { + if (options.transactionCalls) options.transactionCalls.value += 1; return work(database as unknown as EntitlementDatabaseClientV1); }, }; @@ -277,6 +279,18 @@ void test('[BUA-008, IAM-009] Prisma entitlement adapter round-trips project-sco }); }); +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()); From 0615d54c5dca554403def8db7cb07ad2e690a222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:20:56 +0700 Subject: [PATCH 39/59] fix(http): accept standard cookie names --- .../api/src/platform/http/csrf-protection.ts | 2 +- .../test/platform/http/csrf-protection.test.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/services/api/src/platform/http/csrf-protection.ts b/services/api/src/platform/http/csrf-protection.ts index 54b6d38b..9786da39 100644 --- a/services/api/src/platform/http/csrf-protection.ts +++ b/services/api/src/platform/http/csrf-protection.ts @@ -80,7 +80,7 @@ function parseCookies(raw: string): { if ( name.length > MAX_COOKIE_NAME_LENGTH || value.length > MAX_COOKIE_VALUE_LENGTH || - !/^[A-Za-z0-9_]+$/u.test(name) || + !/^[!#$%&'*+\-.^_`|~A-Za-z0-9]+$/u.test(name) || value.includes('\r') || value.includes('\n') ) { diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts index 54c483bb..e5647737 100644 --- a/services/api/test/platform/http/csrf-protection.test.ts +++ b/services/api/test/platform/http/csrf-protection.test.ts @@ -83,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}`, From 1bf5650886a778897826e06ed1624e6f4e892892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:22:03 +0700 Subject: [PATCH 40/59] fix(iam): compose database session context --- services/api/src/app.module.ts | 14 +++++-- .../foundation-module-composition.test.ts | 38 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) 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/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 }); From 66a9a56828e8d3216060920d96d857097ec7586f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:24:35 +0700 Subject: [PATCH 41/59] fix(iam): require proof before MFA activation --- packages/domain/src/mfa/v1.ts | 1 + services/api/openapi/v1.json | 7 ++-- .../src/features/iam/api/mfa.controller.ts | 4 ++- services/api/src/features/iam/api/mfa.dto.ts | 6 ++++ .../features/iam/application/mfa.service.ts | 36 +++++++++++++++++++ services/api/src/features/iam/iam.module.ts | 9 ++++- .../api/test/features/iam/mfa.service.test.ts | 33 ++++++++++++++--- services/api/test/http-contract.test.ts | 16 ++++++--- 8 files changed, 98 insertions(+), 14 deletions(-) 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/services/api/openapi/v1.json b/services/api/openapi/v1.json index d41c6328..9f03f4ef 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -7431,8 +7431,11 @@ }, "VerifyMfaFactorDto": { "type": "object", - "properties": { "at": { "type": "string", "format": "date-time" } }, - "required": ["at"] + "properties": { + "proof": { "type": "string", "minLength": 1, "maxLength": 4096, "writeOnly": true }, + "at": { "type": "string", "format": "date-time" } + }, + "required": ["proof", "at"] }, "RedeemMfaRecoveryCodeDto": { "type": "object", diff --git a/services/api/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts index e1ae9111..8bebc25a 100644 --- a/services/api/src/features/iam/api/mfa.controller.ts +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -59,7 +59,9 @@ export class MfaController { ): Promise { const mfa = this.requireService(); const context = await this.requestContext.resolve(request); - const result = await this.execute(() => mfa.verifyFactor(context.actorId, factorId, input.at)); + const result = await this.execute(() => + mfa.verifyFactor(context.actorId, factorId, input.proof, input.at), + ); if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); return result.value; } diff --git a/services/api/src/features/iam/api/mfa.dto.ts b/services/api/src/features/iam/api/mfa.dto.ts index db58a085..6c55bd37 100644 --- a/services/api/src/features/iam/api/mfa.dto.ts +++ b/services/api/src/features/iam/api/mfa.dto.ts @@ -34,6 +34,12 @@ export class EnrollMfaFactorDto { } export class VerifyMfaFactorDto { + @ApiProperty({ minLength: 1, maxLength: 4096, writeOnly: true }) + @IsString() + @MinLength(1) + @MaxLength(4096) + proof!: string; + @ApiProperty({ format: 'date-time' }) @IsISO8601() at!: string; diff --git a/services/api/src/features/iam/application/mfa.service.ts b/services/api/src/features/iam/application/mfa.service.ts index b7916b47..526fd48a 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,6 +85,7 @@ export class MfaService { private readonly recoveryMatcher: { matches(presentedDigest: string, storedDigest: string): boolean; }, + private readonly factorProofVerifier: MfaFactorProofVerifierV1 = new UnavailableMfaFactorProofVerifier(), ) {} public async enroll( @@ -89,15 +112,28 @@ export class MfaService { public async verifyFactor( userIdInput: unknown, factorIdInput: unknown, + proofInput: unknown, at: 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(); + 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', at); if (!transitioned.accepted) return Object.freeze({ accepted: false, code: transitioned.code }); diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index fac825fc..5f66bed1 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -22,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, @@ -83,6 +88,7 @@ export interface IamModuleOptions { readonly mfaRepository?: MfaRepositoryPortV1; readonly mfaDatabase?: MfaDatabaseClientV1; readonly mfaService?: MfaService; + readonly mfaFactorProofVerifier?: MfaFactorProofVerifierV1; readonly recoveryCodeMatcher?: { matches(presentedDigest: string, storedDigest: string): boolean; }; @@ -150,6 +156,7 @@ export class IamModule { options.recoveryCodeMatcher ?? { matches: constantTimeRecoveryCodeMatchV1, }, + options.mfaFactorProofVerifier ?? new UnavailableMfaFactorProofVerifier(), )); const iamRepository = options.iamRepository ?? diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts index 97e5213d..eb0a09d7 100644 --- a/services/api/test/features/iam/mfa.service.test.ts +++ b/services/api/test/features/iam/mfa.service.test.ts @@ -14,9 +14,15 @@ 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'), + }, + ); const enrolled = await service.enroll({ id: factorId, userId, @@ -27,10 +33,27 @@ void test('[IAM-012, IAM-013, IAM-014] MFA enrollment and verification are revis 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'); + const invalidProof = await service.verifyFactor( + userId, + factorId, + '000000', + '2026-01-01T00:01:00.000Z', + ); + assert.deepEqual(invalidProof, { accepted: false, code: 'FACTOR_PROOF_INVALID' }); + const verified = await service.verifyFactor( + userId, + factorId, + '654321', + '2026-01-01T00:01:00.000Z', + ); 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'); + const secondVerify = await service.verifyFactor( + userId, + factorId, + '654321', + '2026-01-01T00:02:00.000Z', + ); assert.deepEqual(secondVerify, { accepted: false, code: 'INVALID_STATE' }); }); diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 91bd14cd..a20042fa 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -717,9 +717,15 @@ void test('audit read outages return retryable service-unavailable problems', as 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'), + }, + ); const contextResult = createIamTenantContextV1({ tenantScope: { scopeType: 'workspace', @@ -755,7 +761,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', at: '2026-01-01T00:01:00.000Z' }, }); assert.equal(verified.statusCode, 200); const verifiedBody = parsedBody<{ readonly factors: readonly [{ readonly status: string }] }>( @@ -766,7 +772,7 @@ 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', at: '2026-01-01T00:02:00.000Z' }, }); assertProblem(invalid, 400, 'MFA_REQUEST_REJECTED'); }); From 774d4d5995ea3740a43f3f74da3a11887e36c33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:28:08 +0700 Subject: [PATCH 42/59] fix(iam): derive MFA timestamps from server clock --- services/api/openapi/v1.json | 16 +++---- .../src/features/iam/api/mfa.controller.ts | 4 +- services/api/src/features/iam/api/mfa.dto.ts | 26 +---------- .../features/iam/application/mfa.service.ts | 18 ++++---- services/api/src/features/iam/iam.module.ts | 2 + .../api/test/features/iam/mfa.service.test.ts | 44 ++++++++----------- services/api/test/http-contract.test.ts | 19 ++++++-- 7 files changed, 55 insertions(+), 74 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 9f03f4ef..0d2e76f4 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -7423,27 +7423,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": { - "proof": { "type": "string", "minLength": 1, "maxLength": 4096, "writeOnly": true }, - "at": { "type": "string", "format": "date-time" } + "proof": { "type": "string", "minLength": 1, "maxLength": 4096, "writeOnly": true } }, - "required": ["proof", "at"] + "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/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts index 8bebc25a..04256dbf 100644 --- a/services/api/src/features/iam/api/mfa.controller.ts +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -60,7 +60,7 @@ export class MfaController { const mfa = this.requireService(); const context = await this.requestContext.resolve(request); const result = await this.execute(() => - mfa.verifyFactor(context.actorId, factorId, input.proof, input.at), + mfa.verifyFactor(context.actorId, factorId, input.proof), ); if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); return result.value; @@ -77,7 +77,7 @@ export class MfaController { const mfa = this.requireService(); const context = await this.requestContext.resolve(request); const result = await this.execute(() => - mfa.redeemRecovery(context.actorId, input.presentedDigest, input.at), + 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 6c55bd37..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,14 +15,6 @@ 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 { @@ -39,10 +23,6 @@ export class VerifyMfaFactorDto { @MinLength(1) @MaxLength(4096) proof!: string; - - @ApiProperty({ format: 'date-time' }) - @IsISO8601() - at!: string; } export class RedeemMfaRecoveryCodeDto { @@ -51,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/application/mfa.service.ts b/services/api/src/features/iam/application/mfa.service.ts index 526fd48a..f9f5519c 100644 --- a/services/api/src/features/iam/application/mfa.service.ts +++ b/services/api/src/features/iam/application/mfa.service.ts @@ -86,12 +86,16 @@ export class MfaService { 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); @@ -113,7 +117,6 @@ export class MfaService { userIdInput: unknown, factorIdInput: unknown, proofInput: unknown, - at: unknown, ): Promise> { const userId = stable(userIdInput); const factorId = stable(factorIdInput); @@ -134,7 +137,7 @@ export class MfaService { }); if (!verified) return Object.freeze({ accepted: false as const, code: 'FACTOR_PROOF_INVALID' as const }); - const transitioned = transitionMfaFactorV1(factor, 'VERIFY', at); + const transitioned = transitionMfaFactorV1(factor, 'VERIFY', this.clock().toISOString()); if (!transitioned.accepted) return Object.freeze({ accepted: false, code: transitioned.code }); const next = Object.freeze({ @@ -149,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' }); @@ -157,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 5f66bed1..8efcbc89 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -89,6 +89,7 @@ export interface IamModuleOptions { readonly mfaDatabase?: MfaDatabaseClientV1; readonly mfaService?: MfaService; readonly mfaFactorProofVerifier?: MfaFactorProofVerifierV1; + readonly mfaClock?: () => Date; readonly recoveryCodeMatcher?: { matches(presentedDigest: string, storedDigest: string): boolean; }; @@ -157,6 +158,7 @@ export class IamModule { matches: constantTimeRecoveryCodeMatchV1, }, options.mfaFactorProofVerifier ?? new UnavailableMfaFactorProofVerifier(), + options.mfaClock, )); const iamRepository = options.iamRepository ?? diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts index eb0a09d7..4e00f2f0 100644 --- a/services/api/test/features/iam/mfa.service.test.ts +++ b/services/api/test/features/iam/mfa.service.test.ts @@ -22,38 +22,27 @@ void test('[IAM-012, IAM-013, IAM-014] MFA enrollment and verification are revis { 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 invalidProof = await service.verifyFactor( - userId, - factorId, - '000000', - '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', - '2026-01-01T00:01:00.000Z', - ); + 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, - '654321', - '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' }); }); @@ -63,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', }); diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index a20042fa..d7e1773e 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -725,6 +725,7 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con { verify: ({ proof }) => Promise.resolve(proof === '654321'), }, + () => new Date('2026-01-01T00:00:00.000Z'), ); const contextResult = createIamTenantContextV1({ tenantScope: { @@ -741,6 +742,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', @@ -748,7 +761,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); @@ -761,7 +773,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: { proof: '654321', at: '2026-01-01T00:01:00.000Z' }, + payload: { proof: '654321' }, }); assert.equal(verified.statusCode, 200); const verifiedBody = parsedBody<{ readonly factors: readonly [{ readonly status: string }] }>( @@ -772,7 +784,7 @@ 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: { proof: '654321', at: '2026-01-01T00:02:00.000Z' }, + payload: { proof: '654321' }, }); assertProblem(invalid, 400, 'MFA_REQUEST_REJECTED'); }); @@ -788,7 +800,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', }, }); assertProblem(response, 503, 'MFA_UNAVAILABLE'); From 85d60cc5ea20e21bd0081f7b40740309c2cc78e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:29:12 +0700 Subject: [PATCH 43/59] fix(http): require mutation idempotency keys --- .../http/session-tenant-context.adapter.ts | 4 ++++ services/api/test/http-contract.test.ts | 16 +++++++++++++--- .../http/session-tenant-context.test.ts | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) 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 4fc088aa..a0852f39 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -18,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; @@ -58,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(); } diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index d7e1773e..2d2fc3a4 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -501,6 +501,7 @@ void test('sign-out revokes idempotently and clears browser credentials', async 'x-csrf-token': csrfToken, origin: 'http://localhost:3000', authorization: 'Bearer sign-out-access-token', + 'idempotency-key': 'sign-out-web-001', }, payload: { clientPlatform: 'web', @@ -520,7 +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' }, + headers: { + authorization: 'Bearer sign-out-access-token', + 'idempotency-key': 'sign-out-native-001', + }, payload: { clientPlatform: 'android', sessionId: '00000000-0000-4000-8000-000000000011', @@ -536,7 +540,10 @@ void test('sign-out revokes idempotently and clears browser credentials', async const crossUser = await app.inject({ method: 'POST', url: '/v1/auth/sign-out', - headers: { authorization: 'Bearer sign-out-access-token' }, + headers: { + authorization: 'Bearer sign-out-access-token', + 'idempotency-key': 'sign-out-cross-user-001', + }, payload: { clientPlatform: 'android', sessionId: '00000000-0000-4000-8000-000000000099', @@ -561,7 +568,10 @@ void test('sign-out revokes idempotently and clears browser credentials', async const response = await app.inject({ method: 'POST', url: '/v1/auth/sign-out', - headers: { authorization: 'Bearer sign-out-access-token' }, + headers: { + authorization: 'Bearer sign-out-access-token', + 'idempotency-key': 'sign-out-unavailable-001', + }, payload: { clientPlatform: 'android', sessionId: '00000000-0000-4000-8000-000000000011', 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 cb8a6eaf..6e6cc77b 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -73,6 +73,25 @@ void test('uses the request id for read-only calls and rejects unsafe principal await assert.rejects( adapter.resolve({ id: 'request-read-001', + method: 'GET', + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }), + (error: unknown) => { + 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) => { From cf479892737574c3ef6b06943303d14a1df80e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:31:52 +0700 Subject: [PATCH 44/59] fix(iam): fail closed on missing active refresh token --- .../prisma-session-lifecycle.adapter.ts | 44 +++++++++++++------ .../iam/prisma-session-lifecycle.test.ts | 15 +++++++ 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts index 468cc9e1..139a59e4 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 @@ -107,6 +107,7 @@ interface RefreshTokenDelegateV1 { }): Promise; findMany(input: { readonly where: Readonly>; + readonly orderBy?: Readonly>; }): Promise; updateMany(input: { readonly where: Readonly>; @@ -237,6 +238,26 @@ 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 }, + }); + } + public async issue( principal: AuthenticatedPrincipalV1, clientPlatform: 'android' | 'desktop' | 'web', @@ -330,32 +351,27 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { }); if (!sessionRow) return { accepted: false, code: 'INVALID_REFRESH_TOKEN' }; const session = sessionFromRow(sessionRow); + if (session.status !== 'ACTIVE') return { accepted: false, code: 'REVOKED_FAMILY' }; 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' }, 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 42b5f762..6826fa00 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -194,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(); From a5478ae8847d7b155f3d77a967996e742d994b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:32:45 +0700 Subject: [PATCH 45/59] fix(iam): enforce session inactivity on refresh --- .../prisma-session-lifecycle.adapter.ts | 29 ++++++++++++++++++- .../iam/prisma-session-lifecycle.test.ts | 16 ++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) 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 139a59e4..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 @@ -258,6 +258,25 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { }); } + 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', @@ -351,7 +370,15 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { }); if (!sessionRow) return { accepted: false, code: 'INVALID_REFRESH_TOKEN' }; const session = sessionFromRow(sessionRow); - if (session.status !== 'ACTIVE') return { accepted: false, code: 'REVOKED_FAMILY' }; + 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' }, 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 6826fa00..b08dd7fc 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -225,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); From 0483b4b2b19f294a2931777c211c0097256259a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:34:13 +0700 Subject: [PATCH 46/59] fix(api): expose native refresh response tokens --- services/api/openapi/v1.json | 7 +------ .../src/features/iam/api/session-refresh-response.dto.ts | 2 +- services/api/test/openapi.test.ts | 7 +++++++ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 0d2e76f4..2b7caa06 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -7353,12 +7353,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"] 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/test/openapi.test.ts b/services/api/test/openapi.test.ts index cf1a55d7..124a8a89 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -178,6 +178,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 ?? []) From d83eeb9b8068cdf513ee5218a4a1698cc0a6abfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:36:46 +0700 Subject: [PATCH 47/59] fix(iam): type transaction-scoped membership clients --- .../iam/adapter/prisma-iam-repository.adapter.ts | 9 ++++++--- .../api/test/features/iam/prisma-iam-repository.test.ts | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts index 3c906e16..e8001208 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 @@ -44,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; } @@ -124,7 +127,7 @@ function scopeSpecificity(scope: TenantScopeV1): number { } class PrismaIamTransactionAdapter implements IamTransactionPortV1 { - public constructor(private readonly client: IamDatabaseClientV1) {} + public constructor(private readonly client: IamTransactionDatabaseClientV1) {} public async findMembership( context: IamTenantContextV1, 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 64527aeb..64c3054d 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'; @@ -111,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); From 222910ae41882d512b9e991f4c6d62649eb7b8ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:40:24 +0700 Subject: [PATCH 48/59] fix(iam): resolve personal bootstrap deterministically --- ...a-identity-bootstrap-repository.adapter.ts | 232 +++++++++++------- ...isma-identity-bootstrap-repository.test.ts | 56 +++++ 2 files changed, 201 insertions(+), 87 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts index 350fdc55..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 { @@ -114,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, @@ -138,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 { @@ -198,53 +263,46 @@ 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 { 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 6af14a07..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 @@ -186,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); From a5ba31bc779bbf33f4a485e6a75f405a00e3d753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:43:15 +0700 Subject: [PATCH 49/59] feat(aud): verify independently paged event digests --- packages/domain/src/audit/v1.ts | 16 ++++++++++++---- packages/domain/test/audit-v1.test.mjs | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) 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/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' }, + ); +}); From 72064d69d950699fd6824cdbfddf32b8eae6c283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:46:00 +0700 Subject: [PATCH 50/59] feat(aud): bind opaque page cursors to tenant scope --- .../aud/application/audit-page-cursor.ts | 64 +++++++++++++++++++ .../features/aud/audit-page-cursor.test.ts | 51 +++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 services/api/src/features/aud/application/audit-page-cursor.ts create mode 100644 services/api/test/features/aud/audit-page-cursor.test.ts 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/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', + }); + } +}); From bbca81c436f458deaa05c7f0537224e58eafae78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:52:25 +0700 Subject: [PATCH 51/59] feat(aud): paginate public audit reads --- services/api/openapi/v1.json | 24 ++++ .../in-memory-audit-repository.adapter.ts | 75 ++++++++++++ .../prisma-audit-repository.adapter.ts | 107 +++++++++++++++++- .../src/features/aud/api/audit.controller.ts | 51 ++++++++- .../aud/application/audit-repository.port.ts | 18 +++ .../aud/prisma-audit-repository.test.ts | 66 ++++++++--- services/api/test/http-contract.test.ts | 22 +++- 7 files changed, 336 insertions(+), 27 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 2b7caa06..a5706833 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -6747,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", @@ -6828,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", 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 ed1584fa..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,15 @@ 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'; @@ -33,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(); @@ -76,6 +95,34 @@ 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, @@ -115,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, 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 5a717d10..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,15 @@ 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 { @@ -79,7 +86,11 @@ interface AuditEventDelegateV1 { }): Promise; findMany(input: { readonly where: Readonly>; - readonly orderBy: { readonly sequence: 'asc' | 'desc' }; + readonly orderBy: + | Readonly> + | readonly Readonly>[]; + readonly skip?: number; + readonly take?: number; }): Promise; } @@ -90,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; } @@ -269,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, @@ -392,10 +438,67 @@ 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, diff --git a/services/api/src/features/aud/api/audit.controller.ts b/services/api/src/features/aud/api/audit.controller.ts index 668709c8..96cc053d 100644 --- a/services/api/src/features/aud/api/audit.controller.ts +++ b/services/api/src/features/aud/api/audit.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Inject, Req } from '@nestjs/common'; +import { Controller, Get, Inject, Query, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiOperation, + ApiQuery, ApiServiceUnavailableResponse, ApiTags, } from '@nestjs/swagger'; @@ -16,6 +17,16 @@ import { 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() @@ -29,11 +40,26 @@ export class AuditController { @Get('events') @ApiOperation({ summary: 'List immutable audit events visible to the caller' }) @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): Promise { + async events( + @Req() request: unknown, + @Query('limit') limitInput?: string, + @Query('cursor') cursor?: string, + ): Promise { const context = await this.requestContext.resolve(request); + 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.listEvents(context); + return await this.repository.listEventPage(context, { + limit, + ...(cursor === undefined ? {} : { cursor }), + }); } catch { throw new AuditProblemError('AUDIT_UNAVAILABLE'); } @@ -42,11 +68,26 @@ export class AuditController { @Get('seals') @ApiOperation({ summary: 'List verified audit seals visible to the caller' }) @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): Promise { + async seals( + @Req() request: unknown, + @Query('limit') limitInput?: string, + @Query('cursor') cursor?: string, + ): Promise { const context = await this.requestContext.resolve(request); + 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.listSeals(context); + 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-repository.port.ts b/services/api/src/features/aud/application/audit-repository.port.ts index b513ed64..ac4650f3 100644 --- a/services/api/src/features/aud/application/audit-repository.port.ts +++ b/services/api/src/features/aud/application/audit-repository.port.ts @@ -5,6 +5,16 @@ 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; @@ -17,6 +27,14 @@ export interface AuditTransactionPortV1 { } 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/test/features/aud/prisma-audit-repository.test.ts b/services/api/test/features/aud/prisma-audit-repository.test.ts index 273ebad2..c860be20 100644 --- a/services/api/test/features/aud/prisma-audit-repository.test.ts +++ b/services/api/test/features/aud/prisma-audit-repository.test.ts @@ -33,6 +33,18 @@ 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 }; @@ -47,9 +59,7 @@ function delegate>( readonly orderBy?: Readonly>; }) { firstQueries.push(where); - const matching = rows.filter((row) => - Object.entries(where).every(([key, value]) => row[key] === value), - ); + const matching = rows.filter((row) => matches(row, where)); const [field, direction] = Object.entries(orderBy ?? {})[0] ?? []; if (field) { matching.sort((left, right) => { @@ -63,23 +73,33 @@ function delegate>( 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 = Array.isArray(orderBy) ? orderBy : [orderBy]; 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), ); }, }; @@ -140,6 +160,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); @@ -154,6 +184,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); }); diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 2d2fc3a4..364c38cf 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -633,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', @@ -641,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', @@ -690,8 +704,8 @@ void test('protected artifact reads derive tenant scope from an authenticated ac void test('audit read outages return retryable service-unavailable problems', async () => { const auditRepository = Object.assign(new InMemoryAuditRepositoryAdapter(), { - listEvents: () => Promise.reject(new Error(`database ${leakedMarker}`)), - listSeals: () => Promise.reject(new Error(`database ${leakedMarker}`)), + listEventPage: () => Promise.reject(new Error(`database ${leakedMarker}`)), + listSealPage: () => Promise.reject(new Error(`database ${leakedMarker}`)), }); const principal = { userId: '00000000-0000-4000-8000-000000000001', From 259c92a87845263181d30a745d205cdf17837133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:55:32 +0700 Subject: [PATCH 52/59] fix(iam): skip malformed membership read rows --- .../adapter/prisma-iam-repository.adapter.ts | 16 ++++++++++++++-- .../features/iam/prisma-iam-repository.test.ts | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts index e8001208..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 @@ -98,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, @@ -142,7 +152,8 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { orderBy: { id: 'asc' }, }); return rows - .map(membershipFromRow) + .map(membershipFromRowOrSkip) + .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined) .filter( (membership) => membership.principalId === principalId && @@ -164,7 +175,8 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { orderBy: { id: 'asc' }, }); return rows - .map(membershipFromRow) + .map(membershipFromRowOrSkip) + .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined) .filter((membership) => visibleInScope(context.tenantScope, membership.scope)); } 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 64c3054d..942780ff 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -148,6 +148,24 @@ 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', From 21eb8253ec673d9df3d54e6764d30933e779776b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 14:56:19 +0700 Subject: [PATCH 53/59] test(api): require bearer security on protected operations --- services/api/test/openapi.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 124a8a89..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>; @@ -200,6 +201,29 @@ 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`); From a17772f4b9dd3e6ed8983d3e58272bf4222b4974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 15:01:05 +0700 Subject: [PATCH 54/59] docs(review): record PR 29 dispositions --- .../coderabbit-pr-29-disposition.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/operations/coderabbit-pr-29-disposition.md 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. From e13e3680c23caf6dff78f0080abbf5e912585f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 15:04:16 +0700 Subject: [PATCH 55/59] test(aud): type pagination ordering fixtures --- .../api/test/features/aud/prisma-audit-repository.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 c860be20..33f7da45 100644 --- a/services/api/test/features/aud/prisma-audit-repository.test.ts +++ b/services/api/test/features/aud/prisma-audit-repository.test.ts @@ -84,7 +84,11 @@ function delegate>( readonly take?: number; }) { const filtered = rows.filter((row) => matches(row, where)); - const ordering = Array.isArray(orderBy) ? orderBy : [orderBy]; + const ordering: readonly Readonly< + Record + >[] = Array.isArray(orderBy) + ? (orderBy as readonly Readonly>[]) + : [orderBy as Readonly>]; return Promise.resolve( [...filtered] .sort((left, right) => { From 0c86fdc102cb7c19eb2ebde84fcc68209a00630a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 15:05:16 +0700 Subject: [PATCH 56/59] test(prisma): inventory BUA project scope migration --- services/api/test/prisma-foundation.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index fc538be6..f71da3a0 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -123,6 +123,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '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( From 703cc1f332cb29a30b2f29f173711592b8415e90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 15:05:57 +0700 Subject: [PATCH 57/59] style(aud): format pagination fixture types --- .../api/test/features/aud/prisma-audit-repository.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 33f7da45..9a6ac157 100644 --- a/services/api/test/features/aud/prisma-audit-repository.test.ts +++ b/services/api/test/features/aud/prisma-audit-repository.test.ts @@ -84,9 +84,7 @@ function delegate>( readonly take?: number; }) { const filtered = rows.filter((row) => matches(row, where)); - const ordering: readonly Readonly< - Record - >[] = Array.isArray(orderBy) + const ordering: readonly Readonly>[] = Array.isArray(orderBy) ? (orderBy as readonly Readonly>[]) : [orderBy as Readonly>]; return Promise.resolve( From ad62f4534526517422a31398f0dd0ebd1ecdbe62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 15:16:32 +0700 Subject: [PATCH 58/59] fix(local): make first-run stack startup reliable --- infrastructure/local/.env.example | 2 +- infrastructure/local/compose.yml | 2 +- tools/repo-cli/src/local-services.mjs | 20 +++++++++++++------ .../test/local-infrastructure.test.mjs | 13 +++++++++++- 4 files changed, 28 insertions(+), 9 deletions(-) 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/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/local-infrastructure.test.mjs b/tools/repo-cli/test/local-infrastructure.test.mjs index 084cad42..14376cad 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/); From 0617995ebf8b57e1df2fa552c0070f2798266aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 15:19:13 +0700 Subject: [PATCH 59/59] fix(local): preserve Linux bootstrap line endings --- .gitattributes | 1 + infrastructure/local/minio/bootstrap-buckets.sh | 1 + tools/repo-cli/test/local-infrastructure.test.mjs | 2 ++ 3 files changed, 4 insertions(+) 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/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/tools/repo-cli/test/local-infrastructure.test.mjs b/tools/repo-cli/test/local-infrastructure.test.mjs index 14376cad..49fe61db 100644 --- a/tools/repo-cli/test/local-infrastructure.test.mjs +++ b/tools/repo-cli/test/local-infrastructure.test.mjs @@ -90,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);