@open-mercato/shared 0.6.7-develop.6860.1.f796b22e80 → 0.6.7
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 +6 -57
- package/build.mjs +4 -2
- package/dist/lib/auth/server.js +2 -15
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/dynamicLoader.js +43 -117
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/commands/command-bus.js +1 -5
- 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 +11 -2
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +4 -63
- 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 +0 -4
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/engine.js +11 -37
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/mikro.js +12 -3
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/db/pg-errors.js +0 -42
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +6 -48
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +0 -56
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +10 -2
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +17 -52
- 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/index.js +3 -47
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +1 -5
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/query/engine.js +40 -54
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +2 -7
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/search/config.js +2 -61
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenize.js +11 -23
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/index.js +1 -13
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +7 -12
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/overrides.js +1 -50
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +1 -8
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js +0 -1
- package/dist/modules/registry.js.map +2 -2
- package/dist/security/enabledModulesRegistry.js +11 -56
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/jest.config.cjs +1 -2
- package/package.json +9 -19
- package/src/lib/ai/llm-provider.ts +0 -33
- package/src/lib/auth/__tests__/server.test.ts +0 -23
- package/src/lib/auth/server.ts +2 -23
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +8 -63
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +1 -21
- package/src/lib/bootstrap/dynamicLoader.ts +65 -213
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +0 -28
- package/src/lib/commands/command-bus.ts +1 -5
- package/src/lib/commands/command-interceptor-runner.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +0 -298
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +0 -26
- package/src/lib/crud/enricher-runner.ts +11 -2
- package/src/lib/crud/factory.ts +1 -69
- 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 +0 -3
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +3 -18
- package/src/lib/data/engine.ts +16 -50
- package/src/lib/db/__tests__/mikro.test.ts +25 -0
- package/src/lib/db/mikro.ts +38 -13
- package/src/lib/db/pg-errors.ts +0 -58
- package/src/lib/di/container.ts +8 -72
- package/src/lib/email/__tests__/send.test.ts +1 -37
- package/src/lib/email/send.ts +0 -79
- package/src/lib/encryption/__tests__/subscriber.test.ts +2 -54
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +0 -58
- package/src/lib/encryption/subscriber.ts +14 -2
- package/src/lib/encryption/tenantDataEncryptionService.ts +18 -64
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/server.ts +2 -6
- package/src/lib/logger/__tests__/logger.test.ts +0 -77
- package/src/lib/logger/index.ts +1 -59
- package/src/lib/modules/registry.ts +0 -10
- package/src/lib/query/__tests__/engine.test.ts +13 -65
- package/src/lib/query/engine.ts +58 -75
- package/src/lib/query/types.ts +5 -4
- package/src/lib/redis/__tests__/connection.test.ts +0 -16
- package/src/lib/redis/connection.ts +7 -10
- package/src/lib/search/__tests__/config.test.ts +0 -177
- package/src/lib/search/config.ts +8 -106
- package/src/lib/search/tokenize.ts +11 -33
- package/src/lib/webhooks/__tests__/verify.test.ts +1 -20
- package/src/lib/webhooks/index.ts +1 -19
- package/src/lib/webhooks/verify.ts +9 -17
- package/src/modules/__tests__/registry.test.ts +2 -10
- package/src/modules/analytics.ts +0 -7
- package/src/modules/customer-auth.ts +0 -1
- package/src/modules/encryption.ts +0 -3
- package/src/modules/navigation/backendChrome.ts +0 -15
- package/src/modules/overrides.ts +0 -103
- package/src/modules/payment_gateways/types.ts +0 -41
- package/src/modules/registry.ts +0 -3
- package/src/security/enabledModulesRegistry.ts +12 -64
- package/dist/lib/ai/safety-identifier.js +0 -32
- package/dist/lib/ai/safety-identifier.js.map +0 -7
- package/dist/lib/auth/organizationScope.js +0 -34
- package/dist/lib/auth/organizationScope.js.map +0 -7
- package/dist/lib/bootstrap/clientOnlyModules.js +0 -55
- package/dist/lib/bootstrap/clientOnlyModules.js.map +0 -7
- package/dist/lib/data/consistency.js +0 -19
- package/dist/lib/data/consistency.js.map +0 -7
- package/dist/lib/db/entityMetadata.js +0 -26
- package/dist/lib/db/entityMetadata.js.map +0 -7
- package/dist/lib/logger/extension.js +0 -29
- package/dist/lib/logger/extension.js.map +0 -7
- package/dist/lib/modules/surfaceFingerprint.js +0 -47
- package/dist/lib/modules/surfaceFingerprint.js.map +0 -7
- package/dist/lib/query/ciphertext-search-warning.js +0 -45
- package/dist/lib/query/ciphertext-search-warning.js.map +0 -7
- package/dist/lib/search/auto-indexing.js +0 -14
- package/dist/lib/search/auto-indexing.js.map +0 -7
- package/dist/lib/search/availability.js +0 -107
- package/dist/lib/search/availability.js.map +0 -7
- package/dist/lib/search/tokenLookup.js +0 -46
- package/dist/lib/search/tokenLookup.js.map +0 -7
- package/dist/lib/telemetry/runtime.js +0 -35
- package/dist/lib/telemetry/runtime.js.map +0 -7
- package/dist/lib/webhooks/body.js +0 -57
- package/dist/lib/webhooks/body.js.map +0 -7
- package/dist/lib/webhooks/inbound-types.js +0 -1
- package/dist/lib/webhooks/inbound-types.js.map +0 -7
- package/dist/modules/widgets/extension-points.js +0 -96
- package/dist/modules/widgets/extension-points.js.map +0 -7
- package/dist/security/featurePolicy.js +0 -62
- package/dist/security/featurePolicy.js.map +0 -7
- package/scripts/versionSource.cjs +0 -65
- package/src/lib/__tests__/versionSource.test.ts +0 -46
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +0 -59
- package/src/lib/ai/__tests__/safety-identifier.test.ts +0 -71
- package/src/lib/ai/safety-identifier.ts +0 -73
- package/src/lib/auth/__tests__/organizationScope.test.ts +0 -107
- package/src/lib/auth/organizationScope.ts +0 -65
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +0 -189
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +0 -98
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +0 -211
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +0 -192
- package/src/lib/bootstrap/clientOnlyModules.ts +0 -85
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +0 -171
- package/src/lib/data/__tests__/consistency.test.ts +0 -38
- package/src/lib/data/consistency.ts +0 -17
- package/src/lib/db/__tests__/entityMetadata.test.ts +0 -38
- package/src/lib/db/__tests__/pg-errors.test.ts +0 -48
- package/src/lib/db/entityMetadata.ts +0 -43
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +0 -155
- package/src/lib/di/__tests__/container-app-di.test.ts +0 -173
- package/src/lib/di/__tests__/registrar-error-log.test.ts +0 -154
- package/src/lib/i18n/__tests__/config.test.ts +0 -8
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +0 -30
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +0 -29
- package/src/lib/logger/extension.ts +0 -59
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +0 -122
- package/src/lib/modules/surfaceFingerprint.ts +0 -87
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +0 -178
- package/src/lib/query/ciphertext-search-warning.ts +0 -95
- package/src/lib/search/__tests__/availability.test.ts +0 -211
- package/src/lib/search/__tests__/tokenLookup.test.ts +0 -206
- package/src/lib/search/__tests__/tokenize.test.ts +0 -49
- package/src/lib/search/auto-indexing.ts +0 -22
- package/src/lib/search/availability.ts +0 -232
- package/src/lib/search/tokenLookup.ts +0 -133
- package/src/lib/telemetry/runtime.ts +0 -72
- package/src/lib/webhooks/__tests__/body.test.ts +0 -95
- package/src/lib/webhooks/body.ts +0 -70
- package/src/lib/webhooks/inbound-types.ts +0 -125
- package/src/modules/__tests__/nav-group-order-override.test.ts +0 -183
- package/src/modules/payment_gateways/__tests__/types.test.ts +0 -37
- package/src/modules/widgets/__tests__/extension-points.test.ts +0 -101
- package/src/modules/widgets/extension-points.ts +0 -524
- package/src/security/__tests__/featurePolicy.test.ts +0 -166
- package/src/security/featurePolicy.ts +0 -89
|
@@ -1,73 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,65 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
createClientOnlyStubPlugin,
|
|
7
|
-
encodeJsStringLiteral,
|
|
8
|
-
isClientOnlyModulePath,
|
|
9
|
-
renderClientOnlyModuleStub,
|
|
10
|
-
} from '../clientOnlyModules'
|
|
11
|
-
import { createCliBundlePlugins } from '../dynamicLoader'
|
|
12
|
-
|
|
13
|
-
const CHART_IMPORT = '@open-mercato/ui/backend/charts'
|
|
14
|
-
|
|
15
|
-
function createServerHelperFixture(tempDir: string): string {
|
|
16
|
-
const moduleDir = path.join(tempDir, 'src', 'modules', 'demo')
|
|
17
|
-
fs.mkdirSync(moduleDir, { recursive: true })
|
|
18
|
-
|
|
19
|
-
fs.writeFileSync(
|
|
20
|
-
path.join(moduleDir, 'http.client.ts'),
|
|
21
|
-
['export const httpClient = { fetchOrders: async () => [] }', ''].join('\n'),
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
const entry = path.join(tempDir, 'modules.cli.generated.ts')
|
|
25
|
-
fs.writeFileSync(
|
|
26
|
-
entry,
|
|
27
|
-
[
|
|
28
|
-
"import { httpClient } from './src/modules/demo/http.client'",
|
|
29
|
-
'export const modules = [{',
|
|
30
|
-
" id: 'demo',",
|
|
31
|
-
' workers: [{ handler: () => httpClient.fetchOrders() }],',
|
|
32
|
-
'}]',
|
|
33
|
-
'',
|
|
34
|
-
].join('\n'),
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
return entry
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function createAppModuleFixture(tempDir: string): string {
|
|
41
|
-
const widgetDir = path.join(tempDir, 'src', 'modules', 'demo', 'widgets', 'dashboard', 'sales')
|
|
42
|
-
fs.mkdirSync(widgetDir, { recursive: true })
|
|
43
|
-
|
|
44
|
-
fs.writeFileSync(
|
|
45
|
-
path.join(widgetDir, 'widget.client.tsx'),
|
|
46
|
-
[
|
|
47
|
-
'"use client"',
|
|
48
|
-
`import { BarChart } from '${CHART_IMPORT}'`,
|
|
49
|
-
'export default function DemoWidget() { return BarChart }',
|
|
50
|
-
'',
|
|
51
|
-
].join('\n'),
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
const entry = path.join(tempDir, 'modules.cli.generated.ts')
|
|
55
|
-
fs.writeFileSync(
|
|
56
|
-
entry,
|
|
57
|
-
[
|
|
58
|
-
'export const modules = [{',
|
|
59
|
-
" id: 'demo',",
|
|
60
|
-
' dashboardWidgets: [{',
|
|
61
|
-
" moduleId: 'demo',",
|
|
62
|
-
" key: 'demo:sales:widget',",
|
|
63
|
-
" loader: () => import('./src/modules/demo/widgets/dashboard/sales/widget.client').then((mod) => mod.default ?? mod),",
|
|
64
|
-
' }],',
|
|
65
|
-
'}]',
|
|
66
|
-
'',
|
|
67
|
-
].join('\n'),
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
return entry
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function bundle(entry: string, outfile: string, withStubPlugin: boolean): Promise<string> {
|
|
74
|
-
const esbuild = await import('esbuild')
|
|
75
|
-
await esbuild.build({
|
|
76
|
-
entryPoints: [entry],
|
|
77
|
-
outfile,
|
|
78
|
-
bundle: true,
|
|
79
|
-
format: 'esm',
|
|
80
|
-
platform: 'node',
|
|
81
|
-
target: 'node18',
|
|
82
|
-
external: ['@open-mercato/*'],
|
|
83
|
-
plugins: withStubPlugin ? [createClientOnlyStubPlugin()] : [],
|
|
84
|
-
})
|
|
85
|
-
return fs.readFileSync(outfile, 'utf8')
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
describe('isClientOnlyModulePath', () => {
|
|
89
|
-
it('matches local client modules with and without an extension', () => {
|
|
90
|
-
expect(isClientOnlyModulePath('./widget.client')).toBe(true)
|
|
91
|
-
expect(isClientOnlyModulePath('./widget.client.tsx')).toBe(true)
|
|
92
|
-
expect(isClientOnlyModulePath('../dashboard/sales/widget.client.ts')).toBe(true)
|
|
93
|
-
expect(isClientOnlyModulePath('@/modules/demo/widgets/dashboard/sales/widget.client')).toBe(true)
|
|
94
|
-
expect(isClientOnlyModulePath('./notifications.client.ts')).toBe(true)
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('leaves bare package specifiers to the external-import plugin', () => {
|
|
98
|
-
expect(isClientOnlyModulePath('@open-mercato/ui/backend/charts')).toBe(false)
|
|
99
|
-
expect(isClientOnlyModulePath('@open-mercato/core/modules/demo/widget.client')).toBe(false)
|
|
100
|
-
expect(isClientOnlyModulePath('react')).toBe(false)
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
it('does not match server modules that merely mention client', () => {
|
|
104
|
-
expect(isClientOnlyModulePath('./widget.ts')).toBe(false)
|
|
105
|
-
expect(isClientOnlyModulePath('./clientFactory.ts')).toBe(false)
|
|
106
|
-
expect(isClientOnlyModulePath('./client/index.ts')).toBe(false)
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
it('does not match the generated notification-renderer registry', () => {
|
|
110
|
-
expect(isClientOnlyModulePath('@/.mercato/generated/notifications.client.generated')).toBe(false)
|
|
111
|
-
expect(isClientOnlyModulePath('@/.mercato/generated/notifications.client.generated.ts')).toBe(false)
|
|
112
|
-
})
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
describe('encodeJsStringLiteral', () => {
|
|
116
|
-
it('escapes quotes and backslashes so the literal cannot be terminated early', () => {
|
|
117
|
-
expect(encodeJsStringLiteral('say "hi"')).toBe('"say \\"hi\\""')
|
|
118
|
-
expect(encodeJsStringLiteral('a\\b')).toBe('"a\\\\b"')
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('escapes every character outside printable ASCII, including the separators JSON leaves raw', () => {
|
|
122
|
-
expect(encodeJsStringLiteral('a\u2028b')).toBe('"a\\u2028b"')
|
|
123
|
-
expect(encodeJsStringLiteral('a\u2029b')).toBe('"a\\u2029b"')
|
|
124
|
-
expect(encodeJsStringLiteral('a\nb')).toBe('"a\\u000ab"')
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
it('leaves no unescaped delimiter that could break out of the literal', () => {
|
|
128
|
-
const hostile = './widget.client"); globalThis.pwned = true; ("'
|
|
129
|
-
const encoded = encodeJsStringLiteral(hostile)
|
|
130
|
-
|
|
131
|
-
expect(encoded.startsWith('"')).toBe(true)
|
|
132
|
-
expect(encoded.endsWith('"')).toBe(true)
|
|
133
|
-
expect(encoded.slice(1, -1)).not.toMatch(/(^|[^\\])"/)
|
|
134
|
-
expect(JSON.parse(encoded)).toBe(hostile)
|
|
135
|
-
})
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
describe('renderClientOnlyModuleStub', () => {
|
|
139
|
-
it('exports a default that throws when the browser component is actually used', () => {
|
|
140
|
-
const stub = renderClientOnlyModuleStub('./widget.client')
|
|
141
|
-
expect(stub).toContain('export default clientOnlyModuleUnavailable')
|
|
142
|
-
expect(stub).toContain('./widget.client')
|
|
143
|
-
})
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
describe('createCliBundlePlugins', () => {
|
|
147
|
-
it('registers the client-only stub ahead of the alias and external plugins', () => {
|
|
148
|
-
expect(createCliBundlePlugins('/tmp/app-root').map((plugin) => plugin.name)).toEqual([
|
|
149
|
-
'client-only-stub',
|
|
150
|
-
'alias-resolver',
|
|
151
|
-
'external-non-json',
|
|
152
|
-
])
|
|
153
|
-
})
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
describe('CLI bundle graph', () => {
|
|
157
|
-
let tempDir: string
|
|
158
|
-
|
|
159
|
-
beforeEach(() => {
|
|
160
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'open-mercato-client-only-'))
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
afterEach(() => {
|
|
164
|
-
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
it('keeps browser-only widget imports out of the CLI bundle', async () => {
|
|
168
|
-
const entry = createAppModuleFixture(tempDir)
|
|
169
|
-
const output = await bundle(entry, path.join(tempDir, 'stubbed.mjs'), true)
|
|
170
|
-
|
|
171
|
-
expect(output).not.toContain(CHART_IMPORT)
|
|
172
|
-
expect(output).toContain('clientOnlyModuleUnavailable')
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
it('without the plugin the same fixture hoists the browser-only import (regression guard)', async () => {
|
|
176
|
-
const entry = createAppModuleFixture(tempDir)
|
|
177
|
-
const output = await bundle(entry, path.join(tempDir, 'plain.mjs'), false)
|
|
178
|
-
|
|
179
|
-
expect(output).toContain(CHART_IMPORT)
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
it('leaves statically imported server helpers alone even when they are named *.client', async () => {
|
|
183
|
-
const entry = createServerHelperFixture(tempDir)
|
|
184
|
-
const output = await bundle(entry, path.join(tempDir, 'server-helper.mjs'), true)
|
|
185
|
-
|
|
186
|
-
expect(output).toContain('fetchOrders')
|
|
187
|
-
expect(output).not.toContain('clientOnlyModuleUnavailable')
|
|
188
|
-
})
|
|
189
|
-
})
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @jest-environment node
|
|
3
|
-
*
|
|
4
|
-
* The CLI bundle's `@/` alias must mirror what the app tsconfig maps, because the dynamic
|
|
5
|
-
* bootstrap path compiles app sources (`src/di.ts`) and generated registries that import app
|
|
6
|
-
* modules through the alias. The tsconfig maps `@/.mercato/*` to the app root and every other
|
|
7
|
-
* `@/*` to the app's `src/` directory; resolving everything against the app root silently sends
|
|
8
|
-
* `@/lib/x` to a path that does not exist.
|
|
9
|
-
*/
|
|
10
|
-
import fs from 'node:fs'
|
|
11
|
-
import os from 'node:os'
|
|
12
|
-
import path from 'node:path'
|
|
13
|
-
|
|
14
|
-
import { createCliBundlePlugins } from '../dynamicLoader'
|
|
15
|
-
|
|
16
|
-
async function bundleWithCliPlugins(appRoot: string, entry: string): Promise<string> {
|
|
17
|
-
const esbuild = await import('esbuild')
|
|
18
|
-
const outfile = path.join(appRoot, 'bundle.mjs')
|
|
19
|
-
await esbuild.build({
|
|
20
|
-
entryPoints: [entry],
|
|
21
|
-
outfile,
|
|
22
|
-
absWorkingDir: appRoot,
|
|
23
|
-
bundle: true,
|
|
24
|
-
format: 'esm',
|
|
25
|
-
platform: 'node',
|
|
26
|
-
target: 'node18',
|
|
27
|
-
plugins: createCliBundlePlugins(appRoot),
|
|
28
|
-
})
|
|
29
|
-
return fs.readFileSync(outfile, 'utf8')
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe('createCliBundlePlugins — @/ alias resolution', () => {
|
|
33
|
-
let appRoot: string
|
|
34
|
-
|
|
35
|
-
beforeEach(() => {
|
|
36
|
-
appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-alias-resolver-'))
|
|
37
|
-
fs.mkdirSync(path.join(appRoot, 'src', 'lib'), { recursive: true })
|
|
38
|
-
fs.mkdirSync(path.join(appRoot, 'src', 'modules', 'demo'), { recursive: true })
|
|
39
|
-
fs.mkdirSync(path.join(appRoot, '.mercato', 'generated'), { recursive: true })
|
|
40
|
-
|
|
41
|
-
fs.writeFileSync(
|
|
42
|
-
path.join(appRoot, 'src', 'lib', 'marker.ts'),
|
|
43
|
-
"export const srcLibMarker = 'resolved-from-src-lib'\n",
|
|
44
|
-
)
|
|
45
|
-
fs.writeFileSync(
|
|
46
|
-
path.join(appRoot, 'src', 'modules', 'demo', 'index.ts'),
|
|
47
|
-
"export const srcModuleMarker = 'resolved-from-src-module-index'\n",
|
|
48
|
-
)
|
|
49
|
-
fs.writeFileSync(
|
|
50
|
-
path.join(appRoot, '.mercato', 'generated', 'registry.generated.ts'),
|
|
51
|
-
"export const generatedMarker = 'resolved-from-generated'\n",
|
|
52
|
-
)
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
afterEach(() => {
|
|
56
|
-
fs.rmSync(appRoot, { recursive: true, force: true })
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('resolves @/* against the app src directory and @/.mercato/* against the app root', async () => {
|
|
60
|
-
const entry = path.join(appRoot, '.mercato', 'generated', 'entry.ts')
|
|
61
|
-
fs.writeFileSync(
|
|
62
|
-
entry,
|
|
63
|
-
[
|
|
64
|
-
"import { srcLibMarker } from '@/lib/marker'",
|
|
65
|
-
"import { srcModuleMarker } from '@/modules/demo'",
|
|
66
|
-
"import { generatedMarker } from '@/.mercato/generated/registry.generated'",
|
|
67
|
-
'export const markers = [srcLibMarker, srcModuleMarker, generatedMarker]',
|
|
68
|
-
'',
|
|
69
|
-
].join('\n'),
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
const output = await bundleWithCliPlugins(appRoot, entry)
|
|
73
|
-
|
|
74
|
-
expect(output).toContain('resolved-from-src-lib')
|
|
75
|
-
expect(output).toContain('resolved-from-src-module-index')
|
|
76
|
-
expect(output).toContain('resolved-from-generated')
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('prefers an app-root match when the specifier has no src counterpart', async () => {
|
|
80
|
-
fs.writeFileSync(
|
|
81
|
-
path.join(appRoot, 'root-only.ts'),
|
|
82
|
-
"export const rootOnlyMarker = 'resolved-from-app-root'\n",
|
|
83
|
-
)
|
|
84
|
-
const entry = path.join(appRoot, '.mercato', 'generated', 'entry.ts')
|
|
85
|
-
fs.writeFileSync(
|
|
86
|
-
entry,
|
|
87
|
-
[
|
|
88
|
-
"import { rootOnlyMarker } from '@/root-only'",
|
|
89
|
-
'export const markers = [rootOnlyMarker]',
|
|
90
|
-
'',
|
|
91
|
-
].join('\n'),
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
const output = await bundleWithCliPlugins(appRoot, entry)
|
|
95
|
-
|
|
96
|
-
expect(output).toContain('resolved-from-app-root')
|
|
97
|
-
})
|
|
98
|
-
})
|