@open-mercato/shared 0.8.1-develop.7297.1.67605e74f9 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/lib/auth/jwt.js +4 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/organizationScope.js +15 -1
  6. package/dist/lib/auth/organizationScope.js.map +2 -2
  7. package/dist/lib/availability/catalogOnlyProvider.js +77 -0
  8. package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
  9. package/dist/lib/availability/index.js +8 -0
  10. package/dist/lib/availability/index.js.map +7 -0
  11. package/dist/lib/availability/registry.js +47 -0
  12. package/dist/lib/availability/registry.js.map +7 -0
  13. package/dist/lib/availability/types.js +7 -0
  14. package/dist/lib/availability/types.js.map +7 -0
  15. package/dist/lib/catalog-visibility/index.js +11 -0
  16. package/dist/lib/catalog-visibility/index.js.map +7 -0
  17. package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
  18. package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
  19. package/dist/lib/catalog-visibility/matchesOne.js +27 -0
  20. package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
  21. package/dist/lib/catalog-visibility/matchesScope.js +9 -0
  22. package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
  23. package/dist/lib/catalog-visibility/types.js +1 -0
  24. package/dist/lib/catalog-visibility/types.js.map +7 -0
  25. package/dist/lib/catalog-visibility/unionScopes.js +9 -0
  26. package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
  27. package/dist/lib/commands/command-bus.js +383 -125
  28. package/dist/lib/commands/command-bus.js.map +3 -3
  29. package/dist/lib/commands/flush.js +16 -0
  30. package/dist/lib/commands/flush.js.map +2 -2
  31. package/dist/lib/commands/index.js +8 -0
  32. package/dist/lib/commands/index.js.map +2 -2
  33. package/dist/lib/commands/redo.js +2 -7
  34. package/dist/lib/commands/redo.js.map +2 -2
  35. package/dist/lib/commands/transaction-lifetime.js +67 -0
  36. package/dist/lib/commands/transaction-lifetime.js.map +7 -0
  37. package/dist/lib/commands/types.js.map +2 -2
  38. package/dist/lib/commands/undo.js +54 -2
  39. package/dist/lib/commands/undo.js.map +2 -2
  40. package/dist/lib/crud/enricher-runner.js +64 -9
  41. package/dist/lib/crud/enricher-runner.js.map +2 -2
  42. package/dist/lib/crud/factory.js +42 -18
  43. package/dist/lib/crud/factory.js.map +2 -2
  44. package/dist/lib/data/engine.js +12 -3
  45. package/dist/lib/data/engine.js.map +2 -2
  46. package/dist/lib/db/accentInsensitiveSearch.js +21 -0
  47. package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
  48. package/dist/lib/encryption/kms.js +1 -5
  49. package/dist/lib/encryption/kms.js.map +2 -2
  50. package/dist/lib/encryption/likeFilterWarning.js +3 -1
  51. package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
  52. package/dist/lib/encryption/migration-backfill.js +69 -0
  53. package/dist/lib/encryption/migration-backfill.js.map +7 -0
  54. package/dist/lib/encryption/policyMemo.js +28 -0
  55. package/dist/lib/encryption/policyMemo.js.map +7 -0
  56. package/dist/lib/encryption/subscriber.js +30 -3
  57. package/dist/lib/encryption/subscriber.js.map +2 -2
  58. package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
  59. package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
  60. package/dist/lib/modules/api-route-identity.js +102 -0
  61. package/dist/lib/modules/api-route-identity.js.map +7 -0
  62. package/dist/lib/telemetry/runtime.js.map +2 -2
  63. package/dist/lib/version.js +1 -1
  64. package/dist/lib/version.js.map +1 -1
  65. package/dist/modules/events/factory.js +20 -0
  66. package/dist/modules/events/factory.js.map +2 -2
  67. package/dist/modules/registry.js +3 -1
  68. package/dist/modules/registry.js.map +2 -2
  69. package/package.json +12 -5
  70. package/src/lib/auth/__tests__/jwt.test.ts +12 -0
  71. package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
  72. package/src/lib/auth/jwt.ts +9 -0
  73. package/src/lib/auth/organizationScope.ts +39 -0
  74. package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
  75. package/src/lib/availability/__tests__/registry.test.ts +130 -0
  76. package/src/lib/availability/catalogOnlyProvider.ts +152 -0
  77. package/src/lib/availability/index.ts +18 -0
  78. package/src/lib/availability/registry.ts +131 -0
  79. package/src/lib/availability/types.ts +86 -0
  80. package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
  81. package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
  82. package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
  83. package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
  84. package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
  85. package/src/lib/catalog-visibility/index.ts +5 -0
  86. package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
  87. package/src/lib/catalog-visibility/matchesOne.ts +28 -0
  88. package/src/lib/catalog-visibility/matchesScope.ts +8 -0
  89. package/src/lib/catalog-visibility/types.ts +31 -0
  90. package/src/lib/catalog-visibility/unionScopes.ts +16 -0
  91. package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
  92. package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
  93. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
  94. package/src/lib/commands/__tests__/redo.test.ts +4 -0
  95. package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
  96. package/src/lib/commands/__tests__/undo.test.ts +90 -1
  97. package/src/lib/commands/command-bus.ts +463 -148
  98. package/src/lib/commands/flush.ts +16 -0
  99. package/src/lib/commands/index.ts +7 -0
  100. package/src/lib/commands/redo.ts +4 -8
  101. package/src/lib/commands/transaction-lifetime.ts +88 -0
  102. package/src/lib/commands/types.ts +91 -0
  103. package/src/lib/commands/undo.ts +79 -1
  104. package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
  105. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
  106. package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
  107. package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
  108. package/src/lib/crud/enricher-runner.ts +83 -9
  109. package/src/lib/crud/factory.ts +46 -19
  110. package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
  111. package/src/lib/data/engine.ts +12 -0
  112. package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
  113. package/src/lib/db/accentInsensitiveSearch.ts +70 -0
  114. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
  115. package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
  116. package/src/lib/encryption/__tests__/kms.test.ts +45 -3
  117. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
  118. package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
  119. package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
  120. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
  121. package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
  122. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
  123. package/src/lib/encryption/kms.ts +1 -6
  124. package/src/lib/encryption/likeFilterWarning.ts +10 -2
  125. package/src/lib/encryption/migration-backfill.ts +90 -0
  126. package/src/lib/encryption/policyMemo.ts +51 -0
  127. package/src/lib/encryption/subscriber.ts +41 -4
  128. package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
  129. package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
  130. package/src/lib/modules/api-route-identity.ts +116 -0
  131. package/src/lib/telemetry/runtime.ts +8 -0
  132. package/src/modules/__tests__/registry.test.ts +92 -0
  133. package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
  134. package/src/modules/events/factory.ts +31 -0
  135. package/src/modules/events/types.ts +13 -0
  136. package/src/modules/notifications/types.ts +1 -0
  137. package/src/modules/registry.ts +4 -1
@@ -1,3 +1,3 @@
1
1
  [generate-countries] up to date (259 countries)
2
- [build:shared] found 299 entry points
2
+ [build:shared] found 314 entry points
3
3
  [build:shared] built successfully
package/AGENTS.md CHANGED
@@ -38,6 +38,7 @@ yarn workspace @open-mercato/shared build
38
38
  | `auth/organizationScope` | When an organization-scoped API route must resolve the caller's organization — falls back to `actorOrgId` for an "all organizations" selection, but only while the effective tenant is still the actor's tenant. On `null` for an authenticated caller answer with `organizationScopeRequiredResponse()` (400, code `organization_scope_required`) — never 401 | `@open-mercato/shared/lib/auth/organizationScope` — `resolveActiveOrganizationId(auth)`, `organizationScopeRequiredResponse()` |
39
39
  | `boolean/` | When parsing boolean strings from env/query params | `@open-mercato/shared/lib/boolean` |
40
40
  | `browser/` | When persisting client UI state to `localStorage` — use the safe wrappers and the versioned-envelope helper instead of raw `localStorage` reads/writes | `@open-mercato/shared/lib/browser/safeLocalStorage`, `@open-mercato/shared/lib/browser/versionedPreference` |
41
+ | `catalog-visibility/` | When combining buyer-facing catalog assortment grants (group-level, channel-level) into one effective visibility check — `AssortmentScope`/`EffectiveAssortmentScope` types plus the pure `matchesOne`/`matchesScope`/`unionScopes`/`intersectScopes` algebra. Imports nothing from `catalog`, `customer_groups`, `ecommerce`, or `cart` by construction; `customer_groups`'s `resolveAssortmentScope()` and `ecommerce`'s `BuyerContext.assortmentScope` composition build on top of it | `@open-mercato/shared/lib/catalog-visibility` |
41
42
  | `commands/` | When implementing undo/redo command pattern | `@open-mercato/shared/lib/commands` |
42
43
  | `commands/flush` | When a command mutates entities across multiple phases (scalar + relation syncs) — wraps phases in a single atomic flush | `@open-mercato/shared/lib/commands/flush` — `withAtomicFlush(em, phases, { transaction? })` |
43
44
  | `commands/runCrudCommandWrite` | When a command writes an entity + custom fields + CRUD/index side effects in one logical operation — composes fork → atomic flush → custom-field write → side-effect queue in the only correct order. **Prefer this over composing the primitives by hand for new commands.** | `@open-mercato/shared/lib/commands/runCrudCommandWrite` — `runCrudCommandWrite({ ctx, entityId, action, scope, phases, customFields?, events?, indexer?, sideEffect })` |
@@ -275,6 +276,7 @@ A command's `buildLog()` returns `payload: { undo: { before, after } }`, but the
275
276
 
276
277
  MUST rules:
277
278
  - Inside `undo()`, read the snapshot **only** through `extractUndoPayload<UndoPayload<TSnapshot>>(logEntry)` from `@open-mercato/shared/lib/commands/undo`. It unwraps `commandPayload` (and the redo envelope) and falls back to `snapshotBefore`/`snapshotAfter`.
