@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +4 -1
- package/dist/lib/auth/jwt.js +6 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/mfaPendingAccess.js +42 -0
- package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
- package/dist/lib/auth/organizationAccess.js +7 -4
- package/dist/lib/auth/organizationAccess.js.map +2 -2
- package/dist/lib/auth/principal-service.js +1 -0
- package/dist/lib/auth/principal-service.js.map +7 -0
- package/dist/lib/auth/server.js +38 -7
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/commands/command-bus.js +6 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +24 -8
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +8 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/html/htmlToPlainText.js +16 -0
- package/dist/lib/html/htmlToPlainText.js.map +7 -0
- package/dist/lib/location/countries.js +12 -0
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/openapi/crud.js +4 -1
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/count-cap.js +11 -0
- package/dist/lib/query/count-cap.js.map +7 -0
- package/dist/lib/query/engine.js +270 -34
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/queue/dispatchOrigin.js +20 -0
- package/dist/lib/queue/dispatchOrigin.js.map +7 -0
- package/dist/lib/search/config.js +1 -0
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/entityAccess.js +44 -0
- package/dist/lib/search/entityAccess.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +69 -15
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +15 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +10 -3
- package/src/lib/auth/__tests__/jwt.test.ts +13 -0
- package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
- package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
- package/src/lib/auth/__tests__/server.test.ts +104 -0
- package/src/lib/auth/jwt.ts +17 -0
- package/src/lib/auth/mfaPendingAccess.ts +70 -0
- package/src/lib/auth/organizationAccess.ts +11 -3
- package/src/lib/auth/principal-service.ts +110 -0
- package/src/lib/auth/server.ts +78 -8
- package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
- package/src/lib/commands/command-bus.ts +8 -1
- package/src/lib/crud/__tests__/crud-factory.test.ts +165 -0
- package/src/lib/crud/factory.ts +33 -7
- package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
- package/src/lib/data/engine.ts +7 -1
- package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
- package/src/lib/html/htmlToPlainText.ts +17 -0
- package/src/lib/location/__tests__/countries.test.ts +15 -0
- package/src/lib/location/countries.ts +17 -0
- package/src/lib/openapi/crud.ts +3 -0
- package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
- package/src/lib/query/__tests__/count-cap.test.ts +41 -0
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
- package/src/lib/query/__tests__/engine.test.ts +445 -7
- package/src/lib/query/count-cap.ts +19 -0
- package/src/lib/query/engine.ts +434 -54
- package/src/lib/query/types.ts +15 -0
- package/src/lib/queue/dispatchOrigin.ts +35 -0
- package/src/lib/search/config.ts +10 -0
- package/src/lib/search/entityAccess.ts +132 -0
- package/src/modules/events/__tests__/factory.test.ts +88 -0
- package/src/modules/events/factory.ts +111 -19
- package/src/modules/events/types.ts +17 -0
- package/src/modules/registry.ts +40 -0
- package/src/modules/widgets/component-registry.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1-develop.7102.1.b41f7e3e51",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"types": "./src/lib/bootstrap/dynamicLoader.ts",
|
|
41
41
|
"default": "./dist/lib/bootstrap/dynamicLoader.js"
|
|
42
42
|
},
|
|
43
|
+
"./lib/auth/principal-service": {
|
|
44
|
+
"types": "./src/lib/auth/principal-service.ts",
|
|
45
|
+
"default": "./dist/lib/auth/principal-service.js"
|
|
46
|
+
},
|
|
43
47
|
"./lib/webhooks": {
|
|
44
48
|
"types": "./src/lib/webhooks/index.ts",
|
|
45
49
|
"default": "./dist/lib/webhooks/index.js"
|
|
@@ -105,9 +109,11 @@
|
|
|
105
109
|
"@mikro-orm/core": "^7.1.8",
|
|
106
110
|
"@mikro-orm/decorators": "^7.1.8",
|
|
107
111
|
"@mikro-orm/postgresql": "^7.1.8",
|
|
108
|
-
"@open-mercato/cache": "0.7.
|
|
112
|
+
"@open-mercato/cache": "0.7.1-develop.7102.1.b41f7e3e51",
|
|
113
|
+
"@types/html-to-text": "^9.0.4",
|
|
109
114
|
"@types/sanitize-html": "^2.16.1",
|
|
110
115
|
"dotenv": "^17.4.2",
|
|
116
|
+
"html-to-text": "^10.0.0",
|
|
111
117
|
"pino": "^10.3.1",
|
|
112
118
|
"rate-limiter-flexible": "^11.2.0",
|
|
113
119
|
"re2js": "2.8.6",
|
|
@@ -129,5 +135,6 @@
|
|
|
129
135
|
"type": "git",
|
|
130
136
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
131
137
|
"directory": "packages/shared"
|
|
132
|
-
}
|
|
138
|
+
},
|
|
139
|
+
"stableVersion": "0.7.0"
|
|
133
140
|
}
|
|
@@ -470,3 +470,16 @@ describe('jwt helpers', () => {
|
|
|
470
470
|
})
|
|
471
471
|
})
|
|
472
472
|
})
|
|
473
|
+
|
|
474
|
+
describe('isMfaPendingJwtPayload', () => {
|
|
475
|
+
it('flags pending challenge payloads only', async () => {
|
|
476
|
+
const { isMfaPendingJwtPayload } = await import('../jwt')
|
|
477
|
+
expect(isMfaPendingJwtPayload({ mfa_pending: true, mfa_verified: false })).toBe(true)
|
|
478
|
+
expect(isMfaPendingJwtPayload({ mfa_pending: true })).toBe(true)
|
|
479
|
+
expect(isMfaPendingJwtPayload({ mfa_pending: true, mfa_verified: true })).toBe(false)
|
|
480
|
+
expect(isMfaPendingJwtPayload({ mfa_pending: false, mfa_verified: true })).toBe(false)
|
|
481
|
+
expect(isMfaPendingJwtPayload({})).toBe(false)
|
|
482
|
+
expect(isMfaPendingJwtPayload(null)).toBe(false)
|
|
483
|
+
expect(isMfaPendingJwtPayload('token')).toBe(false)
|
|
484
|
+
})
|
|
485
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isMfaPendingAccessAllowed,
|
|
3
|
+
listMfaPendingAccessRoutes,
|
|
4
|
+
registerMfaPendingAccessRoutes,
|
|
5
|
+
} from '../mfaPendingAccess'
|
|
6
|
+
|
|
7
|
+
describe('mfaPendingAccess', () => {
|
|
8
|
+
it('ships an empty generic registry and denies unknown routes by default', () => {
|
|
9
|
+
const registeredPaths = listMfaPendingAccessRoutes().map((route) => route.path)
|
|
10
|
+
expect(registeredPaths).not.toContain('/api/acme-mfa/complete')
|
|
11
|
+
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/complete')).toBe(false)
|
|
12
|
+
expect(isMfaPendingAccessAllowed('GET', '/api/customers/people')).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('allows registered completion routes for their exact methods only', () => {
|
|
16
|
+
registerMfaPendingAccessRoutes([{ path: '/api/acme-mfa/t2/verify', methods: ['POST'] }])
|
|
17
|
+
|
|
18
|
+
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t2/verify')).toBe(true)
|
|
19
|
+
expect(isMfaPendingAccessAllowed('post', '/api/acme-mfa/t2/verify')).toBe(true)
|
|
20
|
+
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t2/verify/')).toBe(true)
|
|
21
|
+
expect(isMfaPendingAccessAllowed('GET', '/api/acme-mfa/t2/verify')).toBe(false)
|
|
22
|
+
expect(isMfaPendingAccessAllowed('PUT', '/api/acme-mfa/t2/verify')).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('rejects unregistered paths and near-misses', () => {
|
|
26
|
+
registerMfaPendingAccessRoutes([{ path: '/api/acme-mfa/t3/recovery', methods: ['POST'] }])
|
|
27
|
+
|
|
28
|
+
expect(isMfaPendingAccessAllowed('POST', '/api/customers/people')).toBe(false)
|
|
29
|
+
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t3/unrelated')).toBe(false)
|
|
30
|
+
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t3/recovery/impersonate')).toBe(false)
|
|
31
|
+
expect(isMfaPendingAccessAllowed('POST', '/api/auth/login')).toBe(false)
|
|
32
|
+
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t3/recovery-codes')).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('is fail-closed on missing or malformed input', () => {
|
|
36
|
+
registerMfaPendingAccessRoutes([{ path: '/api/acme-mfa/t4/complete', methods: ['POST'] }])
|
|
37
|
+
|
|
38
|
+
expect(isMfaPendingAccessAllowed(undefined, '/api/acme-mfa/t4/complete')).toBe(false)
|
|
39
|
+
expect(isMfaPendingAccessAllowed(null, '/api/acme-mfa/t4/complete')).toBe(false)
|
|
40
|
+
expect(isMfaPendingAccessAllowed('', '')).toBe(false)
|
|
41
|
+
expect(isMfaPendingAccessAllowed('POST', undefined)).toBe(false)
|
|
42
|
+
expect(isMfaPendingAccessAllowed('POST', 'not-a-path')).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('registers additional routes additively and idempotently', () => {
|
|
46
|
+
const routeCountBefore = listMfaPendingAccessRoutes().length
|
|
47
|
+
|
|
48
|
+
registerMfaPendingAccessRoutes([
|
|
49
|
+
{ path: '/api/acme-mfa/t5/complete', methods: ['post', 'POST'] },
|
|
50
|
+
])
|
|
51
|
+
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t5/complete')).toBe(true)
|
|
52
|
+
|
|
53
|
+
registerMfaPendingAccessRoutes([
|
|
54
|
+
{ path: '/api/acme-mfa/t5/complete/', methods: ['POST'] },
|
|
55
|
+
{ path: '/api/acme-mfa/t5/complete', methods: ['GET'] },
|
|
56
|
+
])
|
|
57
|
+
const merged = listMfaPendingAccessRoutes().find((route) => route.path === '/api/acme-mfa/t5/complete')
|
|
58
|
+
expect(merged).toBeDefined()
|
|
59
|
+
expect(merged?.methods).toEqual(['GET', 'POST'])
|
|
60
|
+
expect(listMfaPendingAccessRoutes()).toHaveLength(routeCountBefore + 1)
|
|
61
|
+
|
|
62
|
+
registerMfaPendingAccessRoutes([
|
|
63
|
+
{ path: 'no-leading-slash', methods: [] },
|
|
64
|
+
{ path: '/api/acme-mfa/t5/broken', methods: [] },
|
|
65
|
+
] as never)
|
|
66
|
+
expect(listMfaPendingAccessRoutes()).toHaveLength(routeCountBefore + 1)
|
|
67
|
+
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t5/broken')).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -1,4 +1,39 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isOrganizationAccessAllowed,
|
|
3
|
+
isUnrestrictedOrganizationScope,
|
|
4
|
+
} from '@open-mercato/shared/lib/auth/organizationAccess'
|
|
5
|
+
|
|
6
|
+
describe('isUnrestrictedOrganizationScope', () => {
|
|
7
|
+
it('allows super admins regardless of allowed organizations', () => {
|
|
8
|
+
expect(
|
|
9
|
+
isUnrestrictedOrganizationScope({
|
|
10
|
+
isSuperAdmin: true,
|
|
11
|
+
allowedOrganizationIds: [],
|
|
12
|
+
}),
|
|
13
|
+
).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('allows truly unrestricted non-superadmin scopes', () => {
|
|
17
|
+
expect(
|
|
18
|
+
isUnrestrictedOrganizationScope({
|
|
19
|
+
isSuperAdmin: false,
|
|
20
|
+
allowedOrganizationIds: null,
|
|
21
|
+
}),
|
|
22
|
+
).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it.each([undefined, [] as string[], ['org-a']])(
|
|
26
|
+
'denies restricted non-superadmin scopes with allowedOrganizationIds=%p',
|
|
27
|
+
(allowedOrganizationIds) => {
|
|
28
|
+
expect(
|
|
29
|
+
isUnrestrictedOrganizationScope({
|
|
30
|
+
isSuperAdmin: false,
|
|
31
|
+
allowedOrganizationIds,
|
|
32
|
+
}),
|
|
33
|
+
).toBe(false)
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
})
|
|
2
37
|
|
|
3
38
|
describe('isOrganizationAccessAllowed', () => {
|
|
4
39
|
it('allows super admins regardless of scope', () => {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const PACKAGE_ROOT = join(__dirname, '..', '..', '..', '..')
|
|
5
|
+
const SUBPATH = './lib/auth/principal-service'
|
|
6
|
+
|
|
7
|
+
type ExportTarget = { types?: string | string[]; default?: string }
|
|
8
|
+
|
|
9
|
+
function readExports(): Record<string, ExportTarget | string> {
|
|
10
|
+
const packageJson = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) as {
|
|
11
|
+
exports?: Record<string, ExportTarget | string>
|
|
12
|
+
}
|
|
13
|
+
return packageJson.exports ?? {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Node's subpath resolution: an exact key wins outright, and only when none
|
|
18
|
+
* matches do the `*` patterns compete on the longest static prefix. Mirroring
|
|
19
|
+
* it here is the point of the test — a subpath that resolves only through a
|
|
20
|
+
* wildcard is at the mercy of whichever resolver a consumer happens to run
|
|
21
|
+
* (packed tarball, bundler, Jest), and those do not agree on wildcard handling.
|
|
22
|
+
*/
|
|
23
|
+
function resolveSubpath(exports: Record<string, ExportTarget | string>, subpath: string): string | null {
|
|
24
|
+
if (Object.prototype.hasOwnProperty.call(exports, subpath)) return subpath
|
|
25
|
+
let best: string | null = null
|
|
26
|
+
for (const key of Object.keys(exports)) {
|
|
27
|
+
const star = key.indexOf('*')
|
|
28
|
+
if (star < 0) continue
|
|
29
|
+
const prefix = key.slice(0, star)
|
|
30
|
+
const suffix = key.slice(star + 1)
|
|
31
|
+
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue
|
|
32
|
+
if (subpath.length < prefix.length + suffix.length) continue
|
|
33
|
+
if (!best || prefix.length > best.slice(0, best.indexOf('*')).length) best = key
|
|
34
|
+
}
|
|
35
|
+
return best
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('@open-mercato/shared principal-service export map', () => {
|
|
39
|
+
const exports = readExports()
|
|
40
|
+
|
|
41
|
+
it('resolves through an explicit entry rather than a wildcard pattern', () => {
|
|
42
|
+
expect(resolveSubpath(exports, SUBPATH)).toBe(SUBPATH)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('maps types to the source module and default to the built module', () => {
|
|
46
|
+
const target = exports[SUBPATH]
|
|
47
|
+
expect(typeof target).toBe('object')
|
|
48
|
+
expect(target).toEqual({
|
|
49
|
+
types: './src/lib/auth/principal-service.ts',
|
|
50
|
+
default: './dist/lib/auth/principal-service.js',
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Both halves of the mapping must exist in a packed package. `default` is
|
|
55
|
+
// produced by `yarn build:packages`, which the validation gate runs before
|
|
56
|
+
// `yarn test`.
|
|
57
|
+
it('ships both mapped files', () => {
|
|
58
|
+
const target = exports[SUBPATH] as ExportTarget
|
|
59
|
+
expect(existsSync(join(PACKAGE_ROOT, target.types as string))).toBe(true)
|
|
60
|
+
expect(existsSync(join(PACKAGE_ROOT, target.default as string))).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('keeps the module importable under the mapped default condition', async () => {
|
|
64
|
+
const target = exports[SUBPATH] as ExportTarget
|
|
65
|
+
await expect(import(join(PACKAGE_ROOT, target.default as string))).resolves.toBeDefined()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -94,6 +94,244 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
|
|
|
94
94
|
expect(emFlush).toHaveBeenCalledTimes(1)
|
|
95
95
|
})
|
|
96
96
|
|
|
97
|
+
it('retains the creator identity for a regular tenant-scoped key', async () => {
|
|
98
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
99
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
100
|
+
id: 'key-tenant-scoped',
|
|
101
|
+
name: 'tenant scoped',
|
|
102
|
+
tenantId: 'tenant-1',
|
|
103
|
+
organizationId: null,
|
|
104
|
+
rolesJson: [],
|
|
105
|
+
sessionToken: null,
|
|
106
|
+
sessionUserId: null,
|
|
107
|
+
sessionSecretEncrypted: null,
|
|
108
|
+
opencodeSessionId: null,
|
|
109
|
+
createdBy: 'creator-1',
|
|
110
|
+
expiresAt: null,
|
|
111
|
+
lastUsedAt: null,
|
|
112
|
+
})
|
|
113
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
114
|
+
if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
|
|
115
|
+
if (where.id === 'creator-1') {
|
|
116
|
+
return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'creator-org' }
|
|
117
|
+
}
|
|
118
|
+
return null
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const auth = await getAuthFromRequest(buildRequest('tenant-scoped-secret'))
|
|
122
|
+
|
|
123
|
+
expect(auth).toMatchObject({
|
|
124
|
+
sub: 'api_key:key-tenant-scoped',
|
|
125
|
+
tenantId: 'tenant-1',
|
|
126
|
+
orgId: null,
|
|
127
|
+
isApiKey: true,
|
|
128
|
+
keyId: 'key-tenant-scoped',
|
|
129
|
+
userId: 'creator-1',
|
|
130
|
+
})
|
|
131
|
+
// The creator remains the key's legacy identity, but its concrete
|
|
132
|
+
// organization does not narrow a tenant-scoped key.
|
|
133
|
+
expect(emFindOne).toHaveBeenCalledWith(
|
|
134
|
+
expect.anything(),
|
|
135
|
+
{ id: 'creator-1', deletedAt: null },
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('rejects a tenant-scoped regular key once its creator is soft-deleted', async () => {
|
|
140
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
141
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
142
|
+
id: 'key-deleted-creator',
|
|
143
|
+
name: 'deleted creator',
|
|
144
|
+
tenantId: 'tenant-1',
|
|
145
|
+
organizationId: null,
|
|
146
|
+
rolesJson: [],
|
|
147
|
+
sessionToken: null,
|
|
148
|
+
sessionUserId: null,
|
|
149
|
+
sessionSecretEncrypted: null,
|
|
150
|
+
opencodeSessionId: null,
|
|
151
|
+
createdBy: 'creator-1',
|
|
152
|
+
expiresAt: null,
|
|
153
|
+
lastUsedAt: null,
|
|
154
|
+
})
|
|
155
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
156
|
+
if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
|
|
157
|
+
return null
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
await expect(
|
|
161
|
+
getAuthFromRequest(buildRequest('deleted-creator-secret')),
|
|
162
|
+
).resolves.toBeNull()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('rejects a tenant-scoped regular key whose creator moved to another tenant', async () => {
|
|
166
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
167
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
168
|
+
id: 'key-foreign-creator',
|
|
169
|
+
name: 'foreign creator',
|
|
170
|
+
tenantId: 'tenant-1',
|
|
171
|
+
organizationId: null,
|
|
172
|
+
rolesJson: [],
|
|
173
|
+
sessionToken: null,
|
|
174
|
+
sessionUserId: null,
|
|
175
|
+
sessionSecretEncrypted: null,
|
|
176
|
+
opencodeSessionId: null,
|
|
177
|
+
createdBy: 'creator-1',
|
|
178
|
+
expiresAt: null,
|
|
179
|
+
lastUsedAt: null,
|
|
180
|
+
})
|
|
181
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
182
|
+
if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
|
|
183
|
+
if (where.id === 'creator-1') {
|
|
184
|
+
return { id: 'creator-1', tenantId: 'tenant-2', organizationId: null }
|
|
185
|
+
}
|
|
186
|
+
return null
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
await expect(
|
|
190
|
+
getAuthFromRequest(buildRequest('foreign-creator-secret')),
|
|
191
|
+
).resolves.toBeNull()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('accepts a tenant-scoped regular key that records no creator', async () => {
|
|
195
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
196
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
197
|
+
id: 'key-no-creator',
|
|
198
|
+
name: 'no creator',
|
|
199
|
+
tenantId: 'tenant-1',
|
|
200
|
+
organizationId: null,
|
|
201
|
+
rolesJson: [],
|
|
202
|
+
sessionToken: null,
|
|
203
|
+
sessionUserId: null,
|
|
204
|
+
sessionSecretEncrypted: null,
|
|
205
|
+
opencodeSessionId: null,
|
|
206
|
+
createdBy: null,
|
|
207
|
+
expiresAt: null,
|
|
208
|
+
lastUsedAt: null,
|
|
209
|
+
})
|
|
210
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
211
|
+
if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
|
|
212
|
+
return null
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
await expect(
|
|
216
|
+
getAuthFromRequest(buildRequest('no-creator-secret')),
|
|
217
|
+
).resolves.toMatchObject({ keyId: 'key-no-creator', tenantId: 'tenant-1' })
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('retains the creator identity for an organization-scoped regular key', async () => {
|
|
221
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
222
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
223
|
+
id: 'key-organization-scoped',
|
|
224
|
+
name: 'organization scoped',
|
|
225
|
+
tenantId: 'tenant-1',
|
|
226
|
+
organizationId: 'org-1',
|
|
227
|
+
rolesJson: [],
|
|
228
|
+
sessionToken: null,
|
|
229
|
+
sessionUserId: null,
|
|
230
|
+
sessionSecretEncrypted: null,
|
|
231
|
+
opencodeSessionId: null,
|
|
232
|
+
createdBy: 'creator-1',
|
|
233
|
+
expiresAt: null,
|
|
234
|
+
lastUsedAt: null,
|
|
235
|
+
})
|
|
236
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
237
|
+
if (where.id === 'creator-1') {
|
|
238
|
+
return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
239
|
+
}
|
|
240
|
+
return null
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
await expect(
|
|
244
|
+
getAuthFromRequest(buildRequest('organization-scoped-secret')),
|
|
245
|
+
).resolves.toMatchObject({
|
|
246
|
+
sub: 'api_key:key-organization-scoped',
|
|
247
|
+
tenantId: 'tenant-1',
|
|
248
|
+
orgId: 'org-1',
|
|
249
|
+
userId: 'creator-1',
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('rejects an organization-scoped regular key when its creator scope no longer matches', async () => {
|
|
254
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
255
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
256
|
+
id: 'key-organization-mismatch',
|
|
257
|
+
name: 'organization mismatch',
|
|
258
|
+
tenantId: 'tenant-1',
|
|
259
|
+
organizationId: 'org-1',
|
|
260
|
+
rolesJson: [],
|
|
261
|
+
sessionToken: null,
|
|
262
|
+
sessionUserId: null,
|
|
263
|
+
sessionSecretEncrypted: null,
|
|
264
|
+
opencodeSessionId: null,
|
|
265
|
+
createdBy: 'creator-1',
|
|
266
|
+
expiresAt: null,
|
|
267
|
+
lastUsedAt: null,
|
|
268
|
+
})
|
|
269
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
270
|
+
if (where.id === 'creator-1') {
|
|
271
|
+
return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'org-2' }
|
|
272
|
+
}
|
|
273
|
+
return null
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
await expect(
|
|
277
|
+
getAuthFromRequest(buildRequest('organization-scope-mismatch-secret')),
|
|
278
|
+
).resolves.toBeNull()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('keeps session keys strictly bound to their persisted user and scope', async () => {
|
|
282
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
283
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
284
|
+
id: 'key-session-scoped',
|
|
285
|
+
name: 'session scoped',
|
|
286
|
+
tenantId: 'tenant-1',
|
|
287
|
+
organizationId: null,
|
|
288
|
+
rolesJson: [],
|
|
289
|
+
sessionToken: 'sess_123',
|
|
290
|
+
sessionUserId: 'session-user-1',
|
|
291
|
+
sessionSecretEncrypted: null,
|
|
292
|
+
opencodeSessionId: null,
|
|
293
|
+
createdBy: 'session-user-1',
|
|
294
|
+
expiresAt: null,
|
|
295
|
+
lastUsedAt: null,
|
|
296
|
+
})
|
|
297
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
298
|
+
if (where.id === 'session-user-1') {
|
|
299
|
+
return { id: 'session-user-1', tenantId: 'tenant-1', organizationId: 'user-org' }
|
|
300
|
+
}
|
|
301
|
+
return null
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
await expect(
|
|
305
|
+
getAuthFromRequest(buildRequest('session-scope-mismatch-secret')),
|
|
306
|
+
).resolves.toBeNull()
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('fails closed when a row has session markers but no bound session user', async () => {
|
|
310
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
311
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
312
|
+
id: 'key-malformed-session',
|
|
313
|
+
name: 'malformed session',
|
|
314
|
+
tenantId: 'tenant-1',
|
|
315
|
+
organizationId: null,
|
|
316
|
+
rolesJson: [],
|
|
317
|
+
sessionToken: 'sess_missing_user',
|
|
318
|
+
sessionUserId: null,
|
|
319
|
+
sessionSecretEncrypted: null,
|
|
320
|
+
opencodeSessionId: null,
|
|
321
|
+
createdBy: 'creator-1',
|
|
322
|
+
expiresAt: null,
|
|
323
|
+
lastUsedAt: null,
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
await expect(
|
|
327
|
+
getAuthFromRequest(buildRequest('malformed-session-secret')),
|
|
328
|
+
).resolves.toBeNull()
|
|
329
|
+
expect(emFindOne).not.toHaveBeenCalledWith(
|
|
330
|
+
expect.anything(),
|
|
331
|
+
expect.objectContaining({ id: 'creator-1' }),
|
|
332
|
+
)
|
|
333
|
+
})
|
|
334
|
+
|
|
97
335
|
it('caches negative lookups so invalid keys skip the bcrypt+DB path', async () => {
|
|
98
336
|
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
99
337
|
findApiKeyBySecret.mockResolvedValue(null)
|
|
@@ -128,4 +366,90 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
|
|
|
128
366
|
await getAuthFromRequest(buildRequest('secret-invalidate'))
|
|
129
367
|
expect(findApiKeyBySecret).toHaveBeenCalledTimes(2)
|
|
130
368
|
})
|
|
369
|
+
|
|
370
|
+
describe('super-admin bit stays bounded by the effective key scope', () => {
|
|
371
|
+
async function resolveWithSuperAdminRole(input: {
|
|
372
|
+
secret: string
|
|
373
|
+
keyId: string
|
|
374
|
+
keyOrganizationId: string | null
|
|
375
|
+
aclOrganizations: string[] | null
|
|
376
|
+
}) {
|
|
377
|
+
const { RoleAcl } = await import('@open-mercato/core/modules/auth/data/entities')
|
|
378
|
+
const { Organization, Tenant } = await import('@open-mercato/core/modules/directory/data/entities')
|
|
379
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
380
|
+
// A creator-less key is validated against a live tenant (and organization when bound).
|
|
381
|
+
emFindOne.mockImplementation(async (entity: unknown) => {
|
|
382
|
+
if (entity === Tenant) return { id: 'tenant-1' }
|
|
383
|
+
if (entity === Organization) return { id: input.keyOrganizationId, tenant: { id: 'tenant-1' } }
|
|
384
|
+
return null
|
|
385
|
+
})
|
|
386
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
387
|
+
id: input.keyId,
|
|
388
|
+
name: 'super admin key',
|
|
389
|
+
tenantId: 'tenant-1',
|
|
390
|
+
organizationId: input.keyOrganizationId,
|
|
391
|
+
rolesJson: ['role-super'],
|
|
392
|
+
sessionToken: null,
|
|
393
|
+
sessionUserId: null,
|
|
394
|
+
sessionSecretEncrypted: null,
|
|
395
|
+
opencodeSessionId: null,
|
|
396
|
+
createdBy: null,
|
|
397
|
+
expiresAt: null,
|
|
398
|
+
lastUsedAt: null,
|
|
399
|
+
})
|
|
400
|
+
emFind.mockImplementation(async (entity: unknown) => {
|
|
401
|
+
if (entity === RoleAcl) {
|
|
402
|
+
return [{ isSuperAdmin: true, organizationsJson: input.aclOrganizations }]
|
|
403
|
+
}
|
|
404
|
+
return []
|
|
405
|
+
})
|
|
406
|
+
return getAuthFromRequest(buildRequest(input.secret))
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
it('withholds it from an organization-bound key even when a role grants it', async () => {
|
|
410
|
+
// Generic guards trust this bit before live RBAC, so an organization-restricted key must
|
|
411
|
+
// never take super-admin shortcuts outside its own scope.
|
|
412
|
+
const auth = await resolveWithSuperAdminRole({
|
|
413
|
+
secret: 'super-bound',
|
|
414
|
+
keyId: 'key-super-bound',
|
|
415
|
+
keyOrganizationId: 'org-1',
|
|
416
|
+
aclOrganizations: null,
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: false })
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('withholds it when the granting role ACL is itself organization-restricted', async () => {
|
|
423
|
+
const auth = await resolveWithSuperAdminRole({
|
|
424
|
+
secret: 'super-restricted',
|
|
425
|
+
keyId: 'key-super-restricted',
|
|
426
|
+
keyOrganizationId: null,
|
|
427
|
+
aclOrganizations: ['org-1'],
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: false })
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('grants it for an unbound key with an unrestricted role grant', async () => {
|
|
434
|
+
const auth = await resolveWithSuperAdminRole({
|
|
435
|
+
secret: 'super-global',
|
|
436
|
+
keyId: 'key-super-global',
|
|
437
|
+
keyOrganizationId: null,
|
|
438
|
+
aclOrganizations: null,
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: true })
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('grants it for an unbound key whose role ACL uses the __all__ sentinel', async () => {
|
|
445
|
+
const auth = await resolveWithSuperAdminRole({
|
|
446
|
+
secret: 'super-all-sentinel',
|
|
447
|
+
keyId: 'key-super-all-sentinel',
|
|
448
|
+
keyOrganizationId: null,
|
|
449
|
+
aclOrganizations: ['__all__'],
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: true })
|
|
453
|
+
})
|
|
454
|
+
})
|
|
131
455
|
})
|