@open-mercato/shared 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -1
  3. package/dist/lib/auth/jwt.js +6 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/mfaPendingAccess.js +42 -0
  6. package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
  7. package/dist/lib/auth/organizationAccess.js +7 -4
  8. package/dist/lib/auth/organizationAccess.js.map +2 -2
  9. package/dist/lib/auth/principal-service.js +1 -0
  10. package/dist/lib/auth/principal-service.js.map +7 -0
  11. package/dist/lib/auth/server.js +38 -7
  12. package/dist/lib/auth/server.js.map +2 -2
  13. package/dist/lib/commands/command-bus.js +6 -1
  14. package/dist/lib/commands/command-bus.js.map +2 -2
  15. package/dist/lib/crud/factory.js +24 -8
  16. package/dist/lib/crud/factory.js.map +2 -2
  17. package/dist/lib/data/engine.js +8 -2
  18. package/dist/lib/data/engine.js.map +2 -2
  19. package/dist/lib/html/htmlToPlainText.js +16 -0
  20. package/dist/lib/html/htmlToPlainText.js.map +7 -0
  21. package/dist/lib/location/countries.js +12 -0
  22. package/dist/lib/location/countries.js.map +2 -2
  23. package/dist/lib/openapi/crud.js +4 -1
  24. package/dist/lib/openapi/crud.js.map +2 -2
  25. package/dist/lib/query/count-cap.js +11 -0
  26. package/dist/lib/query/count-cap.js.map +7 -0
  27. package/dist/lib/query/engine.js +270 -34
  28. package/dist/lib/query/engine.js.map +3 -3
  29. package/dist/lib/query/types.js.map +1 -1
  30. package/dist/lib/queue/dispatchOrigin.js +20 -0
  31. package/dist/lib/queue/dispatchOrigin.js.map +7 -0
  32. package/dist/lib/search/config.js +1 -0
  33. package/dist/lib/search/config.js.map +2 -2
  34. package/dist/lib/search/entityAccess.js +44 -0
  35. package/dist/lib/search/entityAccess.js.map +7 -0
  36. package/dist/lib/version.js +1 -1
  37. package/dist/lib/version.js.map +1 -1
  38. package/dist/modules/events/factory.js +69 -15
  39. package/dist/modules/events/factory.js.map +2 -2
  40. package/dist/modules/registry.js +15 -0
  41. package/dist/modules/registry.js.map +2 -2
  42. package/dist/modules/widgets/component-registry.js.map +2 -2
  43. package/package.json +10 -3
  44. package/src/lib/auth/__tests__/jwt.test.ts +13 -0
  45. package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
  46. package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
  47. package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
  48. package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
  49. package/src/lib/auth/__tests__/server.test.ts +104 -0
  50. package/src/lib/auth/jwt.ts +17 -0
  51. package/src/lib/auth/mfaPendingAccess.ts +70 -0
  52. package/src/lib/auth/organizationAccess.ts +11 -3
  53. package/src/lib/auth/principal-service.ts +110 -0
  54. package/src/lib/auth/server.ts +78 -8
  55. package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
  56. package/src/lib/commands/command-bus.ts +8 -1
  57. package/src/lib/crud/__tests__/crud-factory.test.ts +165 -0
  58. package/src/lib/crud/factory.ts +33 -7
  59. package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
  60. package/src/lib/data/engine.ts +7 -1
  61. package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
  62. package/src/lib/html/htmlToPlainText.ts +17 -0
  63. package/src/lib/location/__tests__/countries.test.ts +15 -0
  64. package/src/lib/location/countries.ts +17 -0
  65. package/src/lib/openapi/crud.ts +3 -0
  66. package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
  67. package/src/lib/query/__tests__/count-cap.test.ts +41 -0
  68. package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
  69. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
  70. package/src/lib/query/__tests__/engine.test.ts +445 -7
  71. package/src/lib/query/count-cap.ts +19 -0
  72. package/src/lib/query/engine.ts +434 -54
  73. package/src/lib/query/types.ts +15 -0
  74. package/src/lib/queue/dispatchOrigin.ts +35 -0
  75. package/src/lib/search/config.ts +10 -0
  76. package/src/lib/search/entityAccess.ts +132 -0
  77. package/src/modules/events/__tests__/factory.test.ts +88 -0
  78. package/src/modules/events/factory.ts +111 -19
  79. package/src/modules/events/types.ts +17 -0
  80. package/src/modules/registry.ts +40 -0
  81. package/src/modules/widgets/component-registry.ts +14 -0