279
+ - Snapshot `Date`s come back as ISO strings: pass `{ dateFields: [...] }` (or `datePaths`) to `extractUndoPayload` before assigning them to entities (#6336).
278
280
  - NEVER access `logEntry.payload` in an undo handler. The `logEntry` parameter is typed as `CommandUndoLogEntry`, which intentionally omits `payload` so this footgun is a compile-time error.
279
281
  - Delete-undo should be robust to either deletion strategy: clear `deletedAt` when the row survives (soft delete), otherwise re-create the entity from the snapshot (mirror `packages/core/src/modules/sales/commands/configuration.ts`).
280
282
 
@@ -62,6 +62,9 @@ function inspectSecret(secret) {
62
62
  if (value.length < MIN_SECRET_LENGTH) return "too_short";
63
63
  return null;
64
64
  }
65
+ function isUnsafeJwtSecret(secret) {
66
+ return inspectSecret(secret) !== null;
67
+ }
65
68
  function describeViolation(name, violation) {
66
69
  switch (violation) {
67
70
  case "missing":
@@ -256,6 +259,7 @@ export {
256
259
  assertJwtSecretPolicy,
257
260
  deriveJwtAudienceSecret,
258
261
  isMfaPendingJwtPayload,
262
+ isUnsafeJwtSecret,
259
263
  signAudienceJwt,
260
264
  signJwt,
261
265
  verifyAudienceJwt,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/auth/jwt.ts"],
4
- "sourcesContent": ["import crypto from 'node:crypto'\nimport { createLogger } from '../logger'\nimport { parseNumberWithDefault } from '../number'\n\nconst logger = createLogger('auth').child({ component: 'jwt' })\n\nfunction base64url(input: Buffer | string) {\n return (typeof input === 'string' ? Buffer.from(input) : input)\n .toString('base64')\n .replace(/=/g, '')\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n}\n\nexport type JwtPayload = Record<string, any>\n\nexport type JwtAudience = 'staff' | 'customer' | (string & {})\n\nexport type SignJwtOptions = {\n secret?: string\n expiresInSec?: number\n audience?: string\n issuer?: string\n}\n\nexport type VerifyJwtOptions = {\n secret?: string\n audience?: string\n issuer?: string\n}\n\nconst DEFAULT_ISSUER = 'open-mercato'\nconst DEFAULT_STAFF_AUDIENCE: JwtAudience = 'staff'\nconst AUDIENCE_SECRET_LABEL = 'open-mercato:jwt:v1'\n\nconst LEGACY_GRACE_DEFAULT_MINUTES = 480\nconst LEGACY_TOKEN_CLOCK_SKEW_SECONDS = 60\n\n/**\n * How long after a token was issued (`iat`) the raw-`JWT_SECRET` fallback in `verifyJwt` keeps\n * accepting it. The fallback exists so a rolling deployment of the audience-derived signing\n * scheme does not force-log-out every user; it is a migration window, not a permanent mode.\n *\n * Set via `JWT_LEGACY_GRACE_MINUTES`. Defaults to 480 (8 hours \u2014 one full token TTL).\n * `0`, `false` or `off` disables the fallback entirely (hard cutover). Values that do not parse\n * fall back to the default rather than silently disabling authentication.\n */\nfunction getLegacyGraceMinutes(): number {\n const raw = process.env.JWT_LEGACY_GRACE_MINUTES\n const normalized = typeof raw === 'string' ? raw.trim().toLowerCase() : raw\n if (normalized === 'false' || normalized === 'off') return 0\n return parseNumberWithDefault(normalized, LEGACY_GRACE_DEFAULT_MINUTES, { min: 0, integer: true })\n}\n\n/**\n * Required absolute deadline for the legacy fallback, as an ISO-8601 instant in\n * `JWT_LEGACY_CUTOVER_AT`. Without a valid deadline the fallback stays disabled: token `iat` is\n * attacker-controlled by anyone who knows the former raw secret, so a relative age check alone\n * cannot make the migration window finite.\n */\nfunction getLegacyCutoverEpochSeconds(): number | null {\n const raw = process.env.JWT_LEGACY_CUTOVER_AT\n if (!raw || !raw.trim()) return null\n const parsed = Date.parse(raw.trim())\n if (Number.isNaN(parsed)) {\n warnOnce(\n 'jwt-legacy-cutover-unparseable',\n 'JWT_LEGACY_CUTOVER_AT is not a valid ISO-8601 instant \u2014 legacy JWT fallback remains disabled.',\n )\n return null\n }\n return Math.floor(parsed / 1000)\n}\n\nconst MIN_SECRET_LENGTH = 32\n\n/**\n * Signing secrets that ship in this repository's own examples, compose files, and docs. A\n * deployment reaching production with one of these is not \"weakly configured\" \u2014 it is publicly\n * forgeable by anyone who has read the repository.\n */\nconst PLACEHOLDER_SECRETS = new Set([\n 'jwt',\n 'jwt-secret',\n 'jwtsecret',\n 'secret',\n 'password',\n 'changeme',\n 'change-me',\n 'change-me-dev-secret',\n 'change-me-dev-auth-secret',\n 'your-strong-jwt-secret',\n 'your-secure-jwt-secret-change-me',\n 'dev',\n 'development',\n 'test',\n])\n\nexport type JwtSecretViolation = 'missing' | 'placeholder' | 'too_short'\n\nconst warnedKeys = new Set<string>()\n\nfunction warnOnce(key: string, message: string): void {\n if (warnedKeys.has(key)) return\n warnedKeys.add(key)\n logger.warn(message)\n}\n\nfunction isProduction(): boolean {\n return process.env.NODE_ENV === 'production'\n}\n\nfunction inspectSecret(secret: string | undefined | null): JwtSecretViolation | null {\n const value = typeof secret === 'string' ? secret.trim() : ''\n if (!value) return 'missing'\n if (PLACEHOLDER_SECRETS.has(value.toLowerCase())) return 'placeholder'\n if (value.length < MIN_SECRET_LENGTH) return 'too_short'\n return null\n}\n\nfunction describeViolation(name: string, violation: JwtSecretViolation): string {\n switch (violation) {\n case 'missing':\n return `${name} is not set. Generate one with \\`openssl rand -hex 32\\`.`\n case 'placeholder':\n return `${name} is set to a placeholder value published in this repository's examples, so anyone can forge tokens for this deployment. Generate a real one with \\`openssl rand -hex 32\\`.`\n case 'too_short':\n return `${name} is shorter than ${MIN_SECRET_LENGTH} characters. Generate a stronger one with \\`openssl rand -hex 32\\`.`\n }\n}\n\n/**\n * Fail closed in production, warn in every other environment. Called on every secret read so\n * worker, scheduler, and CLI processes \u2014 which never run the app's startup hook \u2014 are covered\n * too. `assertJwtSecretPolicy` runs the same check eagerly at server startup.\n */\nfunction enforceSecretPolicy(name: string, secret: string | undefined | null): void {\n const violation = inspectSecret(secret)\n if (!violation) return\n const message = describeViolation(name, violation)\n if (isProduction()) {\n throw new Error(`[auth.jwt] Refusing to run in production with an unsafe signing secret: ${message}`)\n }\n warnOnce(`secret-policy:${name}:${violation}`, `${message} This is tolerated outside production only.`)\n}\n\n/**\n * Validate every JWT signing secret this process would use. Call it once at startup so a\n * misconfigured production deployment fails immediately and loudly instead of at the first login\n * attempt. Throws in production; logs a warning elsewhere.\n */\nexport function assertJwtSecretPolicy(): void {\n enforceSecretPolicy('JWT_SECRET', process.env.JWT_SECRET)\n for (const [key, value] of Object.entries(process.env)) {\n if (!/^JWT_[A-Z0-9]+(?:_[A-Z0-9]+)*_SECRET$/.test(key)) continue\n enforceSecretPolicy(key, value)\n }\n}\n\nfunction readBaseSecret(explicit?: string): string {\n const secret = explicit ?? process.env.JWT_SECRET\n if (!secret) throw new Error('JWT_SECRET is not set')\n if (explicit === undefined) enforceSecretPolicy('JWT_SECRET', secret)\n return secret\n}\n\nfunction normalizeAudience(audience: string): string {\n return audience.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_')\n}\n\nconst derivedSecretCache = new Map<string, string>()\n\nfunction deriveAudienceSecretFromBase(normalized: string, base: string): string {\n const cacheKey = `${normalized}::${base}`\n const cached = derivedSecretCache.get(cacheKey)\n if (cached !== undefined) return cached\n const label = `${AUDIENCE_SECRET_LABEL}:${normalized}`\n const derived = crypto.createHmac('sha256', base).update(label).digest('hex')\n derivedSecretCache.set(cacheKey, derived)\n return derived\n}\n\n/**\n * Derive a per-audience signing key from the base `JWT_SECRET`.\n *\n * - If `JWT_${AUDIENCE}_SECRET` env var is set, it is used verbatim (allows operators to rotate a\n * single audience independently).\n * - Otherwise, the key is derived deterministically via HMAC-SHA256 from the base secret using a\n * versioned label. This ensures that a staff JWT signature cannot verify against the customer\n * key (and vice versa) even though both share the same base `JWT_SECRET`.\n * - Derived values are memoized per `(audience, baseSecret)` for the process lifetime.\n */\nexport function deriveJwtAudienceSecret(audience: string, baseSecret?: string): string {\n const normalized = normalizeAudience(audience)\n if (!normalized) throw new Error('Audience is required to derive a JWT secret')\n const overrideName = `JWT_${normalized.toUpperCase()}_SECRET`\n const override = process.env[overrideName]\n if (override && override.trim().length > 0) {\n enforceSecretPolicy(overrideName, override)\n return override\n }\n const base = readBaseSecret(baseSecret)\n return deriveAudienceSecretFromBase(normalized, base)\n}\n\nfunction isSignOptions(value: string | SignJwtOptions | undefined): value is SignJwtOptions {\n return typeof value === 'object' && value !== null\n}\n\nfunction isVerifyOptions(value: string | VerifyJwtOptions | undefined): value is VerifyJwtOptions {\n return typeof value === 'object' && value !== null\n}\n\nfunction toSignOptions(secretOrOptions?: string | SignJwtOptions, expiresInSec?: number): { secret: string; expiresInSec: number; audience?: string; issuer?: string } {\n if (isSignOptions(secretOrOptions)) {\n const audience = secretOrOptions.audience ?? DEFAULT_STAFF_AUDIENCE\n const secret = secretOrOptions.secret ?? deriveJwtAudienceSecret(audience)\n if (!secret) throw new Error('JWT_SECRET is not set')\n return {\n secret,\n expiresInSec: secretOrOptions.expiresInSec ?? 60 * 60 * 8,\n audience,\n issuer: secretOrOptions.issuer ?? DEFAULT_ISSUER,\n }\n }\n if (typeof secretOrOptions === 'string') {\n // Legacy: explicit raw secret supplied by caller \u2014 keep audience/issuer off by default so\n // existing tests and callers that BYO secret see unchanged behavior.\n if (!secretOrOptions) throw new Error('JWT_SECRET is not set')\n return {\n secret: secretOrOptions,\n expiresInSec: expiresInSec ?? 60 * 60 * 8,\n }\n }\n // Default path: staff-audience derived secret + iss/aud claims.\n return {\n secret: deriveJwtAudienceSecret(DEFAULT_STAFF_AUDIENCE),\n expiresInSec: expiresInSec ?? 60 * 60 * 8,\n audience: DEFAULT_STAFF_AUDIENCE,\n issuer: DEFAULT_ISSUER,\n }\n}\n\nfunction toVerifyOptions(secretOrOptions?: string | VerifyJwtOptions): { secret: string; audience?: string; issuer?: string } {\n if (isVerifyOptions(secretOrOptions)) {\n const audience = secretOrOptions.audience ?? DEFAULT_STAFF_AUDIENCE\n const secret = secretOrOptions.secret ?? deriveJwtAudienceSecret(audience)\n if (!secret) throw new Error('JWT_SECRET is not set')\n return {\n secret,\n audience,\n issuer: secretOrOptions.issuer ?? DEFAULT_ISSUER,\n }\n }\n if (typeof secretOrOptions === 'string') {\n if (!secretOrOptions) throw new Error('JWT_SECRET is not set')\n // Legacy explicit secret: no audience/issuer enforcement.\n return { secret: secretOrOptions }\n }\n return {\n secret: deriveJwtAudienceSecret(DEFAULT_STAFF_AUDIENCE),\n audience: DEFAULT_STAFF_AUDIENCE,\n issuer: DEFAULT_ISSUER,\n }\n}\n\nexport function signJwt(\n payload: JwtPayload,\n secretOrOptions?: string | SignJwtOptions,\n expiresInSec?: number,\n) {\n const options = toSignOptions(secretOrOptions, expiresInSec)\n const header = { alg: 'HS256', typ: 'JWT' }\n const now = Math.floor(Date.now() / 1000)\n const body: JwtPayload = { iat: now, exp: now + options.expiresInSec, ...payload }\n if (options.issuer && body.iss === undefined) body.iss = options.issuer\n if (options.audience && body.aud === undefined) body.aud = options.audience\n const encHeader = base64url(JSON.stringify(header))\n const encBody = base64url(JSON.stringify(body))\n const data = `${encHeader}.${encBody}`\n const sig = crypto.createHmac('sha256', options.secret).update(data).digest()\n const encSig = base64url(sig)\n return `${data}.${encSig}`\n}\n\nfunction verifyWithOptions(token: string, options: { secret: string; audience?: string; issuer?: string }): JwtPayload | null {\n const parts = token.split('.')\n if (parts.length !== 3) return null\n const [h, p, s] = parts\n const data = `${h}.${p}`\n const expected = base64url(crypto.createHmac('sha256', options.secret).update(data).digest())\n const providedSignature = Buffer.from(s)\n const expectedSignature = Buffer.from(expected)\n if (providedSignature.length !== expectedSignature.length) return null\n if (!crypto.timingSafeEqual(providedSignature, expectedSignature)) return null\n let payload: JwtPayload\n try {\n payload = JSON.parse(Buffer.from(p, 'base64').toString('utf8'))\n } catch {\n return null\n }\n const now = Math.floor(Date.now() / 1000)\n if (payload.exp && now > payload.exp) return null\n if (options.audience !== undefined) {\n if (payload.aud !== options.audience) return null\n }\n if (options.issuer !== undefined) {\n if (payload.iss !== options.issuer) return null\n }\n return payload\n}\n\n/**\n * Whether a raw-secret token is still inside the migration window. A token is only ever legacy\n * for a bounded period after it was issued, so `iat` is mandatory: a token that cannot prove its\n * age cannot prove it is inside the window either, and accepting it would make the window\n * unbounded \u2014 which is exactly the defect this guard closes.\n */\nfunction isWithinLegacyWindow(payload: JwtPayload, graceMinutes: number): boolean {\n const cutoverAt = getLegacyCutoverEpochSeconds()\n const now = Math.floor(Date.now() / 1000)\n if (cutoverAt === null || now >= cutoverAt) return false\n const issuedAt = payload.iat\n if (typeof issuedAt !== 'number' || !Number.isFinite(issuedAt)) return false\n if (issuedAt > now + LEGACY_TOKEN_CLOCK_SKEW_SECONDS) return false\n return now - issuedAt <= graceMinutes * 60\n}\n\nexport function verifyJwt(token: string, secretOrOptions?: string | VerifyJwtOptions) {\n const options = toVerifyOptions(secretOrOptions)\n const result = verifyWithOptions(token, options)\n if (result) {\n // `_legacyToken` is assigned by this function alone. Strip any same-named claim carried in\n // the token body so a payload can never talk callers (staff session integrity, portal auth)\n // into treating a modern, session-bound token as a sessionless legacy one.\n if (result._legacyToken !== undefined) delete result._legacyToken\n return result\n }\n\n // Legacy fallback: when the caller used the default path (no explicit secret) and the new\n // audience-derived verification failed, try verifying with the raw JWT_SECRET. This keeps\n // pre-migration tokens working across a rolling deployment \u2014 but only until the token's own\n // `iat` leaves the configured grace window, or the configured cutover instant passes.\n if (secretOrOptions === undefined) {\n const graceMinutes = getLegacyGraceMinutes()\n const rawSecret = process.env.JWT_SECRET\n if (graceMinutes > 0 && rawSecret) {\n const legacyResult = verifyWithOptions(token, { secret: rawSecret })\n if (legacyResult && isWithinLegacyWindow(legacyResult, graceMinutes)) {\n legacyResult._legacyToken = true\n return legacyResult\n }\n }\n }\n\n return null\n}\n\n/**\n * Sign a JWT for a specific audience using an audience-derived signing key. The resulting token\n * carries `iss` and `aud` claims and cannot be verified with the base `JWT_SECRET` directly \u2014\n * callers must use `verifyAudienceJwt` with the same audience.\n */\nexport function signAudienceJwt(\n audience: string,\n payload: JwtPayload,\n expiresInSec: number = 60 * 60 * 8,\n): string {\n return signJwt(payload, { audience, expiresInSec })\n}\n\n/**\n * Verify a JWT that was signed with an audience-scoped secret. Rejects tokens that are missing\n * or carry a mismatched `aud`/`iss` claim, so a staff JWT cannot be replayed against the\n * customer portal (and vice versa) even when the base `JWT_SECRET` is shared.\n */\nexport function verifyAudienceJwt(audience: string, token: string): JwtPayload | null {\n return verifyJwt(token, { audience })\n}\n\n/**\n * True when a verified JWT payload represents an MFA challenge in progress: the second factor\n * has not been completed yet (`mfa_pending: true`, `mfa_verified` not `true`). Such tokens are\n * provisional \u2014 general staff APIs must reject them and only the MFA completion routes may\n * accept them (see `@open-mercato/shared/lib/auth/mfaPendingAccess`).\n *\n * The predicate keys on `mfa_pending` alone by design. `mfa_verified` is NOT a durable session\n * property: routes that legitimately re-sign a staff token (`PUT /api/auth/profile`,\n * `GET|POST /api/auth/session/refresh`) rebuild the claims without it. Tightening this to require\n * `mfa_verified === true` would therefore silently downgrade those sessions.\n */\nexport function isMfaPendingJwtPayload(payload: unknown): boolean {\n if (!payload || typeof payload !== 'object') return false\n const record = payload as Record<string, unknown>\n return record.mfa_pending === true && record.mfa_verified !== true\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import crypto from 'node:crypto'\nimport { createLogger } from '../logger'\nimport { parseNumberWithDefault } from '../number'\n\nconst logger = createLogger('auth').child({ component: 'jwt' })\n\nfunction base64url(input: Buffer | string) {\n return (typeof input === 'string' ? Buffer.from(input) : input)\n .toString('base64')\n .replace(/=/g, '')\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n}\n\nexport type JwtPayload = Record<string, any>\n\nexport type JwtAudience = 'staff' | 'customer' | (string & {})\n\nexport type SignJwtOptions = {\n secret?: string\n expiresInSec?: number\n audience?: string\n issuer?: string\n}\n\nexport type VerifyJwtOptions = {\n secret?: string\n audience?: string\n issuer?: string\n}\n\nconst DEFAULT_ISSUER = 'open-mercato'\nconst DEFAULT_STAFF_AUDIENCE: JwtAudience = 'staff'\nconst AUDIENCE_SECRET_LABEL = 'open-mercato:jwt:v1'\n\nconst LEGACY_GRACE_DEFAULT_MINUTES = 480\nconst LEGACY_TOKEN_CLOCK_SKEW_SECONDS = 60\n\n/**\n * How long after a token was issued (`iat`) the raw-`JWT_SECRET` fallback in `verifyJwt` keeps\n * accepting it. The fallback exists so a rolling deployment of the audience-derived signing\n * scheme does not force-log-out every user; it is a migration window, not a permanent mode.\n *\n * Set via `JWT_LEGACY_GRACE_MINUTES`. Defaults to 480 (8 hours \u2014 one full token TTL).\n * `0`, `false` or `off` disables the fallback entirely (hard cutover). Values that do not parse\n * fall back to the default rather than silently disabling authentication.\n */\nfunction getLegacyGraceMinutes(): number {\n const raw = process.env.JWT_LEGACY_GRACE_MINUTES\n const normalized = typeof raw === 'string' ? raw.trim().toLowerCase() : raw\n if (normalized === 'false' || normalized === 'off') return 0\n return parseNumberWithDefault(normalized, LEGACY_GRACE_DEFAULT_MINUTES, { min: 0, integer: true })\n}\n\n/**\n * Required absolute deadline for the legacy fallback, as an ISO-8601 instant in\n * `JWT_LEGACY_CUTOVER_AT`. Without a valid deadline the fallback stays disabled: token `iat` is\n * attacker-controlled by anyone who knows the former raw secret, so a relative age check alone\n * cannot make the migration window finite.\n */\nfunction getLegacyCutoverEpochSeconds(): number | null {\n const raw = process.env.JWT_LEGACY_CUTOVER_AT\n if (!raw || !raw.trim()) return null\n const parsed = Date.parse(raw.trim())\n if (Number.isNaN(parsed)) {\n warnOnce(\n 'jwt-legacy-cutover-unparseable',\n 'JWT_LEGACY_CUTOVER_AT is not a valid ISO-8601 instant \u2014 legacy JWT fallback remains disabled.',\n )\n return null\n }\n return Math.floor(parsed / 1000)\n}\n\nconst MIN_SECRET_LENGTH = 32\n\n/**\n * Signing secrets that ship in this repository's own examples, compose files, and docs. A\n * deployment reaching production with one of these is not \"weakly configured\" \u2014 it is publicly\n * forgeable by anyone who has read the repository.\n */\nconst PLACEHOLDER_SECRETS = new Set([\n 'jwt',\n 'jwt-secret',\n 'jwtsecret',\n 'secret',\n 'password',\n 'changeme',\n 'change-me',\n 'change-me-dev-secret',\n 'change-me-dev-auth-secret',\n 'your-strong-jwt-secret',\n 'your-secure-jwt-secret-change-me',\n 'dev',\n 'development',\n 'test',\n])\n\nexport type JwtSecretViolation = 'missing' | 'placeholder' | 'too_short'\n\nconst warnedKeys = new Set<string>()\n\nfunction warnOnce(key: string, message: string): void {\n if (warnedKeys.has(key)) return\n warnedKeys.add(key)\n logger.warn(message)\n}\n\nfunction isProduction(): boolean {\n return process.env.NODE_ENV === 'production'\n}\n\nfunction inspectSecret(secret: string | undefined | null): JwtSecretViolation | null {\n const value = typeof secret === 'string' ? secret.trim() : ''\n if (!value) return 'missing'\n if (PLACEHOLDER_SECRETS.has(value.toLowerCase())) return 'placeholder'\n if (value.length < MIN_SECRET_LENGTH) return 'too_short'\n return null\n}\n\n/**\n * True when `secret` would be refused by the production signing-secret policy: missing, a\n * published placeholder, or shorter than the minimum length. Lets tooling that launches the app\n * (e.g. the ephemeral integration runner) swap in a safe value instead of booting into a crash.\n */\nexport function isUnsafeJwtSecret(secret?: string | null): boolean {\n return inspectSecret(secret) !== null\n}\n\nfunction describeViolation(name: string, violation: JwtSecretViolation): string {\n switch (violation) {\n case 'missing':\n return `${name} is not set. Generate one with \\`openssl rand -hex 32\\`.`\n case 'placeholder':\n return `${name} is set to a placeholder value published in this repository's examples, so anyone can forge tokens for this deployment. Generate a real one with \\`openssl rand -hex 32\\`.`\n case 'too_short':\n return `${name} is shorter than ${MIN_SECRET_LENGTH} characters. Generate a stronger one with \\`openssl rand -hex 32\\`.`\n }\n}\n\n/**\n * Fail closed in production, warn in every other environment. Called on every secret read so\n * worker, scheduler, and CLI processes \u2014 which never run the app's startup hook \u2014 are covered\n * too. `assertJwtSecretPolicy` runs the same check eagerly at server startup.\n */\nfunction enforceSecretPolicy(name: string, secret: string | undefined | null): void {\n const violation = inspectSecret(secret)\n if (!violation) return\n const message = describeViolation(name, violation)\n if (isProduction()) {\n throw new Error(`[auth.jwt] Refusing to run in production with an unsafe signing secret: ${message}`)\n }\n warnOnce(`secret-policy:${name}:${violation}`, `${message} This is tolerated outside production only.`)\n}\n\n/**\n * Validate every JWT signing secret this process would use. Call it once at startup so a\n * misconfigured production deployment fails immediately and loudly instead of at the first login\n * attempt. Throws in production; logs a warning elsewhere.\n */\nexport function assertJwtSecretPolicy(): void {\n enforceSecretPolicy('JWT_SECRET', process.env.JWT_SECRET)\n for (const [key, value] of Object.entries(process.env)) {\n if (!/^JWT_[A-Z0-9]+(?:_[A-Z0-9]+)*_SECRET$/.test(key)) continue\n enforceSecretPolicy(key, value)\n }\n}\n\nfunction readBaseSecret(explicit?: string): string {\n const secret = explicit ?? process.env.JWT_SECRET\n if (!secret) throw new Error('JWT_SECRET is not set')\n if (explicit === undefined) enforceSecretPolicy('JWT_SECRET', secret)\n return secret\n}\n\nfunction normalizeAudience(audience: string): string {\n return audience.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_')\n}\n\nconst derivedSecretCache = new Map<string, string>()\n\nfunction deriveAudienceSecretFromBase(normalized: string, base: string): string {\n const cacheKey = `${normalized}::${base}`\n const cached = derivedSecretCache.get(cacheKey)\n if (cached !== undefined) return cached\n const label = `${AUDIENCE_SECRET_LABEL}:${normalized}`\n const derived = crypto.createHmac('sha256', base).update(label).digest('hex')\n derivedSecretCache.set(cacheKey, derived)\n return derived\n}\n\n/**\n * Derive a per-audience signing key from the base `JWT_SECRET`.\n *\n * - If `JWT_${AUDIENCE}_SECRET` env var is set, it is used verbatim (allows operators to rotate a\n * single audience independently).\n * - Otherwise, the key is derived deterministically via HMAC-SHA256 from the base secret using a\n * versioned label. This ensures that a staff JWT signature cannot verify against the customer\n * key (and vice versa) even though both share the same base `JWT_SECRET`.\n * - Derived values are memoized per `(audience, baseSecret)` for the process lifetime.\n */\nexport function deriveJwtAudienceSecret(audience: string, baseSecret?: string): string {\n const normalized = normalizeAudience(audience)\n if (!normalized) throw new Error('Audience is required to derive a JWT secret')\n const overrideName = `JWT_${normalized.toUpperCase()}_SECRET`\n const override = process.env[overrideName]\n if (override && override.trim().length > 0) {\n enforceSecretPolicy(overrideName, override)\n return override\n }\n const base = readBaseSecret(baseSecret)\n return deriveAudienceSecretFromBase(normalized, base)\n}\n\nfunction isSignOptions(value: string | SignJwtOptions | undefined): value is SignJwtOptions {\n return typeof value === 'object' && value !== null\n}\n\nfunction isVerifyOptions(value: string | VerifyJwtOptions | undefined): value is VerifyJwtOptions {\n return typeof value === 'object' && value !== null\n}\n\nfunction toSignOptions(secretOrOptions?: string | SignJwtOptions, expiresInSec?: number): { secret: string; expiresInSec: number; audience?: string; issuer?: string } {\n if (isSignOptions(secretOrOptions)) {\n const audience = secretOrOptions.audience ?? DEFAULT_STAFF_AUDIENCE\n const secret = secretOrOptions.secret ?? deriveJwtAudienceSecret(audience)\n if (!secret) throw new Error('JWT_SECRET is not set')\n return {\n secret,\n expiresInSec: secretOrOptions.expiresInSec ?? 60 * 60 * 8,\n audience,\n issuer: secretOrOptions.issuer ?? DEFAULT_ISSUER,\n }\n }\n if (typeof secretOrOptions === 'string') {\n // Legacy: explicit raw secret supplied by caller \u2014 keep audience/issuer off by default so\n // existing tests and callers that BYO secret see unchanged behavior.\n if (!secretOrOptions) throw new Error('JWT_SECRET is not set')\n return {\n secret: secretOrOptions,\n expiresInSec: expiresInSec ?? 60 * 60 * 8,\n }\n }\n // Default path: staff-audience derived secret + iss/aud claims.\n return {\n secret: deriveJwtAudienceSecret(DEFAULT_STAFF_AUDIENCE),\n expiresInSec: expiresInSec ?? 60 * 60 * 8,\n audience: DEFAULT_STAFF_AUDIENCE,\n issuer: DEFAULT_ISSUER,\n }\n}\n\nfunction toVerifyOptions(secretOrOptions?: string | VerifyJwtOptions): { secret: string; audience?: string; issuer?: string } {\n if (isVerifyOptions(secretOrOptions)) {\n const audience = secretOrOptions.audience ?? DEFAULT_STAFF_AUDIENCE\n const secret = secretOrOptions.secret ?? deriveJwtAudienceSecret(audience)\n if (!secret) throw new Error('JWT_SECRET is not set')\n return {\n secret,\n audience,\n issuer: secretOrOptions.issuer ?? DEFAULT_ISSUER,\n }\n }\n if (typeof secretOrOptions === 'string') {\n if (!secretOrOptions) throw new Error('JWT_SECRET is not set')\n // Legacy explicit secret: no audience/issuer enforcement.\n return { secret: secretOrOptions }\n }\n return {\n secret: deriveJwtAudienceSecret(DEFAULT_STAFF_AUDIENCE),\n audience: DEFAULT_STAFF_AUDIENCE,\n issuer: DEFAULT_ISSUER,\n }\n}\n\nexport function signJwt(\n payload: JwtPayload,\n secretOrOptions?: string | SignJwtOptions,\n expiresInSec?: number,\n) {\n const options = toSignOptions(secretOrOptions, expiresInSec)\n const header = { alg: 'HS256', typ: 'JWT' }\n const now = Math.floor(Date.now() / 1000)\n const body: JwtPayload = { iat: now, exp: now + options.expiresInSec, ...payload }\n if (options.issuer && body.iss === undefined) body.iss = options.issuer\n if (options.audience && body.aud === undefined) body.aud = options.audience\n const encHeader = base64url(JSON.stringify(header))\n const encBody = base64url(JSON.stringify(body))\n const data = `${encHeader}.${encBody}`\n const sig = crypto.createHmac('sha256', options.secret).update(data).digest()\n const encSig = base64url(sig)\n return `${data}.${encSig}`\n}\n\nfunction verifyWithOptions(token: string, options: { secret: string; audience?: string; issuer?: string }): JwtPayload | null {\n const parts = token.split('.')\n if (parts.length !== 3) return null\n const [h, p, s] = parts\n const data = `${h}.${p}`\n const expected = base64url(crypto.createHmac('sha256', options.secret).update(data).digest())\n const providedSignature = Buffer.from(s)\n const expectedSignature = Buffer.from(expected)\n if (providedSignature.length !== expectedSignature.length) return null\n if (!crypto.timingSafeEqual(providedSignature, expectedSignature)) return null\n let payload: JwtPayload\n try {\n payload = JSON.parse(Buffer.from(p, 'base64').toString('utf8'))\n } catch {\n return null\n }\n const now = Math.floor(Date.now() / 1000)\n if (payload.exp && now > payload.exp) return null\n if (options.audience !== undefined) {\n if (payload.aud !== options.audience) return null\n }\n if (options.issuer !== undefined) {\n if (payload.iss !== options.issuer) return null\n }\n return payload\n}\n\n/**\n * Whether a raw-secret token is still inside the migration window. A token is only ever legacy\n * for a bounded period after it was issued, so `iat` is mandatory: a token that cannot prove its\n * age cannot prove it is inside the window either, and accepting it would make the window\n * unbounded \u2014 which is exactly the defect this guard closes.\n */\nfunction isWithinLegacyWindow(payload: JwtPayload, graceMinutes: number): boolean {\n const cutoverAt = getLegacyCutoverEpochSeconds()\n const now = Math.floor(Date.now() / 1000)\n if (cutoverAt === null || now >= cutoverAt) return false\n const issuedAt = payload.iat\n if (typeof issuedAt !== 'number' || !Number.isFinite(issuedAt)) return false\n if (issuedAt > now + LEGACY_TOKEN_CLOCK_SKEW_SECONDS) return false\n return now - issuedAt <= graceMinutes * 60\n}\n\nexport function verifyJwt(token: string, secretOrOptions?: string | VerifyJwtOptions) {\n const options = toVerifyOptions(secretOrOptions)\n const result = verifyWithOptions(token, options)\n if (result) {\n // `_legacyToken` is assigned by this function alone. Strip any same-named claim carried in\n // the token body so a payload can never talk callers (staff session integrity, portal auth)\n // into treating a modern, session-bound token as a sessionless legacy one.\n if (result._legacyToken !== undefined) delete result._legacyToken\n return result\n }\n\n // Legacy fallback: when the caller used the default path (no explicit secret) and the new\n // audience-derived verification failed, try verifying with the raw JWT_SECRET. This keeps\n // pre-migration tokens working across a rolling deployment \u2014 but only until the token's own\n // `iat` leaves the configured grace window, or the configured cutover instant passes.\n if (secretOrOptions === undefined) {\n const graceMinutes = getLegacyGraceMinutes()\n const rawSecret = process.env.JWT_SECRET\n if (graceMinutes > 0 && rawSecret) {\n const legacyResult = verifyWithOptions(token, { secret: rawSecret })\n if (legacyResult && isWithinLegacyWindow(legacyResult, graceMinutes)) {\n legacyResult._legacyToken = true\n return legacyResult\n }\n }\n }\n\n return null\n}\n\n/**\n * Sign a JWT for a specific audience using an audience-derived signing key. The resulting token\n * carries `iss` and `aud` claims and cannot be verified with the base `JWT_SECRET` directly \u2014\n * callers must use `verifyAudienceJwt` with the same audience.\n */\nexport function signAudienceJwt(\n audience: string,\n payload: JwtPayload,\n expiresInSec: number = 60 * 60 * 8,\n): string {\n return signJwt(payload, { audience, expiresInSec })\n}\n\n/**\n * Verify a JWT that was signed with an audience-scoped secret. Rejects tokens that are missing\n * or carry a mismatched `aud`/`iss` claim, so a staff JWT cannot be replayed against the\n * customer portal (and vice versa) even when the base `JWT_SECRET` is shared.\n */\nexport function verifyAudienceJwt(audience: string, token: string): JwtPayload | null {\n return verifyJwt(token, { audience })\n}\n\n/**\n * True when a verified JWT payload represents an MFA challenge in progress: the second factor\n * has not been completed yet (`mfa_pending: true`, `mfa_verified` not `true`). Such tokens are\n * provisional \u2014 general staff APIs must reject them and only the MFA completion routes may\n * accept them (see `@open-mercato/shared/lib/auth/mfaPendingAccess`).\n *\n * The predicate keys on `mfa_pending` alone by design. `mfa_verified` is NOT a durable session\n * property: routes that legitimately re-sign a staff token (`PUT /api/auth/profile`,\n * `GET|POST /api/auth/session/refresh`) rebuild the claims without it. Tightening this to require\n * `mfa_verified === true` would therefore silently downgrade those sessions.\n */\nexport function isMfaPendingJwtPayload(payload: unknown): boolean {\n if (!payload || typeof payload !== 'object') return false\n const record = payload as Record<string, unknown>\n return record.mfa_pending === true && record.mfa_verified !== true\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,3 +1,4 @@
1
+ import { forbidden } from "../crud/errors.js";
1
2
  function normalizeId(value) {
2
3
  if (typeof value !== "string") return null;
3
4
  const trimmed = value.trim();
@@ -16,6 +17,17 @@ function resolveActiveOrganizationId(auth) {
16
17
  }
17
18
  return actorOrgId;
18
19
  }
20
+ function isExplicitlyEmptyOrganizationScope(scope) {
21
+ if (!scope) return false;
22
+ return Array.isArray(scope.filterIds) && scope.filterIds.length === 0 || Array.isArray(scope.allowedIds) && scope.allowedIds.length === 0;
23
+ }
24
+ function resolveSingleOrganizationIdOrDeny(scope, auth) {
25
+ if (isExplicitlyEmptyOrganizationScope(scope)) {
26
+ throw forbidden();
27
+ }
28
+ if (scope?.selectedId) return scope.selectedId;
29
+ return auth?.orgId ?? null;
30
+ }
19
31
  const ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE = "organization_scope_required";
20
32
  function organizationScopeRequiredResponse() {
21
33
  return Response.json(
@@ -28,7 +40,9 @@ function organizationScopeRequiredResponse() {
28
40
  }
29
41
  export {
30
42
  ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE,
43
+ isExplicitlyEmptyOrganizationScope,
31
44
  organizationScopeRequiredResponse,
32
- resolveActiveOrganizationId
45
+ resolveActiveOrganizationId,
46
+ resolveSingleOrganizationIdOrDeny
33
47
  };
34
48
  //# sourceMappingURL=organizationScope.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/auth/organizationScope.ts"],
4
- "sourcesContent": ["type OrganizationScopedAuth = {\n orgId?: string | null\n actorOrgId?: unknown\n tenantId?: string | null\n actorTenantId?: unknown\n} | null | undefined\n\nfunction normalizeId(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\n/**\n * Resolves the organization a request is scoped to when the caller may be viewing\n * \"all organizations\".\n *\n * Organization-scoped configuration modules (integrations credentials/state, data sync\n * mappings/schedules/runs) all require a non-null `organization_id`, so there is no\n * meaningful \"all organizations\" view of them. When an operator selects that option the\n * super-admin cookie override clears `auth.orgId` and preserves the actor's own\n * organization in `actorOrgId`; fall back to it so those modules keep showing the\n * operator's own configuration instead of failing.\n *\n * The fallback is only valid while the effective tenant is still the actor's own tenant.\n * When the super-admin cookie override also switched tenants (`actorTenantId` is present\n * and differs from `auth.tenantId`), the actor's organization belongs to another tenant \u2014\n * scoping to it would persist a cross-tenant `{ organizationId, tenantId }` pair. Return\n * `null` instead and let the route answer with `organizationScopeRequiredResponse()`.\n *\n * Answering 401 for an unresolvable scope is not merely wrong but self-perpetuating:\n * `apiFetch` reads 401 as an expired session and redirects through\n * `/api/auth/session/refresh`, which succeeds and returns to the same page, reloading\n * forever. That is why the missing-scope answer is a 400, never a 401.\n */\nexport function resolveActiveOrganizationId(auth: OrganizationScopedAuth): string | null {\n if (!auth) return null\n const selected = normalizeId(auth.orgId)\n if (selected) return selected\n const actorOrgId = normalizeId(auth.actorOrgId)\n if (!actorOrgId) return null\n if ('actorTenantId' in auth) {\n const actorTenantId = normalizeId(auth.actorTenantId)\n const effectiveTenantId = normalizeId(auth.tenantId)\n if (!actorTenantId || actorTenantId !== effectiveTenantId) return null\n }\n return actorOrgId\n}\n\nexport const ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE = 'organization_scope_required'\n\n/**\n * 400 response for an authenticated caller whose organization scope cannot be resolved\n * (e.g. a super-admin viewing a foreign tenant with \"all organizations\" selected).\n * Deliberately not a 401: the session is valid, so refreshing it would loop.\n */\nexport function organizationScopeRequiredResponse(): Response {\n return Response.json(\n {\n error: 'Select an organization to access this resource',\n code: ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE,\n },\n { status: 400 },\n )\n}\n"],
5
- "mappings": "AAOA,SAAS,YAAY,OAA+B;AAClD,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,SAAO,QAAQ,SAAS,IAAI,UAAU;AACxC;AAwBO,SAAS,4BAA4B,MAA6C;AACvF,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,WAAW,YAAY,KAAK,KAAK;AACvC,MAAI,SAAU,QAAO;AACrB,QAAM,aAAa,YAAY,KAAK,UAAU;AAC9C,MAAI,CAAC,WAAY,QAAO;AACxB,MAAI,mBAAmB,MAAM;AAC3B,UAAM,gBAAgB,YAAY,KAAK,aAAa;AACpD,UAAM,oBAAoB,YAAY,KAAK,QAAQ;AACnD,QAAI,CAAC,iBAAiB,kBAAkB,kBAAmB,QAAO;AAAA,EACpE;AACA,SAAO;AACT;AAEO,MAAM,yCAAyC;AAO/C,SAAS,oCAA8C;AAC5D,SAAO,SAAS;AAAA,IACd;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,IACR;AAAA,IACA,EAAE,QAAQ,IAAI;AAAA,EAChB;AACF;",
4
+ "sourcesContent": ["import { forbidden } from '../crud/errors'\n\ntype OrganizationScopedAuth = {\n orgId?: string | null\n actorOrgId?: unknown\n tenantId?: string | null\n actorTenantId?: unknown\n} | null | undefined\n\ntype FiniteOrganizationScope = {\n selectedId?: string | null\n filterIds?: readonly string[] | null\n allowedIds?: readonly string[] | null\n} | null | undefined\n\ntype OrganizationHomeAuth = {\n orgId?: string | null\n} | null | undefined\n\nfunction normalizeId(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\n/**\n * Resolves the organization a request is scoped to when the caller may be viewing\n * \"all organizations\".\n *\n * Organization-scoped configuration modules (integrations credentials/state, data sync\n * mappings/schedules/runs) all require a non-null `organization_id`, so there is no\n * meaningful \"all organizations\" view of them. When an operator selects that option the\n * super-admin cookie override clears `auth.orgId` and preserves the actor's own\n * organization in `actorOrgId`; fall back to it so those modules keep showing the\n * operator's own configuration instead of failing.\n *\n * The fallback is only valid while the effective tenant is still the actor's own tenant.\n * When the super-admin cookie override also switched tenants (`actorTenantId` is present\n * and differs from `auth.tenantId`), the actor's organization belongs to another tenant \u2014\n * scoping to it would persist a cross-tenant `{ organizationId, tenantId }` pair. Return\n * `null` instead and let the route answer with `organizationScopeRequiredResponse()`.\n *\n * Answering 401 for an unresolvable scope is not merely wrong but self-perpetuating:\n * `apiFetch` reads 401 as an expired session and redirects through\n * `/api/auth/session/refresh`, which succeeds and returns to the same page, reloading\n * forever. That is why the missing-scope answer is a 400, never a 401.\n */\nexport function resolveActiveOrganizationId(auth: OrganizationScopedAuth): string | null {\n if (!auth) return null\n const selected = normalizeId(auth.orgId)\n if (selected) return selected\n const actorOrgId = normalizeId(auth.actorOrgId)\n if (!actorOrgId) return null\n if ('actorTenantId' in auth) {\n const actorTenantId = normalizeId(auth.actorTenantId)\n const effectiveTenantId = normalizeId(auth.tenantId)\n if (!actorTenantId || actorTenantId !== effectiveTenantId) return null\n }\n return actorOrgId\n}\n\n/**\n * True when the caller's organization scope was resolved to an explicitly empty set\n * (`filterIds: []` or `allowedIds: []`), i.e. the principal can see no organization at\n * all. An empty set is deny-all and MUST NOT be widened back to the home organization.\n *\n * Routes that would otherwise reveal a record's existence use this to answer with their\n * own not-found response instead of letting `resolveSingleOrganizationIdOrDeny` throw \u2014\n * a thrown deny leaves the route's documented status (and the existence-oracle\n * collapse of issue #5504) up to whoever catches it.\n */\nexport function isExplicitlyEmptyOrganizationScope(scope: FiniteOrganizationScope): boolean {\n if (!scope) return false\n return (Array.isArray(scope.filterIds) && scope.filterIds.length === 0)\n || (Array.isArray(scope.allowedIds) && scope.allowedIds.length === 0)\n}\n\nexport function resolveSingleOrganizationIdOrDeny(\n scope: FiniteOrganizationScope,\n auth: OrganizationHomeAuth,\n): string | null {\n if (isExplicitlyEmptyOrganizationScope(scope)) {\n throw forbidden()\n }\n if (scope?.selectedId) return scope.selectedId\n return auth?.orgId ?? null\n}\n\nexport const ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE = 'organization_scope_required'\n\n/**\n * 400 response for an authenticated caller whose organization scope cannot be resolved\n * (e.g. a super-admin viewing a foreign tenant with \"all organizations\" selected).\n * Deliberately not a 401: the session is valid, so refreshing it would loop.\n */\nexport function organizationScopeRequiredResponse(): Response {\n return Response.json(\n {\n error: 'Select an organization to access this resource',\n code: ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE,\n },\n { status: 400 },\n )\n}\n"],
5
+ "mappings": "AAAA,SAAS,iBAAiB;AAmB1B,SAAS,YAAY,OAA+B;AAClD,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,SAAO,QAAQ,SAAS,IAAI,UAAU;AACxC;AAwBO,SAAS,4BAA4B,MAA6C;AACvF,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,WAAW,YAAY,KAAK,KAAK;AACvC,MAAI,SAAU,QAAO;AACrB,QAAM,aAAa,YAAY,KAAK,UAAU;AAC9C,MAAI,CAAC,WAAY,QAAO;AACxB,MAAI,mBAAmB,MAAM;AAC3B,UAAM,gBAAgB,YAAY,KAAK,aAAa;AACpD,UAAM,oBAAoB,YAAY,KAAK,QAAQ;AACnD,QAAI,CAAC,iBAAiB,kBAAkB,kBAAmB,QAAO;AAAA,EACpE;AACA,SAAO;AACT;AAYO,SAAS,mCAAmC,OAAyC;AAC1F,MAAI,CAAC,MAAO,QAAO;AACnB,SAAQ,MAAM,QAAQ,MAAM,SAAS,KAAK,MAAM,UAAU,WAAW,KAC/D,MAAM,QAAQ,MAAM,UAAU,KAAK,MAAM,WAAW,WAAW;AACvE;AAEO,SAAS,kCACd,OACA,MACe;AACf,MAAI,mCAAmC,KAAK,GAAG;AAC7C,UAAM,UAAU;AAAA,EAClB;AACA,MAAI,OAAO,WAAY,QAAO,MAAM;AACpC,SAAO,MAAM,SAAS;AACxB;AAEO,MAAM,yCAAyC;AAO/C,SAAS,oCAA8C;AAC5D,SAAO,SAAS;AAAA,IACd;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,IACR;AAAA,IACA,EAAE,QAAQ,IAAI;AAAA,EAChB;AACF;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,77 @@
1
+ import { availabilityItemKey } from "./types.js";
2
+ import { availabilityProviderRegistry, AVAILABILITY_CATALOG_ONLY_PROVIDER_ID } from "./registry.js";
3
+ let policyLookup = null;
4
+ function setCatalogOnlyPolicyLookup(lookup) {
5
+ policyLookup = lookup;
6
+ }
7
+ function pureFallbackItem() {
8
+ return {
9
+ state: "not_tracked",
10
+ availableQuantity: null,
11
+ canFulfil: true,
12
+ leadTimeDays: null,
13
+ releaseAt: null,
14
+ isAuthoritative: true,
15
+ policySourceId: null
16
+ };
17
+ }
18
+ function isWithinOrderQuantityRules(requested, override) {
19
+ const { minOrderQuantity, maxOrderQuantity, quantityIncrement } = override;
20
+ if (minOrderQuantity != null && requested < minOrderQuantity) return false;
21
+ if (maxOrderQuantity != null && requested > maxOrderQuantity) return false;
22
+ if (quantityIncrement != null && quantityIncrement > 0 && requested % quantityIncrement !== 0) return false;
23
+ return true;
24
+ }
25
+ function applyOverride(override, requestedQuantity) {
26
+ const result = applyPolicyMatrix(override);
27
+ if (!override || isWithinOrderQuantityRules(requestedQuantity, override)) return result;
28
+ return { ...result, canFulfil: false };
29
+ }
30
+ function applyPolicyMatrix(override) {
31
+ const base = pureFallbackItem();
32
+ if (!override) return base;
33
+ const policySourceId = override.policySourceId ?? null;
34
+ if (override.isActive === false) {
35
+ return { ...base, state: "out_of_stock", canFulfil: false, policySourceId };
36
+ }
37
+ if (override.preorderReleaseAt) {
38
+ const releaseAt = new Date(override.preorderReleaseAt);
39
+ if (!Number.isNaN(releaseAt.getTime()) && releaseAt.getTime() > Date.now()) {
40
+ return {
41
+ ...base,
42
+ state: "preorder",
43
+ canFulfil: true,
44
+ releaseAt: override.preorderReleaseAt,
45
+ policySourceId
46
+ };
47
+ }
48
+ }
49
+ if (override.isStockManaged === true) {
50
+ return { ...base, state: "out_of_stock", canFulfil: false, policySourceId };
51
+ }
52
+ return { ...base, policySourceId };
53
+ }
54
+ async function getAvailability(query, context) {
55
+ const byItem = {};
56
+ let overrides = {};
57
+ if (policyLookup) {
58
+ try {
59
+ overrides = await policyLookup(query, context);
60
+ } catch {
61
+ overrides = {};
62
+ }
63
+ }
64
+ for (const item of query.items) {
65
+ const key = availabilityItemKey(item);
66
+ byItem[key] = applyOverride(overrides[key], item.quantity);
67
+ }
68
+ return { byItem };
69
+ }
70
+ availabilityProviderRegistry.register({
71
+ id: AVAILABILITY_CATALOG_ONLY_PROVIDER_ID,
72
+ getAvailability
73
+ });
74
+ export {
75
+ setCatalogOnlyPolicyLookup
76
+ };
77
+ //# sourceMappingURL=catalogOnlyProvider.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/availability/catalogOnlyProvider.ts"],
4
+ "sourcesContent": ["/**\n * The built-in `catalog-only` fallback provider. Always registered so a\n * `wms`-less *and* `availability`-less storefront stays fully functional.\n *\n * @see .ai/specs/2026-08-14-availability-contract.md \u00A74.3\n */\n\nimport { availabilityItemKey } from './types'\nimport type { AvailabilityItemResult, AvailabilityProviderContext, AvailabilityQuery, AvailabilityResult } from './types'\nimport { availabilityProviderRegistry, AVAILABILITY_CATALOG_ONLY_PROVIDER_ID } from './registry'\n\n/** Per-item policy signal the optional lookup hook may report. */\nexport type CatalogOnlyPolicyOverride = {\n /** `true` \u2192 the item is explicitly opted into stock tracking with no data source behind it (\u00A74.3, out_of_stock). */\n isStockManaged?: boolean\n /** `false` \u2192 the policy row is inactive; treated as out_of_stock. */\n isActive?: boolean\n /** ISO-8601. In the future \u2192 `preorder`. */\n preorderReleaseAt?: string | null\n /** Requested quantities below this cannot be fulfilled. */\n minOrderQuantity?: number | null\n /** Requested quantities above this cannot be fulfilled \u2014 a cap independent of stock. */\n maxOrderQuantity?: number | null\n /** Pack size; requested quantities must be a multiple. */\n quantityIncrement?: number | null\n /** The `AvailabilityPolicy` row id that produced this override. */\n policySourceId?: string | null\n}\n\n/**\n * Optional soft lookup into `AvailabilityPolicy` when the `availability`\n * module is installed. Keyed by `availabilityItemKey()`. Returning `null` or\n * omitting an item from the map means \"no policy row \u2014 module default\".\n */\nexport type CatalogOnlyPolicyLookup = (\n query: AvailabilityQuery,\n context?: AvailabilityProviderContext,\n) => Promise<Record<string, CatalogOnlyPolicyOverride | null | undefined>>\n\nlet policyLookup: CatalogOnlyPolicyLookup | null = null\n\n/**\n * Wired by the `availability` module's `di.ts` \u2014 never a static import from\n * `packages/shared`. The lookup MUST resolve its dependencies from the\n * per-call `context.container`, never from a container captured at\n * registration time. Pass `null` to clear (test isolation).\n */\nexport function setCatalogOnlyPolicyLookup(lookup: CatalogOnlyPolicyLookup | null): void {\n policyLookup = lookup\n}\n\nfunction pureFallbackItem(): AvailabilityItemResult {\n return {\n state: 'not_tracked',\n availableQuantity: null,\n canFulfil: true,\n leadTimeDays: null,\n releaseAt: null,\n isAuthoritative: true,\n policySourceId: null,\n }\n}\n\nfunction isWithinOrderQuantityRules(requested: number, override: CatalogOnlyPolicyOverride): boolean {\n const { minOrderQuantity, maxOrderQuantity, quantityIncrement } = override\n if (minOrderQuantity != null && requested < minOrderQuantity) return false\n if (maxOrderQuantity != null && requested > maxOrderQuantity) return false\n if (quantityIncrement != null && quantityIncrement > 0 && requested % quantityIncrement !== 0) return false\n return true\n}\n\n/**\n * Order-quantity rules are a cap independent of stock: a violation blocks\n * fulfilment without changing the state the policy matrix produced.\n */\nfunction applyOverride(\n override: CatalogOnlyPolicyOverride | null | undefined,\n requestedQuantity: number,\n): AvailabilityItemResult {\n const result = applyPolicyMatrix(override)\n if (!override || isWithinOrderQuantityRules(requestedQuantity, override)) return result\n return { ...result, canFulfil: false }\n}\n\n/**\n * Applies decision 7's matrix (see PLAN.md \u00A7 Key design decisions) for a\n * resolved policy override on top of the pure fallback.\n */\nfunction applyPolicyMatrix(override: CatalogOnlyPolicyOverride | null | undefined): AvailabilityItemResult {\n const base = pureFallbackItem()\n if (!override) return base\n\n const policySourceId = override.policySourceId ?? null\n\n // An inactive row makes the item unpurchasable before any preorder date, exactly\n // like the stock-tracked providers.\n if (override.isActive === false) {\n return { ...base, state: 'out_of_stock', canFulfil: false, policySourceId }\n }\n\n if (override.preorderReleaseAt) {\n const releaseAt = new Date(override.preorderReleaseAt)\n if (!Number.isNaN(releaseAt.getTime()) && releaseAt.getTime() > Date.now()) {\n return {\n ...base,\n state: 'preorder',\n canFulfil: true,\n releaseAt: override.preorderReleaseAt,\n policySourceId,\n }\n }\n }\n\n if (override.isStockManaged === true) {\n // Opted into stock tracking with no data source to verify against \u2014 see\n // decision 7: this is the \"policy explicitly marks the item unavailable\"\n // case rather than a silent \"in stock\" claim (R5).\n return { ...base, state: 'out_of_stock', canFulfil: false, policySourceId }\n }\n\n return { ...base, policySourceId }\n}\n\nasync function getAvailability(\n query: AvailabilityQuery,\n context?: AvailabilityProviderContext,\n): Promise<AvailabilityResult> {\n const byItem: AvailabilityResult['byItem'] = {}\n\n let overrides: Record<string, CatalogOnlyPolicyOverride | null | undefined> = {}\n if (policyLookup) {\n try {\n overrides = await policyLookup(query, context)\n } catch {\n // Degrade gracefully to the pure fallback \u2014 never let an optional\n // policy lookup failure break the always-available fallback provider.\n overrides = {}\n }\n }\n\n for (const item of query.items) {\n const key = availabilityItemKey(item)\n byItem[key] = applyOverride(overrides[key], item.quantity)\n }\n\n return { byItem }\n}\n\navailabilityProviderRegistry.register({\n id: AVAILABILITY_CATALOG_ONLY_PROVIDER_ID,\n getAvailability,\n})\n"],
5
+ "mappings": "AAOA,SAAS,2BAA2B;AAEpC,SAAS,8BAA8B,6CAA6C;AA8BpF,IAAI,eAA+C;AAQ5C,SAAS,2BAA2B,QAA8C;AACvF,iBAAe;AACjB;AAEA,SAAS,mBAA2C;AAClD,SAAO;AAAA,IACL,OAAO;AAAA,IACP,mBAAmB;AAAA,IACnB,WAAW;AAAA,IACX,cAAc;AAAA,IACd,WAAW;AAAA,IACX,iBAAiB;AAAA,IACjB,gBAAgB;AAAA,EAClB;AACF;AAEA,SAAS,2BAA2B,WAAmB,UAA8C;AACnG,QAAM,EAAE,kBAAkB,kBAAkB,kBAAkB,IAAI;AAClE,MAAI,oBAAoB,QAAQ,YAAY,iBAAkB,QAAO;AACrE,MAAI,oBAAoB,QAAQ,YAAY,iBAAkB,QAAO;AACrE,MAAI,qBAAqB,QAAQ,oBAAoB,KAAK,YAAY,sBAAsB,EAAG,QAAO;AACtG,SAAO;AACT;AAMA,SAAS,cACP,UACA,mBACwB;AACxB,QAAM,SAAS,kBAAkB,QAAQ;AACzC,MAAI,CAAC,YAAY,2BAA2B,mBAAmB,QAAQ,EAAG,QAAO;AACjF,SAAO,EAAE,GAAG,QAAQ,WAAW,MAAM;AACvC;AAMA,SAAS,kBAAkB,UAAgF;AACzG,QAAM,OAAO,iBAAiB;AAC9B,MAAI,CAAC,SAAU,QAAO;AAEtB,QAAM,iBAAiB,SAAS,kBAAkB;AAIlD,MAAI,SAAS,aAAa,OAAO;AAC/B,WAAO,EAAE,GAAG,MAAM,OAAO,gBAAgB,WAAW,OAAO,eAAe;AAAA,EAC5E;AAEA,MAAI,SAAS,mBAAmB;AAC9B,UAAM,YAAY,IAAI,KAAK,SAAS,iBAAiB;AACrD,QAAI,CAAC,OAAO,MAAM,UAAU,QAAQ,CAAC,KAAK,UAAU,QAAQ,IAAI,KAAK,IAAI,GAAG;AAC1E,aAAO;AAAA,QACL,GAAG;AAAA,QACH,OAAO;AAAA,QACP,WAAW;AAAA,QACX,WAAW,SAAS;AAAA,QACpB;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,MAAI,SAAS,mBAAmB,MAAM;AAIpC,WAAO,EAAE,GAAG,MAAM,OAAO,gBAAgB,WAAW,OAAO,eAAe;AAAA,EAC5E;AAEA,SAAO,EAAE,GAAG,MAAM,eAAe;AACnC;AAEA,eAAe,gBACb,OACA,SAC6B;AAC7B,QAAM,SAAuC,CAAC;AAE9C,MAAI,YAA0E,CAAC;AAC/E,MAAI,cAAc;AAChB,QAAI;AACF,kBAAY,MAAM,aAAa,OAAO,OAAO;AAAA,IAC/C,QAAQ;AAGN,kBAAY,CAAC;AAAA,IACf;AAAA,EACF;AAEA,aAAW,QAAQ,MAAM,OAAO;AAC9B,UAAM,MAAM,oBAAoB,IAAI;AACpC,WAAO,GAAG,IAAI,cAAc,UAAU,GAAG,GAAG,KAAK,QAAQ;AAAA,EAC3D;AAEA,SAAO,EAAE,OAAO;AAClB;AAEA,6BAA6B,SAAS;AAAA,EACpC,IAAI;AAAA,EACJ;AACF,CAAC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./types.js";
2
+ export * from "./registry.js";
3
+ import { setCatalogOnlyPolicyLookup } from "./catalogOnlyProvider.js";
4
+ import "./catalogOnlyProvider.js";
5
+ export {
6
+ setCatalogOnlyPolicyLookup
7
+ };
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/availability/index.ts"],
4
+ "sourcesContent": ["/**\n * Public entrypoint for the Availability Contract's shared, dependency-free\n * base \u2014 types, the provider registry, `resolveAvailability()`, and the\n * built-in `catalog-only` fallback.\n *\n * Importing from this barrel (rather than `./registry` directly) guarantees\n * the `catalog-only` provider is registered \u2014 it self-registers as an import\n * side effect in `./catalogOnlyProvider`, which this file always pulls in.\n *\n * @see .ai/specs/2026-08-14-availability-contract.md \u00A74.1a\n */\n\nexport * from './types'\nexport * from './registry'\nexport { setCatalogOnlyPolicyLookup } from './catalogOnlyProvider'\nexport type { CatalogOnlyPolicyLookup, CatalogOnlyPolicyOverride } from './catalogOnlyProvider'\n\nimport './catalogOnlyProvider'\n"],
5
+ "mappings": "AAYA,cAAc;AACd,cAAc;AACd,SAAS,kCAAkC;AAG3C,OAAO;",
6
+ "names": []
7
+ }
@@ -0,0 +1,47 @@
1
+ const AVAILABILITY_CATALOG_ONLY_PROVIDER_ID = "catalog-only";
2
+ class AvailabilityProviderRegistryImpl {
3
+ constructor() {
4
+ // Preserves registration order via Map iteration semantics.
5
+ this.providers = /* @__PURE__ */ new Map();
6
+ }
7
+ register(provider) {
8
+ if (!provider || typeof provider.id !== "string" || provider.id.length === 0) {
9
+ throw new Error("[internal] AvailabilityProviderRegistry: provider must have a non-empty id");
10
+ }
11
+ this.providers.set(provider.id, provider);
12
+ }
13
+ get(id) {
14
+ return this.providers.get(id) ?? null;
15
+ }
16
+ list() {
17
+ return Array.from(this.providers.values());
18
+ }
19
+ reset() {
20
+ this.providers.clear();
21
+ }
22
+ }
23
+ const availabilityProviderRegistry = new AvailabilityProviderRegistryImpl();
24
+ function resolveAutoProvider() {
25
+ for (const provider of availabilityProviderRegistry.list()) {
26
+ if (provider.id !== AVAILABILITY_CATALOG_ONLY_PROVIDER_ID) return provider;
27
+ }
28
+ return availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID);
29
+ }
30
+ async function resolveAvailability(query, options) {
31
+ const selection = options?.moduleConfig ? await options.moduleConfig.getValue("availability", "selectedProvider", {
32
+ defaultValue: "auto",
33
+ scope: { tenantId: query.tenantId }
34
+ }) : "auto";
35
+ const provider = !selection || selection === "auto" ? resolveAutoProvider() : availabilityProviderRegistry.get(selection) ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID);
36
+ const resolved = provider ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID);
37
+ if (!resolved) {
38
+ throw new Error("[internal] No availability provider registered, including the built-in catalog-only fallback");
39
+ }
40
+ return resolved.getAvailability(query, { container: options?.container ?? null });
41
+ }
42
+ export {
43
+ AVAILABILITY_CATALOG_ONLY_PROVIDER_ID,
44
+ availabilityProviderRegistry,
45
+ resolveAvailability
46
+ };
47
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/availability/registry.ts"],
4
+ "sourcesContent": ["/**\n * Availability provider registry \u2014 module-level singleton that collects\n * registered providers and dispatches `resolveAvailability()` to the\n * per-tenant selected one, falling back safely to the built-in\n * `catalog-only` provider.\n *\n * Mirrors `packages/shared/src/lib/ai/llm-provider-registry.ts`.\n *\n * @see ./types\n * @see .ai/specs/2026-08-14-availability-contract.md \u00A74.1a\n */\n\nimport type {\n AvailabilityDependencyResolver,\n AvailabilityProvider,\n AvailabilityQuery,\n AvailabilityResult,\n} from './types'\n\nexport const AVAILABILITY_CATALOG_ONLY_PROVIDER_ID = 'catalog-only'\n\n/** Public interface of the registry. Exposed as a singleton via {@link availabilityProviderRegistry}. */\nexport interface AvailabilityProviderRegistry {\n /**\n * Registers or replaces a provider. Registration is idempotent \u2014 calling\n * with the same id replaces the existing entry. Never order-dependent.\n */\n register(provider: AvailabilityProvider): void\n\n /** Returns the provider with the given id, or null when not registered. */\n get(id: string): AvailabilityProvider | null\n\n /** Returns all registered providers in registration order. */\n list(): readonly AvailabilityProvider[]\n\n /** Removes all registered providers. Intended for test isolation. */\n reset(): void\n}\n\nclass AvailabilityProviderRegistryImpl implements AvailabilityProviderRegistry {\n // Preserves registration order via Map iteration semantics.\n private readonly providers = new Map<string, AvailabilityProvider>()\n\n register(provider: AvailabilityProvider): void {\n if (!provider || typeof provider.id !== 'string' || provider.id.length === 0) {\n throw new Error('[internal] AvailabilityProviderRegistry: provider must have a non-empty id')\n }\n // Idempotent: replace existing by id.\n this.providers.set(provider.id, provider)\n }\n\n get(id: string): AvailabilityProvider | null {\n return this.providers.get(id) ?? null\n }\n\n list(): readonly AvailabilityProvider[] {\n return Array.from(this.providers.values())\n }\n\n reset(): void {\n this.providers.clear()\n }\n}\n\n/** Process-level singleton instance of the registry. */\nexport const availabilityProviderRegistry: AvailabilityProviderRegistry = new AvailabilityProviderRegistryImpl()\n\nexport type AvailabilityProviderSelection = 'auto' | 'catalog-only' | (string & {})\n\n/**\n * Narrow port for per-tenant provider selection \u2014 declared locally so this\n * package stays dependency-free. A caller with DI access (an `availability`\n * module route, a future `ecommerce`/`cart`/`checkout` consumer) passes in\n * its resolved `ModuleConfigService` instance; omitting it always resolves\n * `'auto'`.\n */\nexport interface AvailabilityModuleConfigReader {\n getValue<T = unknown>(\n moduleId: string,\n name: string,\n options?: { defaultValue?: T | null; scope?: { tenantId?: string | null; organizationId?: string | null } },\n ): Promise<T | null>\n}\n\nexport interface ResolveAvailabilityOptions {\n /** `ModuleConfigService('availability', 'selectedProvider')` reader. See {@link AvailabilityModuleConfigReader}. */\n moduleConfig?: AvailabilityModuleConfigReader\n /**\n * The calling request's DI container, forwarded to the selected provider\n * as {@link AvailabilityProviderContext.container}. Omitting it makes a\n * DI-backed provider build its own request container for the call.\n */\n container?: AvailabilityDependencyResolver | null\n}\n\nfunction resolveAutoProvider(): AvailabilityProvider | null {\n // 'auto' = \"the highest-precedence registered provider\" \u2014 the first\n // non-catalog-only registrant, in registration order.\n for (const provider of availabilityProviderRegistry.list()) {\n if (provider.id !== AVAILABILITY_CATALOG_ONLY_PROVIDER_ID) return provider\n }\n return availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)\n}\n\n/**\n * The entry point every read-side consumer calls. Advisory only \u2014 see \u00A74.1:\n * `resolveAvailability()` is never a stock guarantee, `reserveAvailability()`\n * (the `availability` module, not shipped by this contract) is.\n */\nexport async function resolveAvailability(\n query: AvailabilityQuery,\n options?: ResolveAvailabilityOptions,\n): Promise<AvailabilityResult> {\n const selection = options?.moduleConfig\n ? await options.moduleConfig.getValue<AvailabilityProviderSelection>('availability', 'selectedProvider', {\n defaultValue: 'auto',\n scope: { tenantId: query.tenantId },\n })\n : 'auto'\n\n const provider =\n !selection || selection === 'auto'\n ? resolveAutoProvider()\n : availabilityProviderRegistry.get(selection) ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)\n\n const resolved = provider ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)\n if (!resolved) {\n throw new Error('[internal] No availability provider registered, including the built-in catalog-only fallback')\n }\n return resolved.getAvailability(query, { container: options?.container ?? null })\n}\n"],
5
+ "mappings": "AAmBO,MAAM,wCAAwC;AAoBrD,MAAM,iCAAyE;AAAA,EAA/E;AAEE;AAAA,SAAiB,YAAY,oBAAI,IAAkC;AAAA;AAAA,EAEnE,SAAS,UAAsC;AAC7C,QAAI,CAAC,YAAY,OAAO,SAAS,OAAO,YAAY,SAAS,GAAG,WAAW,GAAG;AAC5E,YAAM,IAAI,MAAM,4EAA4E;AAAA,IAC9F;AAEA,SAAK,UAAU,IAAI,SAAS,IAAI,QAAQ;AAAA,EAC1C;AAAA,EAEA,IAAI,IAAyC;AAC3C,WAAO,KAAK,UAAU,IAAI,EAAE,KAAK;AAAA,EACnC;AAAA,EAEA,OAAwC;AACtC,WAAO,MAAM,KAAK,KAAK,UAAU,OAAO,CAAC;AAAA,EAC3C;AAAA,EAEA,QAAc;AACZ,SAAK,UAAU,MAAM;AAAA,EACvB;AACF;AAGO,MAAM,+BAA6D,IAAI,iCAAiC;AA8B/G,SAAS,sBAAmD;AAG1D,aAAW,YAAY,6BAA6B,KAAK,GAAG;AAC1D,QAAI,SAAS,OAAO,sCAAuC,QAAO;AAAA,EACpE;AACA,SAAO,6BAA6B,IAAI,qCAAqC;AAC/E;AAOA,eAAsB,oBACpB,OACA,SAC6B;AAC7B,QAAM,YAAY,SAAS,eACvB,MAAM,QAAQ,aAAa,SAAwC,gBAAgB,oBAAoB;AAAA,IACrG,cAAc;AAAA,IACd,OAAO,EAAE,UAAU,MAAM,SAAS;AAAA,EACpC,CAAC,IACD;AAEJ,QAAM,WACJ,CAAC,aAAa,cAAc,SACxB,oBAAoB,IACpB,6BAA6B,IAAI,SAAS,KAAK,6BAA6B,IAAI,qCAAqC;AAE3H,QAAM,WAAW,YAAY,6BAA6B,IAAI,qCAAqC;AACnG,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,MAAM,8FAA8F;AAAA,EAChH;AACA,SAAO,SAAS,gBAAgB,OAAO,EAAE,WAAW,SAAS,aAAa,KAAK,CAAC;AAClF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,7 @@
1
+ function availabilityItemKey(item) {
2
+ return `${item.catalogProductId}:${item.catalogVariantId ?? ""}`;
3
+ }
4
+ export {
5
+ availabilityItemKey
6
+ };
7
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/availability/types.ts"],
4
+ "sourcesContent": ["/**\n * Availability Contract \u2014 base types.\n *\n * Zero module dependencies, so `ecommerce`, `cart`, `checkout` and `catalog`\n * can consume this without a `requires` edge on either `availability` or `wms`.\n *\n * @see .ai/specs/2026-08-14-availability-contract.md \u00A74.1a\n */\n\nexport type AvailabilityState =\n | 'in_stock'\n | 'low_stock'\n | 'out_of_stock'\n | 'backorder'\n | 'preorder'\n | 'not_tracked'\n\nexport type AvailabilityItemQuery = {\n catalogProductId: string\n /** `null` or omitted \u2192 product-level rollup over active variants. */\n catalogVariantId?: string | null\n /** The quantity being asked about; state is relative to it. */\n quantity: number\n}\n\nexport type AvailabilityQuery = {\n tenantId: string\n organizationId: string\n items: AvailabilityItemQuery[]\n /** Selects the policy chain. */\n storeId?: string | null\n channelId?: string | null\n /** `null`/omitted \u2192 every in-scope location. */\n locationIds?: string[] | null\n /**\n * Additive, optional. When true, a provider MUST skip any internal read\n * cache and compute a live result \u2014 the \u00A76 \"cart re-validation\" row.\n */\n bypassCache?: boolean\n}\n\nexport type AvailabilityItemResult = {\n state: AvailabilityState\n /** Sellable quantity; `null` when not tracked. */\n availableQuantity: number | null\n /** Whether the requested quantity can be met, including a backorder/preorder path. */\n canFulfil: boolean\n /** Set for `'backorder'`. */\n leadTimeDays: number | null\n /** ISO-8601; set for `'preorder'`. */\n releaseAt: string | null\n /** `false` for a cached browse-time read \u2014 never a guarantee. */\n isAuthoritative: boolean\n /** The `AvailabilityPolicy` row that decided, or `null` for a module default. */\n policySourceId: string | null\n}\n\nexport type AvailabilityResult = {\n /** Key: `${catalogProductId}:${catalogVariantId ?? ''}` \u2014 stable and caller-derivable. */\n byItem: Record<string, AvailabilityItemResult>\n}\n\n/** Narrow DI port a provider resolves its per-call dependencies (`em`, cache, services) from. */\nexport type AvailabilityDependencyResolver = {\n resolve: <T = unknown>(name: string) => T\n}\n\n/**\n * Per-call context handed to a provider. `container` is the calling\n * request's DI container; a provider MUST resolve its dependencies from it\n * (or build its own when absent) and never from a container captured at\n * registration time \u2014 the registry is process-wide.\n */\nexport type AvailabilityProviderContext = {\n container?: AvailabilityDependencyResolver | null\n}\n\nexport interface AvailabilityProvider {\n id: string\n getAvailability(query: AvailabilityQuery, context?: AvailabilityProviderContext): Promise<AvailabilityResult>\n}\n\n/** Builds the stable `AvailabilityResult.byItem` key for an item. */\nexport function availabilityItemKey(item: { catalogProductId: string; catalogVariantId?: string | null }): string {\n return `${item.catalogProductId}:${item.catalogVariantId ?? ''}`\n}\n"],
5
+ "mappings": "AAmFO,SAAS,oBAAoB,MAA8E;AAChH,SAAO,GAAG,KAAK,gBAAgB,IAAI,KAAK,oBAAoB,EAAE;AAChE;",
6
+ "names": []
7
+ }
@@ -0,0 +1,11 @@
1
+ import { matchesOne } from "./matchesOne.js";
2
+ import { matchesScope } from "./matchesScope.js";
3
+ import { unionScopes } from "./unionScopes.js";
4
+ import { intersectScopes } from "./intersectScopes.js";
5
+ export {
6
+ intersectScopes,
7
+ matchesOne,
8
+ matchesScope,
9
+ unionScopes
10
+ };
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/catalog-visibility/index.ts"],
4
+ "sourcesContent": ["export type { AssortmentScope, EffectiveAssortmentScope, ScopedProduct } from './types'\nexport { matchesOne } from './matchesOne'\nexport { matchesScope } from './matchesScope'\nexport { unionScopes } from './unionScopes'\nexport { intersectScopes } from './intersectScopes'\n"],
5
+ "mappings": "AACA,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB;AAC7B,SAAS,mBAAmB;AAC5B,SAAS,uBAAuB;",
6
+ "names": []
7
+ }
@@ -0,0 +1,49 @@
1
+ function combineField(channelIds, groupIds) {
2
+ const a = channelIds && channelIds.length > 0 ? channelIds : void 0;
3
+ const b = groupIds && groupIds.length > 0 ? groupIds : void 0;
4
+ if (!a && !b) return { ids: void 0, residual: void 0 };
5
+ if (!a) return { ids: b, residual: void 0 };
6
+ if (!b) return { ids: a, residual: void 0 };
7
+ const bSet = new Set(b);
8
+ if (a.every((id) => bSet.has(id))) return { ids: a, residual: void 0 };
9
+ const aSet = new Set(a);
10
+ if (b.every((id) => aSet.has(id))) return { ids: b, residual: void 0 };
11
+ return { ids: a, residual: b };
12
+ }
13
+ function unionIds(a, b) {
14
+ const merged = [...a ?? [], ...b ?? []];
15
+ if (merged.length === 0) return void 0;
16
+ return Array.from(new Set(merged));
17
+ }
18
+ function mergeAsConjunction(channel, group) {
19
+ const categoryCombination = combineField(channel.categoryIds, group.categoryIds);
20
+ const tagCombination = combineField(channel.tagIds, group.tagIds);
21
+ const merged = {};
22
+ if (categoryCombination.ids) merged.categoryIds = categoryCombination.ids;
23
+ if (tagCombination.ids) merged.tagIds = tagCombination.ids;
24
+ const excludeProductIds = unionIds(channel.excludeProductIds, group.excludeProductIds);
25
+ if (excludeProductIds) merged.excludeProductIds = excludeProductIds;
26
+ const excludeCategoryIds = unionIds(channel.excludeCategoryIds, group.excludeCategoryIds);
27
+ if (excludeCategoryIds) merged.excludeCategoryIds = excludeCategoryIds;
28
+ const excludeTagIds = unionIds(channel.excludeTagIds, group.excludeTagIds);
29
+ if (excludeTagIds) merged.excludeTagIds = excludeTagIds;
30
+ const residual = {};
31
+ if (categoryCombination.residual) residual.categoryIds = categoryCombination.residual;
32
+ if (tagCombination.residual) residual.tagIds = tagCombination.residual;
33
+ const allOf = [
34
+ ...Object.keys(residual).length > 0 ? [residual] : [],
35
+ ...channel.allOf ?? [],
36
+ ...group.allOf ?? []
37
+ ];
38
+ if (allOf.length > 0) merged.allOf = allOf;
39
+ return merged;
40
+ }
41
+ function intersectScopes(channel, group) {
42
+ if (channel === null) return group;
43
+ if (group === null) return [channel];
44
+ return group.map((branch) => mergeAsConjunction(channel, branch));
45
+ }
46
+ export {
47
+ intersectScopes
48
+ };
49
+ //# sourceMappingURL=intersectScopes.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/catalog-visibility/intersectScopes.ts"],
4
+ "sourcesContent": ["import type { AssortmentScope, EffectiveAssortmentScope } from './types'\n\ntype FieldCombination = { ids: string[] | undefined; residual: string[] | undefined }\n\n/**\n * Combines one dimension (categoryIds or tagIds) from two independent AND-scopes. Every\n * outcome is exact \u2014 `overlap(P, ids) && overlap(P, residual)` equals\n * `overlap(P, channelIds) && overlap(P, groupIds)` for every product:\n *\n * - Both absent/empty -> unrestricted (`undefined`).\n * - Only one side restricts -> that side's set, unmodified (the other side is vacuously true).\n * - Both restrict and one set is a subset of the other (including equal sets) -> the subset,\n * because overlapping the subset already implies overlapping the superset.\n * - Both restrict with incomparable sets -> the channel's set stays on the branch and the\n * group's set is returned as `residual`, to be ANDed via `allOf`. Set-intersecting the two\n * would wrongly deny a product that satisfies each side through a different id (e.g. a\n * product in both Electronics and Clearance under channel [Electronics] \u2229 group [Clearance]).\n */\nfunction combineField(channelIds: string[] | undefined, groupIds: string[] | undefined): FieldCombination {\n const a = channelIds && channelIds.length > 0 ? channelIds : undefined\n const b = groupIds && groupIds.length > 0 ? groupIds : undefined\n if (!a && !b) return { ids: undefined, residual: undefined }\n if (!a) return { ids: b, residual: undefined }\n if (!b) return { ids: a, residual: undefined }\n const bSet = new Set(b)\n if (a.every((id) => bSet.has(id))) return { ids: a, residual: undefined }\n const aSet = new Set(a)\n if (b.every((id) => aSet.has(id))) return { ids: b, residual: undefined }\n return { ids: a, residual: b }\n}\n\n/**\n * Excludes are always safe to union: `NOT excluded_a AND NOT excluded_b` is exactly\n * `NOT (excluded_a OR excluded_b)`, i.e. avoiding the union of both exclude sets.\n */\nfunction unionIds(a: string[] | undefined, b: string[] | undefined): string[] | undefined {\n const merged = [...(a ?? []), ...(b ?? [])]\n if (merged.length === 0) return undefined\n return Array.from(new Set(merged))\n}\n\n/**\n * Builds one branch equivalent to \"matches `channel` AND matches `group`\". Dimensions and\n * excludes are merged into flat fields where that is exact; whatever cannot be merged\n * exactly (incomparable inclusion sets, and any `allOf` either side already carries) is\n * kept as `allOf` entries, which `matchesOne` ANDs at match time. Never returns an\n * unsatisfiable-looking or dropped branch.\n */\nfunction mergeAsConjunction(channel: AssortmentScope, group: AssortmentScope): AssortmentScope {\n const categoryCombination = combineField(channel.categoryIds, group.categoryIds)\n const tagCombination = combineField(channel.tagIds, group.tagIds)\n\n const merged: AssortmentScope = {}\n if (categoryCombination.ids) merged.categoryIds = categoryCombination.ids\n if (tagCombination.ids) merged.tagIds = tagCombination.ids\n\n const excludeProductIds = unionIds(channel.excludeProductIds, group.excludeProductIds)\n if (excludeProductIds) merged.excludeProductIds = excludeProductIds\n const excludeCategoryIds = unionIds(channel.excludeCategoryIds, group.excludeCategoryIds)\n if (excludeCategoryIds) merged.excludeCategoryIds = excludeCategoryIds\n const excludeTagIds = unionIds(channel.excludeTagIds, group.excludeTagIds)\n if (excludeTagIds) merged.excludeTagIds = excludeTagIds\n\n const residual: AssortmentScope = {}\n if (categoryCombination.residual) residual.categoryIds = categoryCombination.residual\n if (tagCombination.residual) residual.tagIds = tagCombination.residual\n\n const allOf: AssortmentScope[] = [\n ...(Object.keys(residual).length > 0 ? [residual] : []),\n ...(channel.allOf ?? []),\n ...(group.allOf ?? []),\n ]\n if (allOf.length > 0) merged.allOf = allOf\n\n return merged\n}\n\n/**\n * Distributes a single channel-level scope across every branch of an already-unioned\n * group-level effective scope: (channel) \u2229 (g1 \u222A g2 \u222A ... \u222A gn) = (channel\u2229g1) \u222A (channel\u2229g2) \u222A ...\n * Each branch is exact by construction (spec \u00A73.3 / \u00A711):\n * `matchesScope(p, intersectScopes(channel, group)) === (channel === null || matchesOne(p, channel)) && matchesScope(p, group)`\n * for every product. See `mergeAsConjunction` for how a branch carries both conditions.\n *\n * Branches are never dropped, so the result is `[]` (deny-all) only when `group` is `[]`.\n * Merging raw category/tag arrays by plain union or intersection is wrong for the same\n * structural reason flattening the union was wrong (R2) \u2014 do not reintroduce it.\n */\nexport function intersectScopes(channel: AssortmentScope | null, group: EffectiveAssortmentScope): EffectiveAssortmentScope {\n if (channel === null) return group\n if (group === null) return [channel]\n return group.map((branch) => mergeAsConjunction(channel, branch))\n}\n"],
5
+ "mappings": "AAkBA,SAAS,aAAa,YAAkC,UAAkD;AACxG,QAAM,IAAI,cAAc,WAAW,SAAS,IAAI,aAAa;AAC7D,QAAM,IAAI,YAAY,SAAS,SAAS,IAAI,WAAW;AACvD,MAAI,CAAC,KAAK,CAAC,EAAG,QAAO,EAAE,KAAK,QAAW,UAAU,OAAU;AAC3D,MAAI,CAAC,EAAG,QAAO,EAAE,KAAK,GAAG,UAAU,OAAU;AAC7C,MAAI,CAAC,EAAG,QAAO,EAAE,KAAK,GAAG,UAAU,OAAU;AAC7C,QAAM,OAAO,IAAI,IAAI,CAAC;AACtB,MAAI,EAAE,MAAM,CAAC,OAAO,KAAK,IAAI,EAAE,CAAC,EAAG,QAAO,EAAE,KAAK,GAAG,UAAU,OAAU;AACxE,QAAM,OAAO,IAAI,IAAI,CAAC;AACtB,MAAI,EAAE,MAAM,CAAC,OAAO,KAAK,IAAI,EAAE,CAAC,EAAG,QAAO,EAAE,KAAK,GAAG,UAAU,OAAU;AACxE,SAAO,EAAE,KAAK,GAAG,UAAU,EAAE;AAC/B;AAMA,SAAS,SAAS,GAAyB,GAA+C;AACxF,QAAM,SAAS,CAAC,GAAI,KAAK,CAAC,GAAI,GAAI,KAAK,CAAC,CAAE;AAC1C,MAAI,OAAO,WAAW,EAAG,QAAO;AAChC,SAAO,MAAM,KAAK,IAAI,IAAI,MAAM,CAAC;AACnC;AASA,SAAS,mBAAmB,SAA0B,OAAyC;AAC7F,QAAM,sBAAsB,aAAa,QAAQ,aAAa,MAAM,WAAW;AAC/E,QAAM,iBAAiB,aAAa,QAAQ,QAAQ,MAAM,MAAM;AAEhE,QAAM,SAA0B,CAAC;AACjC,MAAI,oBAAoB,IAAK,QAAO,cAAc,oBAAoB;AACtE,MAAI,eAAe,IAAK,QAAO,SAAS,eAAe;AAEvD,QAAM,oBAAoB,SAAS,QAAQ,mBAAmB,MAAM,iBAAiB;AACrF,MAAI,kBAAmB,QAAO,oBAAoB;AAClD,QAAM,qBAAqB,SAAS,QAAQ,oBAAoB,MAAM,kBAAkB;AACxF,MAAI,mBAAoB,QAAO,qBAAqB;AACpD,QAAM,gBAAgB,SAAS,QAAQ,eAAe,MAAM,aAAa;AACzE,MAAI,cAAe,QAAO,gBAAgB;AAE1C,QAAM,WAA4B,CAAC;AACnC,MAAI,oBAAoB,SAAU,UAAS,cAAc,oBAAoB;AAC7E,MAAI,eAAe,SAAU,UAAS,SAAS,eAAe;AAE9D,QAAM,QAA2B;AAAA,IAC/B,GAAI,OAAO,KAAK,QAAQ,EAAE,SAAS,IAAI,CAAC,QAAQ,IAAI,CAAC;AAAA,IACrD,GAAI,QAAQ,SAAS,CAAC;AAAA,IACtB,GAAI,MAAM,SAAS,CAAC;AAAA,EACtB;AACA,MAAI,MAAM,SAAS,EAAG,QAAO,QAAQ;AAErC,SAAO;AACT;AAaO,SAAS,gBAAgB,SAAiC,OAA2D;AAC1H,MAAI,YAAY,KAAM,QAAO;AAC7B,MAAI,UAAU,KAAM,QAAO,CAAC,OAAO;AACnC,SAAO,MAAM,IAAI,CAAC,WAAW,mBAAmB,SAAS,MAAM,CAAC;AAClE;",
6
+ "names": []
7
+ }
@@ -0,0 +1,27 @@
1
+ function overlaps(productIds, scopeIds) {
2
+ if (productIds.length === 0 || scopeIds.length === 0) return false;
3
+ const set = new Set(scopeIds);
4
+ return productIds.some((id) => set.has(id));
5
+ }
6
+ function included(product, scope) {
7
+ const categoryIds = scope.categoryIds ?? [];
8
+ const tagIds = scope.tagIds ?? [];
9
+ const categoryOk = categoryIds.length === 0 || overlaps(product.categoryIds, categoryIds);
10
+ const tagOk = tagIds.length === 0 || overlaps(product.tagIds, tagIds);
11
+ return categoryOk && tagOk;
12
+ }
13
+ function matchesOne(product, scope) {
14
+ if (!included(product, scope)) return false;
15
+ const excludeProductIds = scope.excludeProductIds ?? [];
16
+ if (excludeProductIds.includes(product.id)) return false;
17
+ const excludeCategoryIds = scope.excludeCategoryIds ?? [];
18
+ if (excludeCategoryIds.length > 0 && overlaps(product.categoryIds, excludeCategoryIds)) return false;
19
+ const excludeTagIds = scope.excludeTagIds ?? [];
20
+ if (excludeTagIds.length > 0 && overlaps(product.tagIds, excludeTagIds)) return false;
21
+ const allOf = scope.allOf ?? [];
22
+ return allOf.every((nested) => matchesOne(product, nested));
23
+ }
24
+ export {
25
+ matchesOne
26
+ };
27
+ //# sourceMappingURL=matchesOne.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/catalog-visibility/matchesOne.ts"],
4
+ "sourcesContent": ["import type { AssortmentScope, ScopedProduct } from './types'\n\nfunction overlaps(productIds: string[], scopeIds: string[]): boolean {\n if (productIds.length === 0 || scopeIds.length === 0) return false\n const set = new Set(scopeIds)\n return productIds.some((id) => set.has(id))\n}\n\nfunction included(product: ScopedProduct, scope: AssortmentScope): boolean {\n const categoryIds = scope.categoryIds ?? []\n const tagIds = scope.tagIds ?? []\n const categoryOk = categoryIds.length === 0 || overlaps(product.categoryIds, categoryIds)\n const tagOk = tagIds.length === 0 || overlaps(product.tagIds, tagIds)\n return categoryOk && tagOk\n}\n\n/** AND match of one scope, including every nested `allOf` scope. */\nexport function matchesOne(product: ScopedProduct, scope: AssortmentScope): boolean {\n if (!included(product, scope)) return false\n const excludeProductIds = scope.excludeProductIds ?? []\n if (excludeProductIds.includes(product.id)) return false\n const excludeCategoryIds = scope.excludeCategoryIds ?? []\n if (excludeCategoryIds.length > 0 && overlaps(product.categoryIds, excludeCategoryIds)) return false\n const excludeTagIds = scope.excludeTagIds ?? []\n if (excludeTagIds.length > 0 && overlaps(product.tagIds, excludeTagIds)) return false\n const allOf = scope.allOf ?? []\n return allOf.every((nested) => matchesOne(product, nested))\n}\n"],
5
+ "mappings": "AAEA,SAAS,SAAS,YAAsB,UAA6B;AACnE,MAAI,WAAW,WAAW,KAAK,SAAS,WAAW,EAAG,QAAO;AAC7D,QAAM,MAAM,IAAI,IAAI,QAAQ;AAC5B,SAAO,WAAW,KAAK,CAAC,OAAO,IAAI,IAAI,EAAE,CAAC;AAC5C;AAEA,SAAS,SAAS,SAAwB,OAAiC;AACzE,QAAM,cAAc,MAAM,eAAe,CAAC;AAC1C,QAAM,SAAS,MAAM,UAAU,CAAC;AAChC,QAAM,aAAa,YAAY,WAAW,KAAK,SAAS,QAAQ,aAAa,WAAW;AACxF,QAAM,QAAQ,OAAO,WAAW,KAAK,SAAS,QAAQ,QAAQ,MAAM;AACpE,SAAO,cAAc;AACvB;AAGO,SAAS,WAAW,SAAwB,OAAiC;AAClF,MAAI,CAAC,SAAS,SAAS,KAAK,EAAG,QAAO;AACtC,QAAM,oBAAoB,MAAM,qBAAqB,CAAC;AACtD,MAAI,kBAAkB,SAAS,QAAQ,EAAE,EAAG,QAAO;AACnD,QAAM,qBAAqB,MAAM,sBAAsB,CAAC;AACxD,MAAI,mBAAmB,SAAS,KAAK,SAAS,QAAQ,aAAa,kBAAkB,EAAG,QAAO;AAC/F,QAAM,gBAAgB,MAAM,iBAAiB,CAAC;AAC9C,MAAI,cAAc,SAAS,KAAK,SAAS,QAAQ,QAAQ,aAAa,EAAG,QAAO;AAChF,QAAM,QAAQ,MAAM,SAAS,CAAC;AAC9B,SAAO,MAAM,MAAM,CAAC,WAAW,WAAW,SAAS,MAAM,CAAC;AAC5D;",
6
+ "names": []
7
+ }
@@ -0,0 +1,9 @@
1
+ import { matchesOne } from "./matchesOne.js";
2
+ function matchesScope(product, effective) {
3
+ if (effective === null) return true;
4
+ return effective.some((scope) => matchesOne(product, scope));
5
+ }
6
+ export {
7
+ matchesScope
8
+ };
9
+ //# sourceMappingURL=matchesScope.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/catalog-visibility/matchesScope.ts"],
4
+ "sourcesContent": ["import type { EffectiveAssortmentScope, ScopedProduct } from './types'\nimport { matchesOne } from './matchesOne'\n\n/** OR across sources. `null` -> true (unrestricted). `[]` -> false for every product (deny-all). */\nexport function matchesScope(product: ScopedProduct, effective: EffectiveAssortmentScope): boolean {\n if (effective === null) return true\n return effective.some((scope) => matchesOne(product, scope))\n}\n"],
5
+ "mappings": "AACA,SAAS,kBAAkB;AAGpB,SAAS,aAAa,SAAwB,WAA8C;AACjG,MAAI,cAAc,KAAM,QAAO;AAC/B,SAAO,UAAU,KAAK,CAAC,UAAU,WAAW,SAAS,KAAK,CAAC;AAC7D;",
6
+ "names": []
7
+ }
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": [],
4
+ "sourcesContent": [],
5
+ "mappings": "",
6
+ "names": []
7
+ }
@@ -0,0 +1,9 @@
1
+ function unionScopes(scopes) {
2
+ if (scopes.length === 0) return null;
3
+ if (scopes.some((s) => s === null)) return null;
4
+ return scopes.filter((scope) => scope !== null);
5
+ }
6
+ export {
7
+ unionScopes
8
+ };
9
+ //# sourceMappingURL=unionScopes.js.map