@open-mercato/shared 0.8.1-develop.7296.1.2111d779db → 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
@@ -118,6 +118,15 @@ function inspectSecret(secret: string | undefined | null): JwtSecretViolation |
118
118
  return null
119
119
  }
120
120
 
121
+ /**
122
+ * True when `secret` would be refused by the production signing-secret policy: missing, a
123
+ * published placeholder, or shorter than the minimum length. Lets tooling that launches the app
124
+ * (e.g. the ephemeral integration runner) swap in a safe value instead of booting into a crash.
125
+ */
126
+ export function isUnsafeJwtSecret(secret?: string | null): boolean {
127
+ return inspectSecret(secret) !== null
128
+ }
129
+
121
130
  function describeViolation(name: string, violation: JwtSecretViolation): string {
122
131
  switch (violation) {
123
132
  case 'missing':
@@ -1,3 +1,5 @@
1
+ import { forbidden } from '../crud/errors'
2
+
1
3
  type OrganizationScopedAuth = {
2
4
  orgId?: string | null
3
5
  actorOrgId?: unknown
@@ -5,6 +7,16 @@ type OrganizationScopedAuth = {
5
7
  actorTenantId?: unknown
6
8
  } | null | undefined
7
9
 
10
+ type FiniteOrganizationScope = {
11
+ selectedId?: string | null
12
+ filterIds?: readonly string[] | null
13
+ allowedIds?: readonly string[] | null
14
+ } | null | undefined
15
+
16
+ type OrganizationHomeAuth = {
17
+ orgId?: string | null
18
+ } | null | undefined
19
+
8
20
  function normalizeId(value: unknown): string | null {
9
21
  if (typeof value !== 'string') return null
10
22
  const trimmed = value.trim()
@@ -47,6 +59,33 @@ export function resolveActiveOrganizationId(auth: OrganizationScopedAuth): strin
47
59
  return actorOrgId
48
60
  }
49
61
 
62
+ /**
63
+ * True when the caller's organization scope was resolved to an explicitly empty set
64
+ * (`filterIds: []` or `allowedIds: []`), i.e. the principal can see no organization at
65
+ * all. An empty set is deny-all and MUST NOT be widened back to the home organization.
66
+ *
67
+ * Routes that would otherwise reveal a record's existence use this to answer with their
68
+ * own not-found response instead of letting `resolveSingleOrganizationIdOrDeny` throw —
69
+ * a thrown deny leaves the route's documented status (and the existence-oracle
70
+ * collapse of issue #5504) up to whoever catches it.
71
+ */
72
+ export function isExplicitlyEmptyOrganizationScope(scope: FiniteOrganizationScope): boolean {
73
+ if (!scope) return false
74
+ return (Array.isArray(scope.filterIds) && scope.filterIds.length === 0)
75
+ || (Array.isArray(scope.allowedIds) && scope.allowedIds.length === 0)
76
+ }
77
+
78
+ export function resolveSingleOrganizationIdOrDeny(
79
+ scope: FiniteOrganizationScope,
80
+ auth: OrganizationHomeAuth,
81
+ ): string | null {
82
+ if (isExplicitlyEmptyOrganizationScope(scope)) {
83
+ throw forbidden()
84
+ }
85
+ if (scope?.selectedId) return scope.selectedId
86
+ return auth?.orgId ?? null
87
+ }
88
+
50
89
  export const ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE = 'organization_scope_required'
51
90
 
52
91
  /**
@@ -0,0 +1,166 @@
1
+ import '../catalogOnlyProvider'
2
+ import { setCatalogOnlyPolicyLookup } from '../catalogOnlyProvider'
3
+ import { availabilityProviderRegistry, resolveAvailability, AVAILABILITY_CATALOG_ONLY_PROVIDER_ID } from '../registry'
4
+ import type { AvailabilityQuery } from '../types'
5
+
6
+ function makeQuery(overrides: Partial<AvailabilityQuery> = {}): AvailabilityQuery {
7
+ return {
8
+ tenantId: 'tenant-1',
9
+ organizationId: 'org-1',
10
+ items: [{ catalogProductId: 'product-1', catalogVariantId: 'variant-1', quantity: 1 }],
11
+ ...overrides,
12
+ }
13
+ }
14
+
15
+ describe('catalog-only fallback provider', () => {
16
+ beforeEach(() => {
17
+ setCatalogOnlyPolicyLookup(null)
18
+ })
19
+
20
+ afterAll(() => {
21
+ setCatalogOnlyPolicyLookup(null)
22
+ })
23
+
24
+ it('is always registered under the catalog-only id', () => {
25
+ expect(availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)).not.toBeNull()
26
+ })
27
+
28
+ it('returns not_tracked with canFulfil true and isAuthoritative true for every item with no policy hook (R5)', async () => {
29
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
30
+ const result = await provider.getAvailability(makeQuery())
31
+ const item = result.byItem['product-1:variant-1']
32
+ expect(item.state).toBe('not_tracked')
33
+ expect(item.canFulfil).toBe(true)
34
+ expect(item.isAuthoritative).toBe(true)
35
+ expect(item.availableQuantity).toBeNull()
36
+ // not_tracked must never collapse into in_stock for a naive consumer.
37
+ expect(item.state).not.toBe('in_stock')
38
+ })
39
+
40
+ it('reflects a future preorderReleaseAt from the policy hook as preorder', async () => {
41
+ const future = new Date(Date.now() + 86_400_000).toISOString()
42
+ setCatalogOnlyPolicyLookup(async () => ({
43
+ 'product-1:variant-1': { preorderReleaseAt: future, policySourceId: 'policy-1' },
44
+ }))
45
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
46
+ const result = await provider.getAvailability(makeQuery())
47
+ const item = result.byItem['product-1:variant-1']
48
+ expect(item.state).toBe('preorder')
49
+ expect(item.releaseAt).toBe(future)
50
+ expect(item.policySourceId).toBe('policy-1')
51
+ })
52
+
53
+ it('treats a past preorderReleaseAt as no longer preorder', async () => {
54
+ const past = new Date(Date.now() - 86_400_000).toISOString()
55
+ setCatalogOnlyPolicyLookup(async () => ({
56
+ 'product-1:variant-1': { preorderReleaseAt: past, policySourceId: 'policy-1' },
57
+ }))
58
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
59
+ const result = await provider.getAvailability(makeQuery())
60
+ expect(result.byItem['product-1:variant-1'].state).not.toBe('preorder')
61
+ })
62
+
63
+ it('treats an inactive policy row as out_of_stock', async () => {
64
+ setCatalogOnlyPolicyLookup(async () => ({
65
+ 'product-1:variant-1': { isActive: false, policySourceId: 'policy-2' },
66
+ }))
67
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
68
+ const result = await provider.getAvailability(makeQuery())
69
+ const item = result.byItem['product-1:variant-1']
70
+ expect(item.state).toBe('out_of_stock')
71
+ expect(item.canFulfil).toBe(false)
72
+ })
73
+
74
+ it('keeps an inactive policy row out_of_stock even with a future preorder date (#6806)', async () => {
75
+ const future = new Date(Date.now() + 7 * 86_400_000).toISOString()
76
+ setCatalogOnlyPolicyLookup(async () => ({
77
+ 'product-1:variant-1': { isActive: false, preorderReleaseAt: future, policySourceId: 'policy-4' },
78
+ }))
79
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
80
+ const result = await provider.getAvailability(makeQuery())
81
+ const item = result.byItem['product-1:variant-1']
82
+ expect(item.state).toBe('out_of_stock')
83
+ expect(item.canFulfil).toBe(false)
84
+ expect(item.releaseAt).toBeNull()
85
+ expect(item.policySourceId).toBe('policy-4')
86
+ })
87
+
88
+ it('treats is_stock_managed true (no real data source) as out_of_stock rather than in_stock', async () => {
89
+ setCatalogOnlyPolicyLookup(async () => ({
90
+ 'product-1:variant-1': { isStockManaged: true, policySourceId: 'policy-3' },
91
+ }))
92
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
93
+ const result = await provider.getAvailability(makeQuery())
94
+ const item = result.byItem['product-1:variant-1']
95
+ expect(item.state).toBe('out_of_stock')
96
+ expect(item.canFulfil).toBe(false)
97
+ })
98
+
99
+ it('blocks a requested quantity above maxOrderQuantity without changing the state', async () => {
100
+ setCatalogOnlyPolicyLookup(async () => ({
101
+ 'product-1:variant-1': { maxOrderQuantity: 5, policySourceId: 'policy-4' },
102
+ }))
103
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
104
+ const result = await provider.getAvailability(makeQuery({
105
+ items: [{ catalogProductId: 'product-1', catalogVariantId: 'variant-1', quantity: 6 }],
106
+ }))
107
+ const item = result.byItem['product-1:variant-1']
108
+ expect(item.state).toBe('not_tracked')
109
+ expect(item.canFulfil).toBe(false)
110
+ })
111
+
112
+ it('blocks a requested quantity below minOrderQuantity or off the quantityIncrement', async () => {
113
+ setCatalogOnlyPolicyLookup(async () => ({
114
+ 'product-1:variant-1': { minOrderQuantity: 4, quantityIncrement: 4 },
115
+ 'product-2:': { quantityIncrement: 4 },
116
+ 'product-3:': { minOrderQuantity: 4, maxOrderQuantity: 12, quantityIncrement: 4 },
117
+ }))
118
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
119
+ const result = await provider.getAvailability(makeQuery({
120
+ items: [
121
+ { catalogProductId: 'product-1', catalogVariantId: 'variant-1', quantity: 2 },
122
+ { catalogProductId: 'product-2', catalogVariantId: null, quantity: 6 },
123
+ { catalogProductId: 'product-3', catalogVariantId: null, quantity: 8 },
124
+ ],
125
+ }))
126
+ expect(result.byItem['product-1:variant-1'].canFulfil).toBe(false)
127
+ expect(result.byItem['product-2:'].canFulfil).toBe(false)
128
+ expect(result.byItem['product-3:'].canFulfil).toBe(true)
129
+ })
130
+
131
+ it('keeps a future preorder fulfillable only within the order-quantity rules', async () => {
132
+ const future = new Date(Date.now() + 86_400_000).toISOString()
133
+ setCatalogOnlyPolicyLookup(async () => ({
134
+ 'product-1:variant-1': { preorderReleaseAt: future, maxOrderQuantity: 1 },
135
+ }))
136
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
137
+ const result = await provider.getAvailability(makeQuery({
138
+ items: [{ catalogProductId: 'product-1', catalogVariantId: 'variant-1', quantity: 2 }],
139
+ }))
140
+ const item = result.byItem['product-1:variant-1']
141
+ expect(item.state).toBe('preorder')
142
+ expect(item.canFulfil).toBe(false)
143
+ })
144
+
145
+ it('degrades to the pure fallback when the policy hook throws', async () => {
146
+ setCatalogOnlyPolicyLookup(async () => {
147
+ throw new Error('boom')
148
+ })
149
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
150
+ const result = await provider.getAvailability(makeQuery())
151
+ expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
152
+ })
153
+
154
+ it('hands the per-call context to the policy hook', async () => {
155
+ const lookup = jest.fn(async () => ({}))
156
+ setCatalogOnlyPolicyLookup(lookup)
157
+ const container = { resolve: <T,>(): T => undefined as T }
158
+ await resolveAvailability(makeQuery(), { container })
159
+ expect(lookup).toHaveBeenCalledWith(makeQuery(), { container })
160
+ })
161
+
162
+ it('is reachable end-to-end via resolveAvailability', async () => {
163
+ const result = await resolveAvailability(makeQuery())
164
+ expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
165
+ })
166
+ })
@@ -0,0 +1,130 @@
1
+ import { availabilityProviderRegistry, resolveAvailability, AVAILABILITY_CATALOG_ONLY_PROVIDER_ID } from '../registry'
2
+ import type { AvailabilityModuleConfigReader } from '../registry'
3
+ import type { AvailabilityProvider, AvailabilityQuery, AvailabilityResult } from '../types'
4
+
5
+ function makeProvider(id: string, result: Partial<AvailabilityResult['byItem'][string]> = {}): AvailabilityProvider {
6
+ return {
7
+ id,
8
+ async getAvailability(query: AvailabilityQuery): Promise<AvailabilityResult> {
9
+ const byItem: AvailabilityResult['byItem'] = {}
10
+ for (const item of query.items) {
11
+ const key = `${item.catalogProductId}:${item.catalogVariantId ?? ''}`
12
+ byItem[key] = {
13
+ state: 'in_stock',
14
+ availableQuantity: 100,
15
+ canFulfil: true,
16
+ leadTimeDays: null,
17
+ releaseAt: null,
18
+ isAuthoritative: true,
19
+ policySourceId: null,
20
+ ...result,
21
+ }
22
+ }
23
+ return { byItem }
24
+ },
25
+ }
26
+ }
27
+
28
+ function makeQuery(overrides: Partial<AvailabilityQuery> = {}): AvailabilityQuery {
29
+ return {
30
+ tenantId: 'tenant-1',
31
+ organizationId: 'org-1',
32
+ items: [{ catalogProductId: 'product-1', catalogVariantId: 'variant-1', quantity: 1 }],
33
+ ...overrides,
34
+ }
35
+ }
36
+
37
+ describe('availabilityProviderRegistry', () => {
38
+ beforeEach(() => {
39
+ availabilityProviderRegistry.reset()
40
+ })
41
+
42
+ it('registers and retrieves providers by id', () => {
43
+ const provider = makeProvider('alpha')
44
+ availabilityProviderRegistry.register(provider)
45
+ expect(availabilityProviderRegistry.get('alpha')).toBe(provider)
46
+ expect(availabilityProviderRegistry.get('missing')).toBeNull()
47
+ })
48
+
49
+ it('is idempotent — replaces by id rather than duplicating', () => {
50
+ const first = makeProvider('alpha')
51
+ const second = makeProvider('alpha')
52
+ availabilityProviderRegistry.register(first)
53
+ availabilityProviderRegistry.register(second)
54
+ expect(availabilityProviderRegistry.list()).toHaveLength(1)
55
+ expect(availabilityProviderRegistry.get('alpha')).toBe(second)
56
+ })
57
+
58
+ it('lists providers in registration order', () => {
59
+ availabilityProviderRegistry.register(makeProvider('first'))
60
+ availabilityProviderRegistry.register(makeProvider('second'))
61
+ expect(availabilityProviderRegistry.list().map((p) => p.id)).toEqual(['first', 'second'])
62
+ })
63
+
64
+ it('rejects a provider with an empty id', () => {
65
+ expect(() => availabilityProviderRegistry.register({ id: '', getAvailability: async () => ({ byItem: {} }) })).toThrow()
66
+ })
67
+ })
68
+
69
+ describe('resolveAvailability', () => {
70
+ beforeEach(() => {
71
+ availabilityProviderRegistry.reset()
72
+ })
73
+
74
+ it('falls back to catalog-only when nothing else is registered and selection is auto', async () => {
75
+ availabilityProviderRegistry.register(makeProvider(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID, { state: 'not_tracked', availableQuantity: null }))
76
+ const result = await resolveAvailability(makeQuery())
77
+ expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
78
+ })
79
+
80
+ it("'auto' picks the highest-precedence non-catalog-only registrant", async () => {
81
+ availabilityProviderRegistry.register(makeProvider(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID, { state: 'not_tracked' }))
82
+ availabilityProviderRegistry.register(makeProvider('wms', { state: 'in_stock' }))
83
+ const result = await resolveAvailability(makeQuery())
84
+ expect(result.byItem['product-1:variant-1'].state).toBe('in_stock')
85
+ })
86
+
87
+ it('honors an explicit selection via the injected module-config reader', async () => {
88
+ availabilityProviderRegistry.register(makeProvider(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID, { state: 'not_tracked' }))
89
+ availabilityProviderRegistry.register(makeProvider('wms', { state: 'in_stock' }))
90
+ const moduleConfig: AvailabilityModuleConfigReader = {
91
+ getValue: async <T,>() => 'catalog-only' as T,
92
+ }
93
+ const result = await resolveAvailability(makeQuery(), { moduleConfig })
94
+ expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
95
+ })
96
+
97
+ it('falls back to catalog-only when the selected id is not currently registered', async () => {
98
+ availabilityProviderRegistry.register(makeProvider(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID, { state: 'not_tracked' }))
99
+ const moduleConfig: AvailabilityModuleConfigReader = {
100
+ getValue: async <T,>() => 'some-unregistered-provider' as T,
101
+ }
102
+ const result = await resolveAvailability(makeQuery(), { moduleConfig })
103
+ expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
104
+ })
105
+
106
+ it('defaults to auto when the module-config reader is omitted', async () => {
107
+ availabilityProviderRegistry.register(makeProvider('wms', { state: 'in_stock' }))
108
+ const result = await resolveAvailability(makeQuery())
109
+ expect(result.byItem['product-1:variant-1'].state).toBe('in_stock')
110
+ })
111
+
112
+ it('forwards the calling request container to the selected provider', async () => {
113
+ const getAvailability = jest.fn(async () => ({ byItem: {} }))
114
+ availabilityProviderRegistry.register({ id: 'wms', getAvailability })
115
+ const container = { resolve: <T,>(): T => undefined as T }
116
+ await resolveAvailability(makeQuery(), { container })
117
+ expect(getAvailability).toHaveBeenCalledWith(makeQuery(), { container })
118
+ })
119
+
120
+ it('passes a null container when the caller supplies none', async () => {
121
+ const getAvailability = jest.fn(async () => ({ byItem: {} }))
122
+ availabilityProviderRegistry.register({ id: 'wms', getAvailability })
123
+ await resolveAvailability(makeQuery())
124
+ expect(getAvailability).toHaveBeenCalledWith(makeQuery(), { container: null })
125
+ })
126
+
127
+ it('throws when no provider is registered at all', async () => {
128
+ await expect(resolveAvailability(makeQuery())).rejects.toThrow()
129
+ })
130
+ })
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The built-in `catalog-only` fallback provider. Always registered so a
3
+ * `wms`-less *and* `availability`-less storefront stays fully functional.
4
+ *
5
+ * @see .ai/specs/2026-08-14-availability-contract.md §4.3
6
+ */
7
+
8
+ import { availabilityItemKey } from './types'
9
+ import type { AvailabilityItemResult, AvailabilityProviderContext, AvailabilityQuery, AvailabilityResult } from './types'
10
+ import { availabilityProviderRegistry, AVAILABILITY_CATALOG_ONLY_PROVIDER_ID } from './registry'
11
+
12
+ /** Per-item policy signal the optional lookup hook may report. */
13
+ export type CatalogOnlyPolicyOverride = {
14
+ /** `true` → the item is explicitly opted into stock tracking with no data source behind it (§4.3, out_of_stock). */
15
+ isStockManaged?: boolean
16
+ /** `false` → the policy row is inactive; treated as out_of_stock. */
17
+ isActive?: boolean
18
+ /** ISO-8601. In the future → `preorder`. */
19
+ preorderReleaseAt?: string | null
20
+ /** Requested quantities below this cannot be fulfilled. */
21
+ minOrderQuantity?: number | null
22
+ /** Requested quantities above this cannot be fulfilled — a cap independent of stock. */
23
+ maxOrderQuantity?: number | null
24
+ /** Pack size; requested quantities must be a multiple. */
25
+ quantityIncrement?: number | null
26
+ /** The `AvailabilityPolicy` row id that produced this override. */
27
+ policySourceId?: string | null
28
+ }
29
+
30
+ /**
31
+ * Optional soft lookup into `AvailabilityPolicy` when the `availability`
32
+ * module is installed. Keyed by `availabilityItemKey()`. Returning `null` or
33
+ * omitting an item from the map means "no policy row — module default".
34
+ */
35
+ export type CatalogOnlyPolicyLookup = (
36
+ query: AvailabilityQuery,
37
+ context?: AvailabilityProviderContext,
38
+ ) => Promise<Record<string, CatalogOnlyPolicyOverride | null | undefined>>
39
+
40
+ let policyLookup: CatalogOnlyPolicyLookup | null = null
41
+
42
+ /**
43
+ * Wired by the `availability` module's `di.ts` — never a static import from
44
+ * `packages/shared`. The lookup MUST resolve its dependencies from the
45
+ * per-call `context.container`, never from a container captured at
46
+ * registration time. Pass `null` to clear (test isolation).
47
+ */
48
+ export function setCatalogOnlyPolicyLookup(lookup: CatalogOnlyPolicyLookup | null): void {
49
+ policyLookup = lookup
50
+ }
51
+
52
+ function pureFallbackItem(): AvailabilityItemResult {
53
+ return {
54
+ state: 'not_tracked',
55
+ availableQuantity: null,
56
+ canFulfil: true,
57
+ leadTimeDays: null,
58
+ releaseAt: null,
59
+ isAuthoritative: true,
60
+ policySourceId: null,
61
+ }
62
+ }
63
+
64
+ function isWithinOrderQuantityRules(requested: number, override: CatalogOnlyPolicyOverride): boolean {
65
+ const { minOrderQuantity, maxOrderQuantity, quantityIncrement } = override
66
+ if (minOrderQuantity != null && requested < minOrderQuantity) return false
67
+ if (maxOrderQuantity != null && requested > maxOrderQuantity) return false
68
+ if (quantityIncrement != null && quantityIncrement > 0 && requested % quantityIncrement !== 0) return false
69
+ return true
70
+ }
71
+
72
+ /**
73
+ * Order-quantity rules are a cap independent of stock: a violation blocks
74
+ * fulfilment without changing the state the policy matrix produced.
75
+ */
76
+ function applyOverride(
77
+ override: CatalogOnlyPolicyOverride | null | undefined,
78
+ requestedQuantity: number,
79
+ ): AvailabilityItemResult {
80
+ const result = applyPolicyMatrix(override)
81
+ if (!override || isWithinOrderQuantityRules(requestedQuantity, override)) return result
82
+ return { ...result, canFulfil: false }
83
+ }
84
+
85
+ /**
86
+ * Applies decision 7's matrix (see PLAN.md § Key design decisions) for a
87
+ * resolved policy override on top of the pure fallback.
88
+ */
89
+ function applyPolicyMatrix(override: CatalogOnlyPolicyOverride | null | undefined): AvailabilityItemResult {
90
+ const base = pureFallbackItem()
91
+ if (!override) return base
92
+
93
+ const policySourceId = override.policySourceId ?? null
94
+
95
+ // An inactive row makes the item unpurchasable before any preorder date, exactly
96
+ // like the stock-tracked providers.
97
+ if (override.isActive === false) {
98
+ return { ...base, state: 'out_of_stock', canFulfil: false, policySourceId }
99
+ }
100
+
101
+ if (override.preorderReleaseAt) {
102
+ const releaseAt = new Date(override.preorderReleaseAt)
103
+ if (!Number.isNaN(releaseAt.getTime()) && releaseAt.getTime() > Date.now()) {
104
+ return {
105
+ ...base,
106
+ state: 'preorder',
107
+ canFulfil: true,
108
+ releaseAt: override.preorderReleaseAt,
109
+ policySourceId,
110
+ }
111
+ }
112
+ }
113
+
114
+ if (override.isStockManaged === true) {
115
+ // Opted into stock tracking with no data source to verify against — see
116
+ // decision 7: this is the "policy explicitly marks the item unavailable"
117
+ // case rather than a silent "in stock" claim (R5).
118
+ return { ...base, state: 'out_of_stock', canFulfil: false, policySourceId }
119
+ }
120
+
121
+ return { ...base, policySourceId }
122
+ }
123
+
124
+ async function getAvailability(
125
+ query: AvailabilityQuery,
126
+ context?: AvailabilityProviderContext,
127
+ ): Promise<AvailabilityResult> {
128
+ const byItem: AvailabilityResult['byItem'] = {}
129
+
130
+ let overrides: Record<string, CatalogOnlyPolicyOverride | null | undefined> = {}
131
+ if (policyLookup) {
132
+ try {
133
+ overrides = await policyLookup(query, context)
134
+ } catch {
135
+ // Degrade gracefully to the pure fallback — never let an optional
136
+ // policy lookup failure break the always-available fallback provider.
137
+ overrides = {}
138
+ }
139
+ }
140
+
141
+ for (const item of query.items) {
142
+ const key = availabilityItemKey(item)
143
+ byItem[key] = applyOverride(overrides[key], item.quantity)
144
+ }
145
+
146
+ return { byItem }
147
+ }
148
+
149
+ availabilityProviderRegistry.register({
150
+ id: AVAILABILITY_CATALOG_ONLY_PROVIDER_ID,
151
+ getAvailability,
152
+ })
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Public entrypoint for the Availability Contract's shared, dependency-free
3
+ * base — types, the provider registry, `resolveAvailability()`, and the
4
+ * built-in `catalog-only` fallback.
5
+ *
6
+ * Importing from this barrel (rather than `./registry` directly) guarantees
7
+ * the `catalog-only` provider is registered — it self-registers as an import
8
+ * side effect in `./catalogOnlyProvider`, which this file always pulls in.
9
+ *
10
+ * @see .ai/specs/2026-08-14-availability-contract.md §4.1a
11
+ */
12
+
13
+ export * from './types'
14
+ export * from './registry'
15
+ export { setCatalogOnlyPolicyLookup } from './catalogOnlyProvider'
16
+ export type { CatalogOnlyPolicyLookup, CatalogOnlyPolicyOverride } from './catalogOnlyProvider'
17
+
18
+ import './catalogOnlyProvider'
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Availability provider registry — module-level singleton that collects
3
+ * registered providers and dispatches `resolveAvailability()` to the
4
+ * per-tenant selected one, falling back safely to the built-in
5
+ * `catalog-only` provider.
6
+ *
7
+ * Mirrors `packages/shared/src/lib/ai/llm-provider-registry.ts`.
8
+ *
9
+ * @see ./types
10
+ * @see .ai/specs/2026-08-14-availability-contract.md §4.1a
11
+ */
12
+
13
+ import type {
14
+ AvailabilityDependencyResolver,
15
+ AvailabilityProvider,
16
+ AvailabilityQuery,
17
+ AvailabilityResult,
18
+ } from './types'
19
+
20
+ export const AVAILABILITY_CATALOG_ONLY_PROVIDER_ID = 'catalog-only'
21
+
22
+ /** Public interface of the registry. Exposed as a singleton via {@link availabilityProviderRegistry}. */
23
+ export interface AvailabilityProviderRegistry {
24
+ /**
25
+ * Registers or replaces a provider. Registration is idempotent — calling
26
+ * with the same id replaces the existing entry. Never order-dependent.
27
+ */
28
+ register(provider: AvailabilityProvider): void
29
+
30
+ /** Returns the provider with the given id, or null when not registered. */
31
+ get(id: string): AvailabilityProvider | null
32
+
33
+ /** Returns all registered providers in registration order. */
34
+ list(): readonly AvailabilityProvider[]
35
+
36
+ /** Removes all registered providers. Intended for test isolation. */
37
+ reset(): void
38
+ }
39
+
40
+ class AvailabilityProviderRegistryImpl implements AvailabilityProviderRegistry {
41
+ // Preserves registration order via Map iteration semantics.
42
+ private readonly providers = new Map<string, AvailabilityProvider>()
43
+
44
+ register(provider: AvailabilityProvider): void {
45
+ if (!provider || typeof provider.id !== 'string' || provider.id.length === 0) {
46
+ throw new Error('[internal] AvailabilityProviderRegistry: provider must have a non-empty id')
47
+ }
48
+ // Idempotent: replace existing by id.
49
+ this.providers.set(provider.id, provider)
50
+ }
51
+
52
+ get(id: string): AvailabilityProvider | null {
53
+ return this.providers.get(id) ?? null
54
+ }
55
+
56
+ list(): readonly AvailabilityProvider[] {
57
+ return Array.from(this.providers.values())
58
+ }
59
+
60
+ reset(): void {
61
+ this.providers.clear()
62
+ }
63
+ }
64
+
65
+ /** Process-level singleton instance of the registry. */
66
+ export const availabilityProviderRegistry: AvailabilityProviderRegistry = new AvailabilityProviderRegistryImpl()
67
+
68
+ export type AvailabilityProviderSelection = 'auto' | 'catalog-only' | (string & {})
69
+
70
+ /**
71
+ * Narrow port for per-tenant provider selection — declared locally so this
72
+ * package stays dependency-free. A caller with DI access (an `availability`
73
+ * module route, a future `ecommerce`/`cart`/`checkout` consumer) passes in
74
+ * its resolved `ModuleConfigService` instance; omitting it always resolves
75
+ * `'auto'`.
76
+ */
77
+ export interface AvailabilityModuleConfigReader {
78
+ getValue<T = unknown>(
79
+ moduleId: string,
80
+ name: string,
81
+ options?: { defaultValue?: T | null; scope?: { tenantId?: string | null; organizationId?: string | null } },
82
+ ): Promise<T | null>
83
+ }
84
+
85
+ export interface ResolveAvailabilityOptions {
86
+ /** `ModuleConfigService('availability', 'selectedProvider')` reader. See {@link AvailabilityModuleConfigReader}. */
87
+ moduleConfig?: AvailabilityModuleConfigReader
88
+ /**
89
+ * The calling request's DI container, forwarded to the selected provider
90
+ * as {@link AvailabilityProviderContext.container}. Omitting it makes a
91
+ * DI-backed provider build its own request container for the call.
92
+ */
93
+ container?: AvailabilityDependencyResolver | null
94
+ }
95
+
96
+ function resolveAutoProvider(): AvailabilityProvider | null {
97
+ // 'auto' = "the highest-precedence registered provider" — the first
98
+ // non-catalog-only registrant, in registration order.
99
+ for (const provider of availabilityProviderRegistry.list()) {
100
+ if (provider.id !== AVAILABILITY_CATALOG_ONLY_PROVIDER_ID) return provider
101
+ }
102
+ return availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)
103
+ }
104
+
105
+ /**
106
+ * The entry point every read-side consumer calls. Advisory only — see §4.1:
107
+ * `resolveAvailability()` is never a stock guarantee, `reserveAvailability()`
108
+ * (the `availability` module, not shipped by this contract) is.
109
+ */
110
+ export async function resolveAvailability(
111
+ query: AvailabilityQuery,
112
+ options?: ResolveAvailabilityOptions,
113
+ ): Promise<AvailabilityResult> {
114
+ const selection = options?.moduleConfig
115
+ ? await options.moduleConfig.getValue<AvailabilityProviderSelection>('availability', 'selectedProvider', {
116
+ defaultValue: 'auto',
117
+ scope: { tenantId: query.tenantId },
118
+ })
119
+ : 'auto'
120
+
121
+ const provider =
122
+ !selection || selection === 'auto'
123
+ ? resolveAutoProvider()
124
+ : availabilityProviderRegistry.get(selection) ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)
125
+
126
+ const resolved = provider ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)
127
+ if (!resolved) {
128
+ throw new Error('[internal] No availability provider registered, including the built-in catalog-only fallback')
129
+ }
130
+ return resolved.getAvailability(query, { container: options?.container ?? null })
131
+ }