@@ -10,6 +10,7 @@ jest.mock('next/headers', () => ({
10
10
  }))
11
11
 
12
12
  jest.mock('@open-mercato/shared/lib/auth/jwt', () => ({
13
+ ...jest.requireActual('@open-mercato/shared/lib/auth/jwt'),
13
14
  verifyJwt: (...args: unknown[]) => verifyJwt(...args),
14
15
  }))
15
16
 
@@ -162,6 +163,109 @@ describe('auth server integrity checks', () => {
162
163
  await expect(getAuthFromRequest(request)).resolves.toBeNull()
163
164
  })
164
165
 
166
+ describe('mfa-pending staff tokens', () => {
167
+ const pendingAuth = {
168
+ sub: '11111111-1111-4111-8111-111111111111',
169
+ sid: '44444444-4444-4444-8444-444444444444',
170
+ tenantId: '22222222-2222-4222-8222-222222222222',
171
+ orgId: '33333333-3333-4333-8333-333333333333',
172
+ roles: ['admin'],
173
+ mfa_pending: true,
174
+ mfa_verified: false,
175
+ }
176
+
177
+ function mfaPendingRequest(path: string, method: string): Request {
178
+ return new Request(`https://example.test${path}`, {
179
+ method,
180
+ headers: { authorization: `Bearer pending-token` },
181
+ })
182
+ }
183
+
184
+ beforeEach(async () => {
185
+ // The registry ships empty; completion routes are registered by their owning module
186
+ // (enterprise security). Register equivalent fixtures to exercise resolver mechanics.
187
+ const { registerMfaPendingAccessRoutes } = await import('../mfaPendingAccess')
188
+ registerMfaPendingAccessRoutes([
189
+ { path: '/api/security/mfa/prepare', methods: ['POST'] },
190
+ { path: '/api/security/mfa/verify', methods: ['POST'] },
191
+ { path: '/api/security/mfa/recovery', methods: ['POST'] },
192
+ ])
193
+ verifyJwt.mockReturnValue(pendingAuth)
194
+ resolveCanonicalStaffAuthContext.mockResolvedValue({ ...pendingAuth })
195
+ })
196
+
197
+ it('rejects a pending token on a general staff API before roles are restored', async () => {
198
+ const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
199
+
200
+ await expect(resolveAuthFromRequestDetailed(mfaPendingRequest('/api/customers/people', 'GET')))
201
+ .resolves.toEqual({ auth: null, status: 'invalid' })
202
+ expect(resolveCanonicalStaffAuthContext).not.toHaveBeenCalled()
203
+ })
204
+
205
+ it('still resolves a pending token on the registered MFA completion routes (POST)', async () => {
206
+ const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
207
+
208
+ for (const path of ['/api/security/mfa/prepare', '/api/security/mfa/verify', '/api/security/mfa/recovery']) {
209
+ resolveCanonicalStaffAuthContext.mockClear()
210
+ const resolution = await resolveAuthFromRequestDetailed(mfaPendingRequest(path, 'POST'))
211
+ expect(resolution.status).toBe('authenticated')
212
+ expect(resolution.auth).toEqual({ ...pendingAuth })
213
+ expect(resolveCanonicalStaffAuthContext).toHaveBeenCalledWith(em, pendingAuth)
214
+ }
215
+ })
216
+
217
+ it('rejects a pending token with a non-completion method on a completion route', async () => {
218
+ const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
219
+
220
+ await expect(resolveAuthFromRequestDetailed(mfaPendingRequest('/api/security/mfa/verify', 'GET')))
221
+ .resolves.toEqual({ auth: null, status: 'invalid' })
222
+ expect(resolveCanonicalStaffAuthContext).not.toHaveBeenCalled()
223
+ })
224
+
225
+ it('resolves the verified replacement token like a normal login', async () => {
226
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
227
+ const verifiedAuth = { ...pendingAuth, mfa_pending: false, mfa_verified: true }
228
+ verifyJwt.mockReturnValue(verifiedAuth)
229
+ resolveCanonicalStaffAuthContext.mockResolvedValue({ ...verifiedAuth, roles: ['employee'] })
230
+
231
+ const request = new Request('https://example.test/api/customers/people', {
232
+ headers: { authorization: 'Bearer verified-token' },
233
+ })
234
+
235
+ await expect(getAuthFromRequest(request)).resolves.toEqual({ ...verifiedAuth, roles: ['employee'] })
236
+ expect(resolveCanonicalStaffAuthContext).toHaveBeenCalledWith(em, verifiedAuth)
237
+ })
238
+
239
+ it('leaves tokens without MFA claims untouched', async () => {
240
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
241
+ const plainAuth = {
242
+ sub: '11111111-1111-4111-8111-111111111111',
243
+ tenantId: '22222222-2222-4222-8222-222222222222',
244
+ orgId: '33333333-3333-4333-8333-333333333333',
245
+ roles: [],
246
+ }
247
+ verifyJwt.mockReturnValue(plainAuth)
248
+ resolveCanonicalStaffAuthContext.mockResolvedValue(plainAuth)
249
+
250
+ const request = new Request('https://example.test/api/test', {
251
+ headers: { cookie: 'auth_token=jwt-token' },
252
+ })
253
+
254
+ await expect(getAuthFromRequest(request)).resolves.toEqual(plainAuth)
255
+ })
256
+
257
+ it('rejects a pending token from cookie-based page resolution unconditionally', async () => {
258
+ const { getAuthFromCookies, resolveAuthFromCookiesDetailed } = await import('@open-mercato/shared/lib/auth/server')
259
+ cookieStore.get.mockImplementation((name: string) => {
260
+ if (name === 'auth_token') return { value: 'pending-jwt-token' }
261
+ return undefined
262
+ })
263
+
264
+ await expect(getAuthFromCookies()).resolves.toBeNull()
265
+ await expect(resolveAuthFromCookiesDetailed()).resolves.toEqual({ auth: null, status: 'invalid' })
266
+ expect(resolveCanonicalStaffAuthContext).not.toHaveBeenCalled()
267
+ })
268
+ })
165
269
  it('reports a transient DB failure on the api-key path as "error" (retryable 503)', async () => {
166
270
  const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
167
271
  // Distinct secret so the shared api-key auth cache does not serve a prior miss.
@@ -377,3 +377,20 @@ export function signAudienceJwt(
377
377
  export function verifyAudienceJwt(audience: string, token: string): JwtPayload | null {
378
378
  return verifyJwt(token, { audience })
379
379
  }
380
+
381
+ /**
382
+ * True when a verified JWT payload represents an MFA challenge in progress: the second factor
383
+ * has not been completed yet (`mfa_pending: true`, `mfa_verified` not `true`). Such tokens are
384
+ * provisional — general staff APIs must reject them and only the MFA completion routes may
385
+ * accept them (see `@open-mercato/shared/lib/auth/mfaPendingAccess`).
386
+ *
387
+ * The predicate keys on `mfa_pending` alone by design. `mfa_verified` is NOT a durable session
388
+ * property: routes that legitimately re-sign a staff token (`PUT /api/auth/profile`,
389
+ * `GET|POST /api/auth/session/refresh`) rebuild the claims without it. Tightening this to require
390
+ * `mfa_verified === true` would therefore silently downgrade those sessions.
391
+ */
392
+ export function isMfaPendingJwtPayload(payload: unknown): boolean {
393
+ if (!payload || typeof payload !== 'object') return false
394
+ const record = payload as Record<string, unknown>
395
+ return record.mfa_pending === true && record.mfa_verified !== true
396
+ }
@@ -0,0 +1,70 @@
1
+ export type MfaPendingAccessRoute = {
2
+ path: string
3
+ methods: string[]
4
+ }
5
+
6
+ const registeredMethodsByPath = new Map<string, Set<string>>()
7
+
8
+ function normalizePath(path: string): string | null {
9
+ if (typeof path !== 'string') return null
10
+ const trimmed = path.trim()
11
+ if (!trimmed || !trimmed.startsWith('/')) return null
12
+ return (trimmed.length > 1 ? trimmed.replace(/\/+$/, '') : trimmed).toLowerCase() || '/'
13
+ }
14
+
15
+ function normalizeMethods(methods: unknown): string[] {
16
+ if (!Array.isArray(methods)) return []
17
+ const normalized = methods
18
+ .filter((method): method is string => typeof method === 'string' && method.trim().length > 0)
19
+ .map((method) => method.trim().toUpperCase())
20
+ return Array.from(new Set(normalized)).sort((first, second) => (first < second ? -1 : first > second ? 1 : 0))
21
+ }
22
+
23
+ /**
24
+ * Register routes that may accept an MFA-pending staff token. The registry starts empty and
25
+ * stays generic: every module that completes an MFA challenge on its own endpoints registers
26
+ * them here during its bootstrap — the canonical `/api/security/mfa/*` completion routes are
27
+ * registered by the enterprise `security` module (`lib/mfaCompletionRoutes.ts`), third-party
28
+ * MFA implementations register theirs the same way. Registration is additive: methods merge
29
+ * into any existing entry for the same path and are never removed.
30
+ *
31
+ * Contract defined in `.ai/specs/enterprise/implemented/SPEC-ENT-007-2026-03-06-auth-login-interceptors-extension.md`
32
+ * (§ Amendment 2026-08-21 — central MFA-pending token gate).
33
+ */
34
+ export function registerMfaPendingAccessRoutes(routes: MfaPendingAccessRoute[]): void {
35
+ for (const route of Array.isArray(routes) ? routes : []) {
36
+ const path = normalizePath(route?.path ?? '')
37
+ const methods = normalizeMethods(route?.methods)
38
+ if (!path || !methods.length) continue
39
+ const existing = registeredMethodsByPath.get(path)
40
+ if (!existing) {
41
+ registeredMethodsByPath.set(path, new Set(methods))
42
+ continue
43
+ }
44
+ for (const method of methods) existing.add(method)
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Fail-closed check deciding whether an MFA-pending staff token may be resolved for this exact
50
+ * method + path pair. Only explicitly registered completion routes pass; every unregistered
51
+ * request is denied.
52
+ */
53
+ export function isMfaPendingAccessAllowed(
54
+ method: string | null | undefined,
55
+ pathname: string | null | undefined,
56
+ ): boolean {
57
+ const path = normalizePath(typeof pathname === 'string' ? pathname : '')
58
+ if (!path) return false
59
+ if (typeof method !== 'string' || method.trim().length === 0) return false
60
+ const methods = registeredMethodsByPath.get(path)
61
+ if (!methods) return false
62
+ return methods.has(method.trim().toUpperCase())
63
+ }
64
+
65
+ /** Test/ops helper: current snapshot of the pending-access registry. */
66
+ export function listMfaPendingAccessRoutes(): ReadonlyArray<Readonly<MfaPendingAccessRoute>> {
67
+ return Array.from(registeredMethodsByPath.entries())
68
+ .map(([path, methods]) => ({ path, methods: Array.from(methods).sort((first, second) => (first < second ? -1 : first > second ? 1 : 0)) }))
69
+ .sort((first, second) => (first.path < second.path ? -1 : first.path > second.path ? 1 : 0))
70
+ }
@@ -4,6 +4,15 @@ export type OrganizationAccessDecisionInput = {
4
4
  targetOrganizationId: string | null
5
5
  }
6
6
 
7
+ export type UnrestrictedOrganizationScopeInput = {
8
+ isSuperAdmin: boolean
9
+ allowedOrganizationIds: readonly string[] | null | undefined
10
+ }
11
+
12
+ export function isUnrestrictedOrganizationScope(input: UnrestrictedOrganizationScopeInput): boolean {
13
+ return input.isSuperAdmin || input.allowedOrganizationIds === null
14
+ }
15
+
7
16
  /**
8
17
  * Fail-closed organization-access predicate. The single source of truth for
9
18
  * "may this principal act on `targetOrganizationId`".
@@ -15,8 +24,7 @@ export type OrganizationAccessDecisionInput = {
15
24
  * - restricted + target org -> allow iff the target is a member of the allowed set
16
25
  */
17
26
  export function isOrganizationAccessAllowed(input: OrganizationAccessDecisionInput): boolean {
18
- if (input.isSuperAdmin) return true
19
- if (input.allowedOrganizationIds === null) return true
27
+ if (isUnrestrictedOrganizationScope(input)) return true
20
28
  if (!input.targetOrganizationId) return false
21
- return input.allowedOrganizationIds.includes(input.targetOrganizationId)
29
+ return (input.allowedOrganizationIds ?? []).includes(input.targetOrganizationId)
22
30
  }
@@ -0,0 +1,110 @@
1
+ import type { AuthContext } from './server'
2
+
3
+ export type PrincipalScope = {
4
+ tenantId: string
5
+ organizationId: string
6
+ }
7
+
8
+ export type AuthPrincipalType = 'user' | 'role'
9
+
10
+ export type AuthPrincipalLabel = {
11
+ id: string
12
+ label: string
13
+ secondary: string | null
14
+ }
15
+
16
+ export type AuthPrincipalRolePage = {
17
+ items: AuthPrincipalLabel[]
18
+ page: number
19
+ pageSize: number
20
+ total: number
21
+ }
22
+
23
+ /** Public, request-scoped read boundary owned by the Auth module. */
24
+ export interface AuthPrincipalService {
25
+ principalExists(input: {
26
+ type: AuthPrincipalType
27
+ id: string
28
+ scope: PrincipalScope
29
+ }): Promise<boolean>
30
+ resolveActiveUserRoleIds(userId: string, scope: PrincipalScope): Promise<string[]>
31
+ filterActiveRoleIds(roleIds: string[], scope: PrincipalScope): Promise<string[]>
32
+ resolveLabels(input: {
33
+ type: AuthPrincipalType
34
+ ids: string[]
35
+ scope: PrincipalScope
36
+ }): Promise<AuthPrincipalLabel[]>
37
+ /**
38
+ * Optional additive capability for organization-eligible role pickers.
39
+ * Implementations must apply eligibility before pagination and bound the
40
+ * advertised result window so sparse ACL matches cannot amplify requests.
41
+ */
42
+ queryActiveRolePage?(input: {
43
+ scope: PrincipalScope
44
+ search?: string
45
+ excludedIds?: string[]
46
+ page: number
47
+ pageSize: number
48
+ }): Promise<AuthPrincipalRolePage>
49
+ listSuperAdminUserIds(tenantId: string): Promise<string[]>
50
+ }
51
+
52
+ /** Public, request-scoped read boundary owned by the API Keys module. */
53
+ export interface ApiKeyPrincipalService {
54
+ resolveAssignedRoleIds(apiKeyId: string, scope: PrincipalScope): Promise<string[]>
55
+ }
56
+
57
+ export type OrganizationScope = {
58
+ selectedId: string | null
59
+ filterIds: string[] | null
60
+ allowedIds: string[] | null
61
+ tenantId: string | null
62
+ // True when an explicit organization selection could not be honored. Reads
63
+ // fall back to the caller's accessible organizations; writes must fail.
64
+ selectionRejected?: boolean
65
+ }
66
+
67
+ export type OrganizationScopeAcl = {
68
+ isSuperAdmin: boolean
69
+ features: string[]
70
+ organizations: string[] | null
71
+ }
72
+
73
+ export type OrganizationScopeRequest = Request | {
74
+ cookies?: { get: (name: string) => { value: string } | undefined }
75
+ headers?: { get(name: string): string | null }
76
+ }
77
+
78
+ /**
79
+ * Narrow, request-scoped hierarchy boundary owned by Directory.
80
+ *
81
+ * `null` means the selected organization does not exist in the requested
82
+ * tenant. An empty array means it exists but has no ancestors.
83
+ */
84
+ export interface OrganizationHierarchyService {
85
+ resolveAncestorIds(input: {
86
+ tenantId: string
87
+ organizationId: string
88
+ }): Promise<string[] | null>
89
+ }
90
+
91
+ /** Public, request-scoped organization expansion boundary owned by Directory. */
92
+ export interface OrganizationScopeService {
93
+ resolve(input: {
94
+ auth: AuthContext | null | undefined
95
+ selectedId?: string | null
96
+ tenantId?: string | null
97
+ freshAcl?: boolean
98
+ }): Promise<OrganizationScope>
99
+ resolveFresh(input: {
100
+ auth: NonNullable<AuthContext>
101
+ selectedId?: string | null
102
+ tenantId?: string | null
103
+ }): Promise<{ scope: OrganizationScope; acl: OrganizationScopeAcl }>
104
+ resolveForRequest(input: {
105
+ auth: AuthContext | null | undefined
106
+ request?: OrganizationScopeRequest
107
+ selectedId?: string | null
108
+ tenantId?: string | null
109
+ }): Promise<OrganizationScope>
110
+ }
@@ -1,9 +1,18 @@
1
1
  import { cookies } from 'next/headers.js'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
- import { verifyJwt } from './jwt'
3
+ import { isMfaPendingJwtPayload, verifyJwt } from './jwt'
4
+ import { isMfaPendingAccessAllowed } from './mfaPendingAccess'
4
5
  import { getSharedApiKeyAuthCache } from './apiKeyAuthCache'
5
6
  import { isTransientDbError } from '@open-mercato/shared/lib/db/pg-errors'
6
7
 
8
+ function readRequestPathname(req: Request): string | null {
9
+ try {
10
+ return new URL(req.url).pathname
11
+ } catch {
12
+ return null
13
+ }
14
+ }
15
+
7
16
  const TENANT_COOKIE_NAME = 'om_selected_tenant'
8
17
  const ORGANIZATION_COOKIE_NAME = 'om_selected_org'
9
18
  const ALL_ORGANIZATIONS_COOKIE_VALUE = '__all__'
@@ -59,6 +68,16 @@ export type TrustedAuthContextEnvelope = {
59
68
  status?: AuthResolutionStatus
60
69
  }
61
70
 
71
+ /**
72
+ * Attach an already-resolved auth context to a synthetic request.
73
+ *
74
+ * INVARIANT callers MUST uphold: `envelope.auth` has to be the product of a gated resolution —
75
+ * `getAuthFromRequest` / `getAuthFromCookies` (or an equivalently validated principal such as an
76
+ * API-key record). The envelope short-circuits `resolveAuthFromRequestDetailed` ahead of both the
77
+ * MFA-pending gate and `resolveCanonicalStaffAuthContext`, so an envelope built straight from an
78
+ * unvalidated JWT would reinstate the MFA bypass this module closes and skip session-revocation
79
+ * checks as well. Never populate it from raw request credentials.
80
+ */
62
81
  export function attachTrustedAuthContext(
63
82
  request: Request,
64
83
  envelope: TrustedAuthContextEnvelope
@@ -195,13 +214,33 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
195
214
  : []
196
215
  const roleNames = roles.map((role) => role.name).filter((name): name is string => typeof name === 'string' && name.length > 0)
197
216
 
217
+ // A role-level super-admin grant is authorization-grade only when it is genuinely
218
+ // unrestricted: the grant itself must not be organization-scoped, it must belong to the
219
+ // key's tenant, and the key must not be bound to a single organization. RbacService applies
220
+ // the same intersection when projecting the scoped ACL, but generic guards
221
+ // (tenantAccess.resolveIsSuperAdmin, the scoped-API helpers) trust this raw bit *before*
222
+ // live RBAC runs — so an organization-restricted key must never carry it.
198
223
  let keyIsSuperAdmin = false
199
- if (roleIds.length) {
200
- const superAcl = await em.findOne(
224
+ const keyOrganizationId = typeof record.organizationId === 'string' && record.organizationId.trim().length > 0
225
+ ? record.organizationId.trim()
226
+ : null
227
+ if (roleIds.length && !keyOrganizationId) {
228
+ const superAcls = await em.find(
201
229
  RoleAcl,
202
- { role: { $in: roleIds } as any, isSuperAdmin: true, deletedAt: null } as any,
230
+ {
231
+ role: { $in: roleIds } as any,
232
+ tenantId: record.tenantId ?? null,
233
+ isSuperAdmin: true,
234
+ deletedAt: null,
235
+ } as any,
203
236
  )
204
- keyIsSuperAdmin = !!(superAcl && (superAcl as { isSuperAdmin?: boolean }).isSuperAdmin)
237
+ keyIsSuperAdmin = superAcls.some((acl) => {
238
+ const organizations = Array.isArray((acl as { organizationsJson?: string[] | null }).organizationsJson)
239
+ ? (acl as { organizationsJson?: string[] | null }).organizationsJson as string[]
240
+ : null
241
+ // An empty list means "inherit the key's own binding"; with no binding that is tenant-wide.
242
+ return !organizations || organizations.length === 0 || organizations.includes('__all__')
243
+ })
205
244
  }
206
245
 
207
246
  if (cache.shouldWriteLastUsed(record.id)) {
@@ -213,8 +252,25 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
213
252
  }
214
253
  }
215
254
 
216
- // For session keys, use sessionUserId; for regular keys, use createdBy
217
- const actualUserId = record.sessionUserId ?? record.createdBy ?? null
255
+ // Ephemeral session keys are always user-bound. Regular keys retain their
256
+ // legacy creator identity, while tenant-scoped regular keys ignore only the
257
+ // creator's concrete organization when validating the wider key scope.
258
+ // Keep every session marker fail-closed so a malformed session key cannot
259
+ // fall back to the regular key path and escape its user/scope binding.
260
+ const isSessionBoundKey = Boolean(
261
+ record.sessionToken
262
+ || record.sessionUserId
263
+ || record.sessionSecretEncrypted
264
+ || record.opencodeSessionId
265
+ )
266
+ const actualUserId = isSessionBoundKey
267
+ ? record.sessionUserId ?? null
268
+ : record.createdBy ?? null
269
+
270
+ if (isSessionBoundKey && !actualUserId) {
271
+ cache.setMiss(secret)
272
+ return null
273
+ }
218
274
 
219
275
  if (actualUserId) {
220
276
  const user = await em.findOne(User, { id: actualUserId, deletedAt: null })
@@ -226,7 +282,8 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
226
282
  cache.setMiss(secret)
227
283
  return null
228
284
  }
229
- if ((user.organizationId ?? null) !== (record.organizationId ?? null)) {
285
+ const requiresExactOrganization = isSessionBoundKey || Boolean(record.organizationId)
286
+ if (requiresExactOrganization && (user.organizationId ?? null) !== (record.organizationId ?? null)) {
230
287
  cache.setMiss(secret)
231
288
  return null
232
289
  }
@@ -315,6 +372,9 @@ export async function resolveAuthFromCookiesDetailed(): Promise<AuthResolution>
315
372
  const payload = verifyJwt(token) as AuthContext
316
373
  if (!payload) return { auth: null, status: 'invalid' }
317
374
  if (payload.type === 'customer') return { auth: null, status: 'invalid' }
375
+ // MFA-pending tokens are provisional: pages never complete the second factor, so they
376
+ // must not resolve to an authenticated context anywhere in the RSC/page flow.
377
+ if (isMfaPendingJwtPayload(payload)) return { auth: null, status: 'invalid' }
318
378
  const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload)
319
379
  if (!canonicalAuth) return { auth: null, status: 'invalid' }
320
380
  const tenantCookie = cookieStore.get(TENANT_COOKIE_NAME)?.value
@@ -334,6 +394,8 @@ export async function getAuthFromCookies(): Promise<AuthContext> {
334
394
  }
335
395
 
336
396
  export async function resolveAuthFromRequestDetailed(req: Request): Promise<AuthResolution> {
397
+ // Deliberately ahead of the MFA-pending gate below: the envelope carries a context that was
398
+ // already gated by its producer (see `attachTrustedAuthContext`), not raw request credentials.
337
399
  const trusted = readTrustedAuthContext(req)
338
400
  if (trusted) {
339
401
  return {
@@ -357,6 +419,14 @@ export async function resolveAuthFromRequestDetailed(req: Request): Promise<Auth
357
419
  try {
358
420
  const payload = verifyJwt(token) as AuthContext
359
421
  if (payload && payload.type === 'customer') return { auth: null, status: 'invalid' }
422
+ if (payload && isMfaPendingJwtPayload(payload)) {
423
+ // MFA-pending tokens authenticate only the registered challenge-completion routes;
424
+ // everywhere else they resolve to `invalid` so the dispatcher answers 401 (clearing
425
+ // staff auth cookies) instead of restoring the account's roles/features.
426
+ if (!isMfaPendingAccessAllowed(req.method, readRequestPathname(req))) {
427
+ return { auth: null, status: 'invalid' }
428
+ }
429
+ }
360
430
  if (payload) {
361
431
  const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload)
362
432
  if (canonicalAuth) {
@@ -49,6 +49,37 @@ describe('CommandBus', () => {
49
49
  expect(logEntry).toEqual({ id: 'log-entry' })
50
50
  })
51
51
 
52
+ it('records the system actor marker when a trusted command has no auth actor', async () => {
53
+ const logMock = jest.fn(async () => ({ id: 'system-log-entry' }))
54
+ registerCommand({
55
+ id: 'test.system-command',
56
+ execute: jest.fn(async () => ({ ok: true })),
57
+ buildLog: jest.fn(() => ({ actionLabel: 'System test', resourceKind: 'test', resourceId: 'system-123' })),
58
+ })
59
+
60
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
61
+ container.register({ actionLogService: asValue({ log: logMock }) })
62
+
63
+ const bus = new CommandBus()
64
+ const ctx = {
65
+ container,
66
+ auth: null,
67
+ organizationScope: null,
68
+ selectedOrganizationId: null,
69
+ organizationIds: null,
70
+ systemActor: true,
71
+ }
72
+
73
+ await bus.execute('test.system-command', { input: {}, ctx })
74
+
75
+ expect(logMock).toHaveBeenCalledWith(
76
+ expect.objectContaining({
77
+ actorUserId: undefined,
78
+ context: { systemActor: 'system:command' },
79
+ })
80
+ )
81
+ })
82
+
52
83
  it('passes captureAfter snapshot to buildLog as snapshots.after', async () => {
53
84
  const logMock = jest.fn(async () => ({ id: 'log-entry-2' }))
54
85
  const buildLogMock = jest.fn(() => ({
@@ -583,6 +583,9 @@ export class CommandBus {
583
583
  const organizationId =
584
584
  metadata.organizationId ?? options.ctx.selectedOrganizationId ?? options.ctx.auth?.orgId ?? null
585
585
  const actorUserId = metadata.actorUserId ?? options.ctx.auth?.sub ?? null
586
+ const systemActorContext = !actorUserId && options.ctx.systemActor === true
587
+ ? { systemActor: 'system:command' }
588
+ : null
586
589
  const payload: Record<string, unknown> = {
587
590
  tenantId: tenantId ?? undefined,
588
591
  organizationId: organizationId ?? undefined,
@@ -603,7 +606,11 @@ export class CommandBus {
603
606
  if ('snapshotBefore' in metadata && metadata.snapshotBefore !== undefined) payload.snapshotBefore = metadata.snapshotBefore
604
607
  if ('snapshotAfter' in metadata && metadata.snapshotAfter !== undefined) payload.snapshotAfter = metadata.snapshotAfter
605
608
  if ('changes' in metadata && metadata.changes !== undefined && metadata.changes !== null) payload.changes = metadata.changes
606
- if ('context' in metadata && metadata.context !== undefined && metadata.context !== null) payload.context = metadata.context
609
+ if ('context' in metadata && metadata.context !== undefined && metadata.context !== null) {
610
+ payload.context = { ...(systemActorContext ?? {}), ...metadata.context }
611
+ } else if (systemActorContext) {
612
+ payload.context = systemActorContext
613
+ }
607
614
  }
608
615
 
609
616
  const redoEnvelope = wrapRedoPayload('commandPayload' in payload ? (payload.commandPayload as unknown) : undefined, options.input)