@open-mercato/shared 0.6.7 → 0.6.8-develop.6874.1.982d6097d8
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 +57 -6
- package/build.mjs +2 -4
- package/dist/lib/ai/safety-identifier.js +32 -0
- package/dist/lib/ai/safety-identifier.js.map +7 -0
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/auth/server.js +15 -2
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
- package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +117 -43
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/commands/command-bus.js +5 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +2 -2
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +2 -11
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +72 -9
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/interceptor-runner.js +2 -2
- package/dist/lib/crud/interceptor-runner.js.map +2 -2
- package/dist/lib/crud/mutation-guard-registry.js +2 -2
- package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
- package/dist/lib/crud/types.js +4 -0
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/consistency.js +19 -0
- package/dist/lib/data/consistency.js.map +7 -0
- package/dist/lib/data/engine.js +37 -11
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/entityMetadata.js +26 -0
- package/dist/lib/db/entityMetadata.js.map +7 -0
- package/dist/lib/db/pg-errors.js +42 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +48 -6
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +56 -0
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +2 -10
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/config.js +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/i18n/server.js +4 -4
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/logger/extension.js +29 -0
- package/dist/lib/logger/extension.js.map +7 -0
- package/dist/lib/logger/index.js +47 -3
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +5 -1
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/modules/surfaceFingerprint.js +47 -0
- package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
- package/dist/lib/query/ciphertext-search-warning.js +45 -0
- package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
- package/dist/lib/query/engine.js +54 -40
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +7 -2
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/search/auto-indexing.js +14 -0
- package/dist/lib/search/auto-indexing.js.map +7 -0
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/search/config.js +61 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenLookup.js +46 -0
- package/dist/lib/search/tokenLookup.js.map +7 -0
- package/dist/lib/search/tokenize.js +23 -11
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/telemetry/runtime.js +35 -0
- package/dist/lib/telemetry/runtime.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +57 -0
- package/dist/lib/webhooks/body.js.map +7 -0
- package/dist/lib/webhooks/inbound-types.js +1 -0
- package/dist/lib/webhooks/inbound-types.js.map +7 -0
- package/dist/lib/webhooks/index.js +13 -1
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +12 -7
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/overrides.js +50 -1
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +8 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js +1 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/extension-points.js +96 -0
- package/dist/modules/widgets/extension-points.js.map +7 -0
- package/dist/security/enabledModulesRegistry.js +56 -11
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/dist/security/featurePolicy.js +62 -0
- package/dist/security/featurePolicy.js.map +7 -0
- package/jest.config.cjs +2 -1
- package/package.json +19 -9
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +46 -0
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
- package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
- package/src/lib/ai/llm-provider.ts +33 -0
- package/src/lib/ai/safety-identifier.ts +73 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/__tests__/server.test.ts +23 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/auth/server.ts +23 -2
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +213 -65
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
- package/src/lib/commands/command-bus.ts +5 -1
- package/src/lib/commands/command-interceptor-runner.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +351 -0
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
- package/src/lib/crud/enricher-runner.ts +2 -11
- package/src/lib/crud/factory.ts +82 -6
- package/src/lib/crud/interceptor-runner.ts +2 -2
- package/src/lib/crud/mutation-guard-registry.ts +2 -2
- package/src/lib/crud/types.ts +3 -0
- package/src/lib/data/__tests__/consistency.test.ts +38 -0
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
- package/src/lib/data/consistency.ts +17 -0
- package/src/lib/data/engine.ts +50 -16
- package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
- package/src/lib/db/entityMetadata.ts +43 -0
- package/src/lib/db/pg-errors.ts +58 -0
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
- package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
- package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
- package/src/lib/di/container.ts +72 -8
- package/src/lib/email/__tests__/send.test.ts +37 -1
- package/src/lib/email/send.ts +79 -0
- package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
- package/src/lib/encryption/subscriber.ts +2 -14
- package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
- package/src/lib/i18n/__tests__/config.test.ts +8 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/server.ts +6 -2
- package/src/lib/logger/__tests__/logger.test.ts +77 -0
- package/src/lib/logger/extension.ts +59 -0
- package/src/lib/logger/index.ts +59 -1
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
- package/src/lib/modules/registry.ts +10 -0
- package/src/lib/modules/surfaceFingerprint.ts +87 -0
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
- package/src/lib/query/__tests__/engine.test.ts +65 -13
- package/src/lib/query/ciphertext-search-warning.ts +95 -0
- package/src/lib/query/engine.ts +75 -58
- package/src/lib/query/types.ts +4 -5
- package/src/lib/redis/__tests__/connection.test.ts +16 -0
- package/src/lib/redis/connection.ts +10 -7
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/__tests__/config.test.ts +177 -0
- package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
- package/src/lib/search/__tests__/tokenize.test.ts +49 -0
- package/src/lib/search/auto-indexing.ts +22 -0
- package/src/lib/search/availability.ts +232 -0
- package/src/lib/search/config.ts +106 -8
- package/src/lib/search/tokenLookup.ts +133 -0
- package/src/lib/search/tokenize.ts +33 -11
- package/src/lib/telemetry/runtime.ts +72 -0
- package/src/lib/webhooks/__tests__/body.test.ts +95 -0
- package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
- package/src/lib/webhooks/body.ts +70 -0
- package/src/lib/webhooks/inbound-types.ts +125 -0
- package/src/lib/webhooks/index.ts +19 -1
- package/src/lib/webhooks/verify.ts +17 -9
- package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
- package/src/modules/__tests__/registry.test.ts +10 -2
- package/src/modules/analytics.ts +7 -0
- package/src/modules/customer-auth.ts +1 -0
- package/src/modules/encryption.ts +3 -0
- package/src/modules/navigation/backendChrome.ts +15 -0
- package/src/modules/overrides.ts +103 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
- package/src/modules/payment_gateways/types.ts +41 -0
- package/src/modules/registry.ts +3 -0
- package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
- package/src/modules/widgets/extension-points.ts +524 -0
- package/src/security/__tests__/featurePolicy.test.ts +166 -0
- package/src/security/enabledModulesRegistry.ts +64 -12
- package/src/security/featurePolicy.ts +89 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Pure source emitter for the build-time replacement of src/lib/version.ts.
|
|
2
|
+
// Kept side-effect free, and CommonJS so both build.mjs and the Jest suite can load it.
|
|
3
|
+
|
|
4
|
+
const { Project, QuoteKind, ScriptKind, StructureKind, VariableDeclarationKind } = require('ts-morph')
|
|
5
|
+
|
|
6
|
+
const VIRTUAL_FILE_NAME = 'version.ts'
|
|
7
|
+
const BANNER = '// Build-time generated version\n'
|
|
8
|
+
|
|
9
|
+
let sharedProject = null
|
|
10
|
+
|
|
11
|
+
function getProject() {
|
|
12
|
+
if (!sharedProject) {
|
|
13
|
+
sharedProject = new Project({
|
|
14
|
+
useInMemoryFileSystem: true,
|
|
15
|
+
manipulationSettings: { quoteKind: QuoteKind.Single },
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return sharedProject
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertNoSyntacticDiagnostics(project, sourceFile) {
|
|
23
|
+
const diagnostics = project.getProgram().getSyntacticDiagnostics(sourceFile)
|
|
24
|
+
if (diagnostics.length === 0) return
|
|
25
|
+
|
|
26
|
+
const details = diagnostics
|
|
27
|
+
.map((diagnostic) => ` line ${diagnostic.getLineNumber() ?? 0}: ${diagnostic.getMessageText()}`)
|
|
28
|
+
.join('\n')
|
|
29
|
+
throw new Error(`[internal] Generated ${VIRTUAL_FILE_NAME} is not syntactically valid:\n${details}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeSourceText(text) {
|
|
33
|
+
const normalized = text.replace(/\r\n/g, '\n')
|
|
34
|
+
return normalized.endsWith('\n') ? normalized : `${normalized}\n`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildVersionSource(version) {
|
|
38
|
+
const project = getProject()
|
|
39
|
+
const sourceFile = project.createSourceFile(VIRTUAL_FILE_NAME, '', {
|
|
40
|
+
overwrite: true,
|
|
41
|
+
scriptKind: ScriptKind.TS,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
sourceFile.addVariableStatement({
|
|
45
|
+
kind: StructureKind.VariableStatement,
|
|
46
|
+
isExported: true,
|
|
47
|
+
declarationKind: VariableDeclarationKind.Const,
|
|
48
|
+
declarations: [{ name: 'APP_VERSION', initializer: (writer) => writer.quote(String(version)) }],
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
sourceFile.addVariableStatement({
|
|
52
|
+
kind: StructureKind.VariableStatement,
|
|
53
|
+
isExported: true,
|
|
54
|
+
declarationKind: VariableDeclarationKind.Const,
|
|
55
|
+
declarations: [{ name: 'appVersion', initializer: 'APP_VERSION' }],
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
sourceFile.formatText({ indentSize: 2, convertTabsToSpaces: true })
|
|
59
|
+
sourceFile.insertText(0, BANNER)
|
|
60
|
+
assertNoSyntacticDiagnostics(project, sourceFile)
|
|
61
|
+
|
|
62
|
+
return normalizeSourceText(sourceFile.getFullText())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { buildVersionSource }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Project, SyntaxKind } from 'ts-morph'
|
|
2
|
+
import { buildVersionSource } from '../../../scripts/versionSource.cjs'
|
|
3
|
+
|
|
4
|
+
function parse(source: string) {
|
|
5
|
+
const project = new Project({ useInMemoryFileSystem: true })
|
|
6
|
+
const sourceFile = project.createSourceFile('version.ts', source, { overwrite: true })
|
|
7
|
+
return { project, sourceFile }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function exportedLiteral(source: string, name: string): string {
|
|
11
|
+
const { sourceFile } = parse(source)
|
|
12
|
+
return sourceFile.getVariableDeclarationOrThrow(name).getInitializerOrThrow().getText()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('buildVersionSource', () => {
|
|
16
|
+
it('exports both APP_VERSION and appVersion', () => {
|
|
17
|
+
const { sourceFile } = parse(buildVersionSource('1.2.3'))
|
|
18
|
+
expect([...sourceFile.getExportedDeclarations().keys()].sort()).toEqual(['APP_VERSION', 'appVersion'])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('writes the supplied version as the APP_VERSION literal', () => {
|
|
22
|
+
expect(exportedLiteral(buildVersionSource('1.2.3'), 'APP_VERSION')).toBe("'1.2.3'")
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('aliases appVersion to APP_VERSION rather than repeating the literal', () => {
|
|
26
|
+
expect(exportedLiteral(buildVersionSource('1.2.3'), 'appVersion')).toBe('APP_VERSION')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('escapes a version string that would otherwise break the literal', () => {
|
|
30
|
+
const source = buildVersionSource("0.0.0-dev'; drop")
|
|
31
|
+
const { sourceFile } = parse(source)
|
|
32
|
+
const literal = sourceFile
|
|
33
|
+
.getVariableDeclarationOrThrow('APP_VERSION')
|
|
34
|
+
.getInitializerIfKindOrThrow(SyntaxKind.StringLiteral)
|
|
35
|
+
expect(literal.getLiteralValue()).toBe("0.0.0-dev'; drop")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('emits zero syntactic diagnostics', () => {
|
|
39
|
+
const { project, sourceFile } = parse(buildVersionSource('1.2.3'))
|
|
40
|
+
expect(project.getProgram().getSyntacticDiagnostics(sourceFile)).toEqual([])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('keeps the build-time banner comment', () => {
|
|
44
|
+
expect(buildVersionSource('1.2.3').startsWith('// Build-time generated version\n')).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { EnvLookup, LlmProvider, LlmCreateModelOptions } from '../llm-provider'
|
|
2
|
+
|
|
3
|
+
function makeBaseProvider(overrides: Partial<LlmProvider> = {}): LlmProvider {
|
|
4
|
+
const id = overrides.id ?? 'contract-provider'
|
|
5
|
+
const envKeys = overrides.envKeys ?? [`${id.toUpperCase()}_API_KEY`]
|
|
6
|
+
const base: LlmProvider = {
|
|
7
|
+
id,
|
|
8
|
+
name: `Contract Provider ${id}`,
|
|
9
|
+
envKeys,
|
|
10
|
+
defaultModel: 'contract-model',
|
|
11
|
+
defaultModels: [{ id: 'contract-model', name: 'Contract Model', contextWindow: 8192 }],
|
|
12
|
+
isConfigured(env?: EnvLookup): boolean {
|
|
13
|
+
const lookup = env ?? process.env
|
|
14
|
+
return envKeys.some((key) => {
|
|
15
|
+
const value = lookup[key]
|
|
16
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
17
|
+
})
|
|
18
|
+
},
|
|
19
|
+
resolveApiKey(): string | null {
|
|
20
|
+
return null
|
|
21
|
+
},
|
|
22
|
+
getConfiguredEnvKey(): string {
|
|
23
|
+
return envKeys[0]
|
|
24
|
+
},
|
|
25
|
+
createModel(options: LlmCreateModelOptions) {
|
|
26
|
+
return { __kind: 'contract-model', modelId: options.modelId }
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
return { ...base, ...overrides }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('LlmProvider contract — moderation/safety-identifier additive members', () => {
|
|
33
|
+
it('treats mapEndUserIdentifier and supportsInputModeration as optional (legacy adapters)', () => {
|
|
34
|
+
const provider = makeBaseProvider()
|
|
35
|
+
expect(provider.mapEndUserIdentifier).toBeUndefined()
|
|
36
|
+
expect(provider.supportsInputModeration).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('accepts an optional endUserIdentifier on createModel options without changing behavior', () => {
|
|
40
|
+
const provider = makeBaseProvider()
|
|
41
|
+
const withIdentifier: LlmCreateModelOptions = {
|
|
42
|
+
modelId: 'contract-model',
|
|
43
|
+
apiKey: 'sk-test',
|
|
44
|
+
endUserIdentifier: 'hashed-identifier',
|
|
45
|
+
}
|
|
46
|
+
expect(provider.createModel(withIdentifier)).toEqual({ __kind: 'contract-model', modelId: 'contract-model' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('exposes a providerOptions fragment when mapEndUserIdentifier is implemented', () => {
|
|
50
|
+
const provider = makeBaseProvider({
|
|
51
|
+
mapEndUserIdentifier(identifier: string) {
|
|
52
|
+
return { contract: { user_id: identifier } }
|
|
53
|
+
},
|
|
54
|
+
supportsInputModeration: true,
|
|
55
|
+
})
|
|
56
|
+
expect(provider.supportsInputModeration).toBe(true)
|
|
57
|
+
expect(provider.mapEndUserIdentifier?.('abc123')).toEqual({ contract: { user_id: 'abc123' } })
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { computeEndUserIdentifier, deriveAiSafetyIdentifierSecret } from '../safety-identifier'
|
|
2
|
+
|
|
3
|
+
const BASE_SECRET = 'unit-test-base-secret'
|
|
4
|
+
const HEX_64 = /^[0-9a-f]{64}$/
|
|
5
|
+
|
|
6
|
+
describe('safety-identifier', () => {
|
|
7
|
+
describe('deriveAiSafetyIdentifierSecret', () => {
|
|
8
|
+
it('is deterministic and memoized for a given base secret', () => {
|
|
9
|
+
const first = deriveAiSafetyIdentifierSecret(BASE_SECRET)
|
|
10
|
+
const second = deriveAiSafetyIdentifierSecret(BASE_SECRET)
|
|
11
|
+
expect(first).toBe(second)
|
|
12
|
+
expect(first).toMatch(HEX_64)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('produces a different key for a different base secret', () => {
|
|
16
|
+
expect(deriveAiSafetyIdentifierSecret(BASE_SECRET)).not.toBe(
|
|
17
|
+
deriveAiSafetyIdentifierSecret('other-base-secret'),
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('throws when no base secret is available', () => {
|
|
22
|
+
const previous = process.env.JWT_SECRET
|
|
23
|
+
delete process.env.JWT_SECRET
|
|
24
|
+
try {
|
|
25
|
+
expect(() => deriveAiSafetyIdentifierSecret()).toThrow(/JWT_SECRET/)
|
|
26
|
+
} finally {
|
|
27
|
+
if (previous !== undefined) process.env.JWT_SECRET = previous
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('computeEndUserIdentifier', () => {
|
|
33
|
+
it('is stable for the same (tenant, user) pair', () => {
|
|
34
|
+
const a = computeEndUserIdentifier('tenant-1', 'user-1', { baseSecret: BASE_SECRET })
|
|
35
|
+
const b = computeEndUserIdentifier('tenant-1', 'user-1', { baseSecret: BASE_SECRET })
|
|
36
|
+
expect(a).toBe(b)
|
|
37
|
+
expect(a).toMatch(HEX_64)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('separates the same user across tenants (tenant-salted)', () => {
|
|
41
|
+
const t1 = computeEndUserIdentifier('tenant-1', 'user-1', { baseSecret: BASE_SECRET })
|
|
42
|
+
const t2 = computeEndUserIdentifier('tenant-2', 'user-1', { baseSecret: BASE_SECRET })
|
|
43
|
+
expect(t1).not.toBe(t2)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('separates different users within the same tenant', () => {
|
|
47
|
+
const u1 = computeEndUserIdentifier('tenant-1', 'user-1', { baseSecret: BASE_SECRET })
|
|
48
|
+
const u2 = computeEndUserIdentifier('tenant-1', 'user-2', { baseSecret: BASE_SECRET })
|
|
49
|
+
expect(u1).not.toBe(u2)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('never leaks the raw tenant or user id', () => {
|
|
53
|
+
const id = computeEndUserIdentifier('tenant-1', 'user-1', { baseSecret: BASE_SECRET })
|
|
54
|
+
expect(id).not.toContain('tenant-1')
|
|
55
|
+
expect(id).not.toContain('user-1')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('treats a null tenant as an empty salt without throwing', () => {
|
|
59
|
+
const withNull = computeEndUserIdentifier(null, 'user-1', { baseSecret: BASE_SECRET })
|
|
60
|
+
const withEmpty = computeEndUserIdentifier('', 'user-1', { baseSecret: BASE_SECRET })
|
|
61
|
+
expect(withNull).toMatch(HEX_64)
|
|
62
|
+
expect(withNull).toBe(withEmpty)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('throws when userId is empty', () => {
|
|
66
|
+
expect(() => computeEndUserIdentifier('tenant-1', ' ', { baseSecret: BASE_SECRET })).toThrow(
|
|
67
|
+
/userId/,
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -62,6 +62,16 @@ export interface LlmCreateModelOptions {
|
|
|
62
62
|
* proxy); Google honors it when the SDK supports it (@ai-sdk/google ≥3.0).
|
|
63
63
|
*/
|
|
64
64
|
baseURL?: string
|
|
65
|
+
/**
|
|
66
|
+
* Optional opaque, non-reversible end-user identifier attached to the model
|
|
67
|
+
* call so provider-side abuse enforcement can target a single end user
|
|
68
|
+
* instead of the whole API-key organization. The runtime computes this as a
|
|
69
|
+
* tenant-salted HMAC (no PII leaves the platform); the adapter decides how to
|
|
70
|
+
* map it into per-call `providerOptions` via
|
|
71
|
+
* {@link LlmProvider.mapEndUserIdentifier}. Adapters without a mapping ignore
|
|
72
|
+
* it. Always optional — absent identifiers reproduce today's behavior.
|
|
73
|
+
*/
|
|
74
|
+
endUserIdentifier?: string
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
/**
|
|
@@ -146,4 +156,27 @@ export interface LlmProvider {
|
|
|
146
156
|
* behavior at `packages/ai-assistant/src/modules/ai_assistant/api/route/route.ts`.
|
|
147
157
|
*/
|
|
148
158
|
createModel(options: LlmCreateModelOptions): unknown
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Optional. Maps a runtime-computed end-user identifier (see
|
|
162
|
+
* {@link LlmCreateModelOptions.endUserIdentifier}) into the AI SDK
|
|
163
|
+
* `providerOptions` fragment this provider understands — e.g. OpenAI returns
|
|
164
|
+
* `{ openai: { safetyIdentifier } }`, Anthropic returns
|
|
165
|
+
* `{ anthropic: { metadata: { userId } } }`. Keys MUST be the AI SDK
|
|
166
|
+
* provider-option names (camelCase); the SDK translates them to the
|
|
167
|
+
* provider's request-body fields and strips unknown keys. The runtime merges
|
|
168
|
+
* the returned fragment into the per-call `providerOptions`. Adapters that
|
|
169
|
+
* omit this method send no identifier (today's behavior). Implementations
|
|
170
|
+
* MUST be pure and stateless.
|
|
171
|
+
*/
|
|
172
|
+
mapEndUserIdentifier?(identifier: string): Record<string, unknown>
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Optional. When `true`, the runtime may run input pre-moderation through
|
|
176
|
+
* this provider's moderation endpoint before the model call. Only providers
|
|
177
|
+
* that actually expose a moderation API (initially the OpenAI adapter) set
|
|
178
|
+
* this. Absent/`false` means the moderation gate is skipped for this provider
|
|
179
|
+
* and the surface relies on the provider's own server-side filtering.
|
|
180
|
+
*/
|
|
181
|
+
readonly supportsInputModeration?: boolean
|
|
149
182
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-user safety identifiers for AI provider calls.
|
|
3
|
+
*
|
|
4
|
+
* Providers such as OpenAI let developers attach an opaque per-end-user
|
|
5
|
+
* identifier to each request so abuse enforcement can target one user instead
|
|
6
|
+
* of suspending the whole API-key organization. We never send a reversible id:
|
|
7
|
+
* the value is a tenant-salted HMAC computed from the platform's existing auth
|
|
8
|
+
* secret, so no PII or internal id leaves the platform and the same end user in
|
|
9
|
+
* two tenants produces two unrelated hashes.
|
|
10
|
+
*
|
|
11
|
+
* The per-process secret derivation mirrors `deriveJwtAudienceSecret`
|
|
12
|
+
* (`@open-mercato/shared/lib/auth/jwt`): one HMAC from the base `JWT_SECRET`
|
|
13
|
+
* under a versioned purpose label, memoized for the process lifetime. No new
|
|
14
|
+
* secret to provision.
|
|
15
|
+
*
|
|
16
|
+
* @see .ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import crypto from 'node:crypto'
|
|
20
|
+
|
|
21
|
+
const SAFETY_IDENTIFIER_SECRET_LABEL = 'open-mercato:ai-safety-identifier:v1'
|
|
22
|
+
|
|
23
|
+
const derivedSecretCache = new Map<string, string>()
|
|
24
|
+
|
|
25
|
+
function readBaseSecret(explicit?: string): string {
|
|
26
|
+
const secret = explicit ?? process.env.JWT_SECRET
|
|
27
|
+
if (!secret) {
|
|
28
|
+
throw new Error('[internal] JWT_SECRET is not set; cannot derive AI safety-identifier secret')
|
|
29
|
+
}
|
|
30
|
+
return secret
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Derive the per-process safety-identifier HMAC key from the base auth secret.
|
|
35
|
+
*
|
|
36
|
+
* Deterministic HMAC-SHA256 of a versioned purpose label keyed by the base
|
|
37
|
+
* secret, memoized per base secret. Rotating the base secret rotates every
|
|
38
|
+
* derived identifier — documented as accepted (identifiers are advisory
|
|
39
|
+
* provider-side metadata, not an in-platform security control).
|
|
40
|
+
*/
|
|
41
|
+
export function deriveAiSafetyIdentifierSecret(baseSecret?: string): string {
|
|
42
|
+
const base = readBaseSecret(baseSecret)
|
|
43
|
+
const cached = derivedSecretCache.get(base)
|
|
44
|
+
if (cached !== undefined) return cached
|
|
45
|
+
const derived = crypto
|
|
46
|
+
.createHmac('sha256', base)
|
|
47
|
+
.update(SAFETY_IDENTIFIER_SECRET_LABEL)
|
|
48
|
+
.digest('hex')
|
|
49
|
+
derivedSecretCache.set(base, derived)
|
|
50
|
+
return derived
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compute the opaque end-user safety identifier for a (tenant, user) pair.
|
|
55
|
+
*
|
|
56
|
+
* Returns a 64-char lowercase hex HMAC-SHA256 of `${tenantId}:${userId}` keyed
|
|
57
|
+
* by the derived secret. Throws (with an `[internal]` message) when the base
|
|
58
|
+
* secret is missing or `userId` is empty — callers in the runtime wrap this in
|
|
59
|
+
* a best-effort try/catch so identifier-derivation failures never break chat.
|
|
60
|
+
*/
|
|
61
|
+
export function computeEndUserIdentifier(
|
|
62
|
+
tenantId: string | null | undefined,
|
|
63
|
+
userId: string,
|
|
64
|
+
options?: { baseSecret?: string },
|
|
65
|
+
): string {
|
|
66
|
+
const normalizedUser = (userId ?? '').trim()
|
|
67
|
+
if (!normalizedUser) {
|
|
68
|
+
throw new Error('[internal] computeEndUserIdentifier requires a non-empty userId')
|
|
69
|
+
}
|
|
70
|
+
const key = deriveAiSafetyIdentifierSecret(options?.baseSecret)
|
|
71
|
+
const salt = (tenantId ?? '').trim()
|
|
72
|
+
return crypto.createHmac('sha256', key).update(`${salt}:${normalizedUser}`).digest('hex')
|
|
73
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE,
|
|
5
|
+
organizationScopeRequiredResponse,
|
|
6
|
+
resolveActiveOrganizationId,
|
|
7
|
+
} from '../organizationScope'
|
|
8
|
+
|
|
9
|
+
const accountOrgId = '22222222-2222-4222-8222-222222222222'
|
|
10
|
+
const selectedOrgId = '33333333-3333-4333-8333-333333333333'
|
|
11
|
+
const actorTenantId = '44444444-4444-4444-8444-444444444444'
|
|
12
|
+
const foreignTenantId = '55555555-5555-4555-8555-555555555555'
|
|
13
|
+
|
|
14
|
+
describe('resolveActiveOrganizationId', () => {
|
|
15
|
+
it('uses the selected organization when one is set', () => {
|
|
16
|
+
expect(
|
|
17
|
+
resolveActiveOrganizationId({ orgId: selectedOrgId, actorOrgId: accountOrgId }),
|
|
18
|
+
).toBe(selectedOrgId)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// `orgId: null` + `actorOrgId` set is exactly the shape `applySuperAdminScope` produces for an
|
|
22
|
+
// all-organizations selection. Answering 401 for it sent `apiFetch` into a refresh loop.
|
|
23
|
+
it('falls back to the actor organization for an all-organizations selection', () => {
|
|
24
|
+
expect(
|
|
25
|
+
resolveActiveOrganizationId({ orgId: null, actorOrgId: accountOrgId }),
|
|
26
|
+
).toBe(accountOrgId)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// `actorTenantId` is only present when the super-admin cookie override switched tenants.
|
|
30
|
+
// Selecting the actor's own tenant explicitly keeps the fallback valid.
|
|
31
|
+
it('keeps the fallback when the tenant override selects the actor tenant', () => {
|
|
32
|
+
expect(
|
|
33
|
+
resolveActiveOrganizationId({
|
|
34
|
+
orgId: null,
|
|
35
|
+
actorOrgId: accountOrgId,
|
|
36
|
+
tenantId: actorTenantId,
|
|
37
|
+
actorTenantId,
|
|
38
|
+
}),
|
|
39
|
+
).toBe(accountOrgId)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// The reviewed blocker: tenant B + "all organizations" must not pair the actor's
|
|
43
|
+
// tenant-A organization with tenant B. No fallback — the route answers 400 instead.
|
|
44
|
+
it('refuses the fallback when the effective tenant is not the actor tenant', () => {
|
|
45
|
+
expect(
|
|
46
|
+
resolveActiveOrganizationId({
|
|
47
|
+
orgId: null,
|
|
48
|
+
actorOrgId: accountOrgId,
|
|
49
|
+
tenantId: foreignTenantId,
|
|
50
|
+
actorTenantId,
|
|
51
|
+
}),
|
|
52
|
+
).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('refuses the fallback when the tenant override cleared the tenant entirely', () => {
|
|
56
|
+
expect(
|
|
57
|
+
resolveActiveOrganizationId({
|
|
58
|
+
orgId: null,
|
|
59
|
+
actorOrgId: accountOrgId,
|
|
60
|
+
tenantId: null,
|
|
61
|
+
actorTenantId,
|
|
62
|
+
}),
|
|
63
|
+
).toBeNull()
|
|
64
|
+
expect(
|
|
65
|
+
resolveActiveOrganizationId({
|
|
66
|
+
orgId: null,
|
|
67
|
+
actorOrgId: accountOrgId,
|
|
68
|
+
tenantId: foreignTenantId,
|
|
69
|
+
actorTenantId: null,
|
|
70
|
+
}),
|
|
71
|
+
).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns null when the caller has no organization at all', () => {
|
|
75
|
+
expect(resolveActiveOrganizationId({ orgId: null })).toBeNull()
|
|
76
|
+
expect(resolveActiveOrganizationId({ orgId: null, actorOrgId: null })).toBeNull()
|
|
77
|
+
expect(resolveActiveOrganizationId(null)).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('ignores blank and non-string values rather than scoping to them', () => {
|
|
81
|
+
expect(resolveActiveOrganizationId({ orgId: ' ', actorOrgId: accountOrgId })).toBe(accountOrgId)
|
|
82
|
+
expect(resolveActiveOrganizationId({ orgId: null, actorOrgId: ' ' })).toBeNull()
|
|
83
|
+
expect(resolveActiveOrganizationId({ orgId: null, actorOrgId: 42 })).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('still honours an explicit organization selection across tenants', () => {
|
|
87
|
+
expect(
|
|
88
|
+
resolveActiveOrganizationId({
|
|
89
|
+
orgId: selectedOrgId,
|
|
90
|
+
actorOrgId: accountOrgId,
|
|
91
|
+
tenantId: foreignTenantId,
|
|
92
|
+
actorTenantId,
|
|
93
|
+
}),
|
|
94
|
+
).toBe(selectedOrgId)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('organizationScopeRequiredResponse', () => {
|
|
99
|
+
// 401 would send `apiFetch` back into the session-refresh loop; the scope error must not.
|
|
100
|
+
it('answers 400 with a machine-readable code', async () => {
|
|
101
|
+
const response = organizationScopeRequiredResponse()
|
|
102
|
+
expect(response.status).toBe(400)
|
|
103
|
+
const body = await response.json()
|
|
104
|
+
expect(body.code).toBe(ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE)
|
|
105
|
+
expect(typeof body.error).toBe('string')
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -161,4 +161,27 @@ describe('auth server integrity checks', () => {
|
|
|
161
161
|
|
|
162
162
|
await expect(getAuthFromRequest(request)).resolves.toBeNull()
|
|
163
163
|
})
|
|
164
|
+
|
|
165
|
+
it('reports a transient DB failure on the api-key path as "error" (retryable 503)', async () => {
|
|
166
|
+
const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
|
|
167
|
+
// Distinct secret so the shared api-key auth cache does not serve a prior miss.
|
|
168
|
+
findApiKeyBySecret.mockRejectedValue(Object.assign(new Error('sorry, too many clients already'), { code: '53300' }))
|
|
169
|
+
|
|
170
|
+
const request = new Request('https://example.test/api/test', {
|
|
171
|
+
headers: { 'x-api-key': 'transient-key' },
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
await expect(resolveAuthFromRequestDetailed(request)).resolves.toEqual({ auth: null, status: 'error' })
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('keeps a non-transient api-key failure as "missing" (unchanged 401 behavior)', async () => {
|
|
178
|
+
const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
|
|
179
|
+
findApiKeyBySecret.mockRejectedValue(new Error('unexpected non-db failure'))
|
|
180
|
+
|
|
181
|
+
const request = new Request('https://example.test/api/test', {
|
|
182
|
+
headers: { 'x-api-key': 'non-transient-key' },
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
await expect(resolveAuthFromRequestDetailed(request)).resolves.toEqual({ auth: null, status: 'missing' })
|
|
186
|
+
})
|
|
164
187
|
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
type OrganizationScopedAuth = {
|
|
2
|
+
orgId?: string | null
|
|
3
|
+
actorOrgId?: unknown
|
|
4
|
+
tenantId?: string | null
|
|
5
|
+
actorTenantId?: unknown
|
|
6
|
+
} | null | undefined
|
|
7
|
+
|
|
8
|
+
function normalizeId(value: unknown): string | null {
|
|
9
|
+
if (typeof value !== 'string') return null
|
|
10
|
+
const trimmed = value.trim()
|
|
11
|
+
return trimmed.length > 0 ? trimmed : null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the organization a request is scoped to when the caller may be viewing
|
|
16
|
+
* "all organizations".
|
|
17
|
+
*
|
|
18
|
+
* Organization-scoped configuration modules (integrations credentials/state, data sync
|
|
19
|
+
* mappings/schedules/runs) all require a non-null `organization_id`, so there is no
|
|
20
|
+
* meaningful "all organizations" view of them. When an operator selects that option the
|
|
21
|
+
* super-admin cookie override clears `auth.orgId` and preserves the actor's own
|
|
22
|
+
* organization in `actorOrgId`; fall back to it so those modules keep showing the
|
|
23
|
+
* operator's own configuration instead of failing.
|
|
24
|
+
*
|
|
25
|
+
* The fallback is only valid while the effective tenant is still the actor's own tenant.
|
|
26
|
+
* When the super-admin cookie override also switched tenants (`actorTenantId` is present
|
|
27
|
+
* and differs from `auth.tenantId`), the actor's organization belongs to another tenant —
|
|
28
|
+
* scoping to it would persist a cross-tenant `{ organizationId, tenantId }` pair. Return
|
|
29
|
+
* `null` instead and let the route answer with `organizationScopeRequiredResponse()`.
|
|
30
|
+
*
|
|
31
|
+
* Answering 401 for an unresolvable scope is not merely wrong but self-perpetuating:
|
|
32
|
+
* `apiFetch` reads 401 as an expired session and redirects through
|
|
33
|
+
* `/api/auth/session/refresh`, which succeeds and returns to the same page, reloading
|
|
34
|
+
* forever. That is why the missing-scope answer is a 400, never a 401.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveActiveOrganizationId(auth: OrganizationScopedAuth): string | null {
|
|
37
|
+
if (!auth) return null
|
|
38
|
+
const selected = normalizeId(auth.orgId)
|
|
39
|
+
if (selected) return selected
|
|
40
|
+
const actorOrgId = normalizeId(auth.actorOrgId)
|
|
41
|
+
if (!actorOrgId) return null
|
|
42
|
+
if ('actorTenantId' in auth) {
|
|
43
|
+
const actorTenantId = normalizeId(auth.actorTenantId)
|
|
44
|
+
const effectiveTenantId = normalizeId(auth.tenantId)
|
|
45
|
+
if (!actorTenantId || actorTenantId !== effectiveTenantId) return null
|
|
46
|
+
}
|
|
47
|
+
return actorOrgId
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE = 'organization_scope_required'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 400 response for an authenticated caller whose organization scope cannot be resolved
|
|
54
|
+
* (e.g. a super-admin viewing a foreign tenant with "all organizations" selected).
|
|
55
|
+
* Deliberately not a 401: the session is valid, so refreshing it would loop.
|
|
56
|
+
*/
|
|
57
|
+
export function organizationScopeRequiredResponse(): Response {
|
|
58
|
+
return Response.json(
|
|
59
|
+
{
|
|
60
|
+
error: 'Select an organization to access this resource',
|
|
61
|
+
code: ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE,
|
|
62
|
+
},
|
|
63
|
+
{ status: 400 },
|
|
64
|
+
)
|
|
65
|
+
}
|
package/src/lib/auth/server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { cookies } from 'next/headers.js'
|
|
|
2
2
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
3
|
import { verifyJwt } from './jwt'
|
|
4
4
|
import { getSharedApiKeyAuthCache } from './apiKeyAuthCache'
|
|
5
|
+
import { isTransientDbError } from '@open-mercato/shared/lib/db/pg-errors'
|
|
5
6
|
|
|
6
7
|
const TENANT_COOKIE_NAME = 'om_selected_tenant'
|
|
7
8
|
const ORGANIZATION_COOKIE_NAME = 'om_selected_org'
|
|
@@ -253,7 +254,14 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
|
|
|
253
254
|
}
|
|
254
255
|
cache.setSuccess(secret, auth, record.expiresAt ? record.expiresAt.getTime() : null)
|
|
255
256
|
return auth
|
|
256
|
-
} catch {
|
|
257
|
+
} catch (err) {
|
|
258
|
+
// A transient DB failure (pool exhausted, `max_connections` reached, DB
|
|
259
|
+
// restarting) means we could not confirm the key — NOT that it is invalid.
|
|
260
|
+
// Surface it so callers return a retryable 503 instead of masking it as an
|
|
261
|
+
// auth miss (401). Genuine misses already returned `null` above.
|
|
262
|
+
if (isTransientDbError(err)) {
|
|
263
|
+
throw new AuthResolutionUnavailableError(err)
|
|
264
|
+
}
|
|
257
265
|
return null
|
|
258
266
|
}
|
|
259
267
|
}
|
|
@@ -365,7 +373,20 @@ export async function resolveAuthFromRequestDetailed(req: Request): Promise<Auth
|
|
|
365
373
|
if (!apiKey) {
|
|
366
374
|
return { auth: null, status: resolveUnauthenticatedStatus() }
|
|
367
375
|
}
|
|
368
|
-
|
|
376
|
+
let apiAuth: AuthContext
|
|
377
|
+
try {
|
|
378
|
+
apiAuth = await resolveApiKeyAuth(apiKey)
|
|
379
|
+
} catch (err) {
|
|
380
|
+
// Only a transient canonical-resolution failure maps to 'error' (retryable
|
|
381
|
+
// 503), mirroring the interactive-token path above. Anything else is an
|
|
382
|
+
// unexpected bug — rethrow so it surfaces as a 500 rather than being masked
|
|
383
|
+
// as an auth failure.
|
|
384
|
+
if (!(err instanceof AuthResolutionUnavailableError)) {
|
|
385
|
+
throw err
|
|
386
|
+
}
|
|
387
|
+
hadUnavailableResolution = true
|
|
388
|
+
return { auth: null, status: resolveUnauthenticatedStatus() }
|
|
389
|
+
}
|
|
369
390
|
if (!apiAuth) {
|
|
370
391
|
return { auth: null, status: resolveUnauthenticatedStatus() }
|
|
371
392
|
}
|