@onesub/server 0.11.4 → 0.12.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 (71) hide show
  1. package/README.md +119 -2
  2. package/dist/__tests__/cache.test.d.ts +2 -0
  3. package/dist/__tests__/cache.test.d.ts.map +1 -0
  4. package/dist/__tests__/cache.test.js +34 -0
  5. package/dist/__tests__/cache.test.js.map +1 -0
  6. package/dist/__tests__/openapi.test.d.ts +2 -0
  7. package/dist/__tests__/openapi.test.d.ts.map +1 -0
  8. package/dist/__tests__/openapi.test.js +32 -0
  9. package/dist/__tests__/openapi.test.js.map +1 -0
  10. package/dist/__tests__/redis-store.test.d.ts +2 -0
  11. package/dist/__tests__/redis-store.test.d.ts.map +1 -0
  12. package/dist/__tests__/redis-store.test.js +117 -0
  13. package/dist/__tests__/redis-store.test.js.map +1 -0
  14. package/dist/__tests__/webhook-events.test.d.ts +2 -0
  15. package/dist/__tests__/webhook-events.test.d.ts.map +1 -0
  16. package/dist/__tests__/webhook-events.test.js +36 -0
  17. package/dist/__tests__/webhook-events.test.js.map +1 -0
  18. package/dist/__tests__/webhook-queue.test.d.ts +2 -0
  19. package/dist/__tests__/webhook-queue.test.d.ts.map +1 -0
  20. package/dist/__tests__/webhook-queue.test.js +26 -0
  21. package/dist/__tests__/webhook-queue.test.js.map +1 -0
  22. package/dist/cache.d.ts +32 -0
  23. package/dist/cache.d.ts.map +1 -0
  24. package/dist/cache.js +43 -0
  25. package/dist/cache.js.map +1 -0
  26. package/dist/index.cjs +3114 -0
  27. package/dist/index.cjs.map +1 -0
  28. package/dist/index.d.ts +34 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +3072 -118
  31. package/dist/index.js.map +1 -1
  32. package/dist/openapi.d.ts +41 -0
  33. package/dist/openapi.d.ts.map +1 -0
  34. package/dist/openapi.js +196 -0
  35. package/dist/openapi.js.map +1 -0
  36. package/dist/providers/apple.d.ts +1 -1
  37. package/dist/providers/apple.d.ts.map +1 -1
  38. package/dist/providers/apple.js +45 -34
  39. package/dist/providers/apple.js.map +1 -1
  40. package/dist/providers/google.d.ts.map +1 -1
  41. package/dist/providers/google.js +49 -20
  42. package/dist/providers/google.js.map +1 -1
  43. package/dist/routes/admin.d.ts +2 -1
  44. package/dist/routes/admin.d.ts.map +1 -1
  45. package/dist/routes/admin.js +37 -1
  46. package/dist/routes/admin.js.map +1 -1
  47. package/dist/routes/webhook.d.ts +2 -1
  48. package/dist/routes/webhook.d.ts.map +1 -1
  49. package/dist/routes/webhook.js +21 -1
  50. package/dist/routes/webhook.js.map +1 -1
  51. package/dist/stores/redis.d.ts +60 -0
  52. package/dist/stores/redis.d.ts.map +1 -0
  53. package/dist/stores/redis.js +255 -0
  54. package/dist/stores/redis.js.map +1 -0
  55. package/dist/stores/schema.d.ts.map +1 -1
  56. package/dist/stores/schema.js +10 -0
  57. package/dist/stores/schema.js.map +1 -1
  58. package/dist/tracing.d.ts +32 -0
  59. package/dist/tracing.d.ts.map +1 -0
  60. package/dist/tracing.js +74 -0
  61. package/dist/tracing.js.map +1 -0
  62. package/dist/webhook-events.d.ts +58 -0
  63. package/dist/webhook-events.d.ts.map +1 -0
  64. package/dist/webhook-events.js +57 -0
  65. package/dist/webhook-events.js.map +1 -0
  66. package/dist/webhook-queue.d.ts +92 -0
  67. package/dist/webhook-queue.d.ts.map +1 -0
  68. package/dist/webhook-queue.js +108 -0
  69. package/dist/webhook-queue.js.map +1 -0
  70. package/package.json +33 -4
  71. package/sql/schema.sql +10 -0
package/README.md CHANGED
@@ -9,11 +9,17 @@ npm install @onesub/server
9
9
  ## Requirements
10
10
 
11
11
  - Node.js **>= 20**
12
- - **Express** as a peer dependency — `^4.17.0 || ^5.0.0`. Install in your app:
12
+ - **Express** as a peer dependency — `^4.17.0 || ^5.0.0`:
13
13
  ```bash
14
14
  npm install @onesub/server express
15
15
  ```
16
- - PostgreSQL **12+** (optional, for production stores)
16
+ - Optional peer dependencies (install only what you use):
17
+ ```bash
18
+ npm install ioredis # Redis stores, cache, webhook idempotency
19
+ npm install bullmq # Durable webhook queue with dead-letter list
20
+ npm install pg # Postgres stores
21
+ npm install @opentelemetry/api # Distributed tracing
22
+ ```
17
23
 
18
24
  ## Quick start
19
25
 
@@ -59,6 +65,9 @@ app.listen(4100);
59
65
  | `DELETE /onesub/purchase/admin/:userId/:productId` | Wipe a non-consumable (requires `adminSecret`) |
60
66
  | `POST /onesub/purchase/admin/grant` | Manually grant a purchase (requires `adminSecret`) |
61
67
  | `POST /onesub/purchase/admin/transfer` | Reassign a `transactionId` to a new `userId` (requires `adminSecret`) |
68
+ | `GET /onesub/admin/webhook-deadletters` | List failed webhook jobs in DLQ (requires `adminSecret` + BullMQ) |
69
+ | `POST /onesub/admin/webhook-replay/:id` | Replay a dead-letter job (requires `adminSecret` + BullMQ) |
70
+ | `GET /openapi.json` | OpenAPI 3.1 spec (opt-in via `openapiHandler()`) |
62
71
 
63
72
  ## Lifecycle states (`0.4.0+`)
64
73
 
@@ -134,6 +143,114 @@ google: {
134
143
  }
135
144
  ```
136
145
 
146
+ ## Multi-instance deployments (`0.12.0+`)
147
+
148
+ By default every option uses in-process memory — zero config, zero infra. For multi-node clusters, swap in the Redis-backed equivalents:
149
+
150
+ ```ts
151
+ import Redis from 'ioredis';
152
+ import {
153
+ createOneSubMiddleware,
154
+ RedisSubscriptionStore,
155
+ RedisPurchaseStore,
156
+ RedisCacheAdapter,
157
+ RedisWebhookEventStore,
158
+ } from '@onesub/server';
159
+
160
+ const redis = new Redis(process.env.REDIS_URL!);
161
+
162
+ app.use(createOneSubMiddleware({
163
+ // ...apple / google / database config...
164
+ store: new RedisSubscriptionStore(redis),
165
+ purchaseStore: new RedisPurchaseStore(redis),
166
+ cache: new RedisCacheAdapter(redis), // share Apple JWT + Google OAuth tokens across nodes
167
+ webhookEventStore: new RedisWebhookEventStore(redis), // atomic SET NX dedup — one node handles each event
168
+ }));
169
+ ```
170
+
171
+ | Class | Replaces | What it does |
172
+ |-------|----------|--------------|
173
+ | `RedisSubscriptionStore` | `InMemorySubscriptionStore` | Sorted-set index, newest-first pagination |
174
+ | `RedisPurchaseStore` | `InMemoryPurchaseStore` | Cross-node purchase ownership |
175
+ | `RedisCacheAdapter` | `InMemoryCacheAdapter` | Shared Apple JWT / Google OAuth token cache |
176
+ | `RedisWebhookEventStore` | `InMemoryWebhookEventStore` | Atomic `SET NX` dedup (no get→set race) |
177
+
178
+ ### Webhook queue with durable retries
179
+
180
+ Replace the default in-process queue with BullMQ for decoupled retries and a dead-letter list:
181
+
182
+ ```ts
183
+ import { BullMQWebhookQueue } from '@onesub/server';
184
+ import Redis from 'ioredis';
185
+
186
+ const connection = new Redis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });
187
+ const webhookQueue = new BullMQWebhookQueue({ connection });
188
+
189
+ app.use(createOneSubMiddleware({
190
+ // ...
191
+ webhookQueue,
192
+ adminSecret: process.env.ADMIN_SECRET, // required for DLQ endpoints
193
+ }));
194
+ ```
195
+
196
+ The webhook route 200s immediately once the job is enqueued. The BullMQ worker retries up to 5× (exponential backoff, 1 s base) before moving the job to the dead-letter list. Inspect and replay via the admin endpoints above.
197
+
198
+ ## Cache adapter (`0.12.0+`)
199
+
200
+ The `cache` option controls how Apple JWT assertions and Google OAuth access tokens are cached between requests. Default is `InMemoryCacheAdapter` (process-local). Shared Redis cache prevents every cluster node from minting its own token:
201
+
202
+ ```ts
203
+ import { InMemoryCacheAdapter, RedisCacheAdapter } from '@onesub/server';
204
+
205
+ // single instance (default — no config needed)
206
+ cache: new InMemoryCacheAdapter()
207
+
208
+ // multi-instance
209
+ cache: new RedisCacheAdapter(redis)
210
+ cache: new RedisCacheAdapter(redis, 'myapp:cache:') // custom key prefix
211
+ ```
212
+
213
+ You can also pass any object implementing `CacheAdapter`:
214
+
215
+ ```ts
216
+ export interface CacheAdapter {
217
+ get<T>(key: string): Promise<T | null>;
218
+ set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
219
+ del(key: string): Promise<void>;
220
+ }
221
+ ```
222
+
223
+ ## OpenAPI (`0.12.0+`)
224
+
225
+ The full OpenAPI 3.1 document is exported as `ONESUB_OPENAPI`. Self-host it on your existing Express app:
226
+
227
+ ```ts
228
+ import { openapiHandler } from '@onesub/server';
229
+
230
+ app.get('/openapi.json', openapiHandler());
231
+ ```
232
+
233
+ Or import the spec directly for client generation:
234
+
235
+ ```ts
236
+ import { ONESUB_OPENAPI } from '@onesub/server';
237
+ // use with openapi-typescript, swagger-ui-express, etc.
238
+ ```
239
+
240
+ ## OpenTelemetry tracing (`0.12.0+`)
241
+
242
+ Install `@opentelemetry/api` alongside an OTel SDK and spans appear automatically for receipt validation and webhook processing. The helper is zero-overhead when the package is absent — no `require` error, no performance hit.
243
+
244
+ You can wrap your own code in the same tracer:
245
+
246
+ ```ts
247
+ import { withSpan } from '@onesub/server';
248
+
249
+ const result = await withSpan('my-operation', { 'user.id': userId }, async () => {
250
+ return await doWork();
251
+ });
252
+ ```
253
+
137
254
  ## Schema
138
255
 
139
256
  Canonical Postgres DDL shipped at [`sql/schema.sql`](./sql/schema.sql). Apply with `psql -f` or let `store.initSchema()` run it for you on startup.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=cache.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/cache.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { InMemoryCacheAdapter } from '../cache.js';
3
+ describe('InMemoryCacheAdapter', () => {
4
+ it('returns null for missing keys', async () => {
5
+ const cache = new InMemoryCacheAdapter();
6
+ expect(await cache.get('absent')).toBeNull();
7
+ });
8
+ it('round-trips a value', async () => {
9
+ const cache = new InMemoryCacheAdapter();
10
+ await cache.set('k', { token: 'abc', n: 1 });
11
+ expect(await cache.get('k')).toEqual({ token: 'abc', n: 1 });
12
+ });
13
+ it('expires entries after the TTL elapses', async () => {
14
+ const cache = new InMemoryCacheAdapter();
15
+ await cache.set('k', 'v', 1);
16
+ expect(await cache.get('k')).toBe('v');
17
+ // Force expiry by moving the clock forward via a sleep substitute.
18
+ await new Promise((resolve) => setTimeout(resolve, 1100));
19
+ expect(await cache.get('k')).toBeNull();
20
+ });
21
+ it('treats ttl=0 / undefined as no-expiry', async () => {
22
+ const cache = new InMemoryCacheAdapter();
23
+ await cache.set('forever', 'v');
24
+ await new Promise((resolve) => setTimeout(resolve, 50));
25
+ expect(await cache.get('forever')).toBe('v');
26
+ });
27
+ it('del removes a key', async () => {
28
+ const cache = new InMemoryCacheAdapter();
29
+ await cache.set('k', 'v');
30
+ await cache.del('k');
31
+ expect(await cache.get('k')).toBeNull();
32
+ });
33
+ });
34
+ //# sourceMappingURL=cache.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.test.js","sourceRoot":"","sources":["../../src/__tests__/cache.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAEnD,QAAQ,CAAC,sBAAsB,EAAE,GAAG,EAAE;IACpC,EAAE,CAAC,+BAA+B,EAAE,KAAK,IAAI,EAAE;QAC7C,MAAM,KAAK,GAAG,IAAI,oBAAoB,EAAE,CAAC;QACzC,MAAM,CAAC,MAAM,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qBAAqB,EAAE,KAAK,IAAI,EAAE;QACnC,MAAM,KAAK,GAAG,IAAI,oBAAoB,EAAE,CAAC;QACzC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;QAC7C,MAAM,CAAC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;IAC/D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uCAAuC,EAAE,KAAK,IAAI,EAAE;QACrD,MAAM,KAAK,GAAG,IAAI,oBAAoB,EAAE,CAAC;QACzC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC;QAC7B,MAAM,CAAC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACvC,mEAAmE;QACnE,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC;QAC1D,MAAM,CAAC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC1C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uCAAuC,EAAE,KAAK,IAAI,EAAE;QACrD,MAAM,KAAK,GAAG,IAAI,oBAAoB,EAAE,CAAC;QACzC,MAAM,KAAK,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;QAChC,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;QACxD,MAAM,CAAC,MAAM,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mBAAmB,EAAE,KAAK,IAAI,EAAE;QACjC,MAAM,KAAK,GAAG,IAAI,oBAAoB,EAAE,CAAC;QACzC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QAC1B,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACrB,MAAM,CAAC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC1C,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=openapi.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openapi.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/openapi.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ONESUB_OPENAPI } from '../openapi.js';
3
+ import { ROUTES } from '@onesub/shared';
4
+ describe('OpenAPI document', () => {
5
+ it('declares every public route', () => {
6
+ const declared = Object.keys(ONESUB_OPENAPI.paths);
7
+ // Spot-check the main public routes — the spec is hand-written, so this
8
+ // is the parity guard. Routes added to ROUTES that aren't in the spec
9
+ // should fail this test.
10
+ for (const path of [
11
+ ROUTES.VALIDATE,
12
+ ROUTES.STATUS,
13
+ ROUTES.WEBHOOK_APPLE,
14
+ ROUTES.WEBHOOK_GOOGLE,
15
+ ROUTES.ENTITLEMENTS,
16
+ ROUTES.ADMIN_SUBSCRIPTIONS,
17
+ ]) {
18
+ expect(declared).toContain(path);
19
+ }
20
+ });
21
+ it('uses 3.1.0', () => {
22
+ expect(ONESUB_OPENAPI.openapi).toBe('3.1.0');
23
+ });
24
+ it('declares the AdminSecret security scheme', () => {
25
+ expect(ONESUB_OPENAPI.components.securitySchemes.AdminSecret).toMatchObject({
26
+ type: 'apiKey',
27
+ in: 'header',
28
+ name: 'X-Admin-Secret',
29
+ });
30
+ });
31
+ });
32
+ //# sourceMappingURL=openapi.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openapi.test.js","sourceRoot":"","sources":["../../src/__tests__/openapi.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAExC,QAAQ,CAAC,kBAAkB,EAAE,GAAG,EAAE;IAChC,EAAE,CAAC,6BAA6B,EAAE,GAAG,EAAE;QACrC,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC;QACnD,wEAAwE;QACxE,sEAAsE;QACtE,yBAAyB;QACzB,KAAK,MAAM,IAAI,IAAI;YACjB,MAAM,CAAC,QAAQ;YACf,MAAM,CAAC,MAAM;YACb,MAAM,CAAC,aAAa;YACpB,MAAM,CAAC,cAAc;YACrB,MAAM,CAAC,YAAY;YACnB,MAAM,CAAC,mBAAmB;SAC3B,EAAE,CAAC;YACF,MAAM,CAAC,QAAQ,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QACnC,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,YAAY,EAAE,GAAG,EAAE;QACpB,MAAM,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0CAA0C,EAAE,GAAG,EAAE;QAClD,MAAM,CAAC,cAAc,CAAC,UAAU,CAAC,eAAe,CAAC,WAAW,CAAC,CAAC,aAAa,CAAC;YAC1E,IAAI,EAAE,QAAQ;YACd,EAAE,EAAE,QAAQ;YACZ,IAAI,EAAE,gBAAgB;SACvB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=redis-store.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-store.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/redis-store.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it, beforeEach } from 'vitest';
2
+ import IORedisMock from 'ioredis-mock';
3
+ import { RedisSubscriptionStore, RedisPurchaseStore, RedisCacheAdapter } from '../stores/redis.js';
4
+ import { SUBSCRIPTION_STATUS } from '@onesub/shared';
5
+ function makeRedis() {
6
+ // ioredis-mock's surface matches ioredis enough for our store's methods
7
+ // (set/get/zadd/zrevrange/sadd/smembers/multi). Cast to keep TS happy.
8
+ return new IORedisMock();
9
+ }
10
+ const baseSub = {
11
+ originalTransactionId: 'tx-1',
12
+ userId: 'alice',
13
+ productId: 'pro_monthly',
14
+ platform: 'apple',
15
+ status: SUBSCRIPTION_STATUS.ACTIVE,
16
+ expiresAt: '2030-01-01T00:00:00.000Z',
17
+ purchasedAt: '2025-01-01T00:00:00.000Z',
18
+ willRenew: true,
19
+ };
20
+ describe('RedisSubscriptionStore', () => {
21
+ let redis;
22
+ let store;
23
+ beforeEach(() => {
24
+ redis = makeRedis();
25
+ store = new RedisSubscriptionStore(redis);
26
+ });
27
+ it('save + getByTransactionId round-trip', async () => {
28
+ await store.save(baseSub);
29
+ const fetched = await store.getByTransactionId('tx-1');
30
+ expect(fetched).toEqual(baseSub);
31
+ });
32
+ it('getByUserId returns the most-recent record', async () => {
33
+ await store.save({ ...baseSub, originalTransactionId: 'tx-1', expiresAt: '2026-01-01T00:00:00.000Z' });
34
+ // Force a 1ms gap so the second save lands with a higher zadd score.
35
+ await new Promise((resolve) => setTimeout(resolve, 5));
36
+ await store.save({ ...baseSub, originalTransactionId: 'tx-2', expiresAt: '2027-01-01T00:00:00.000Z' });
37
+ const latest = await store.getByUserId('alice');
38
+ expect(latest?.originalTransactionId).toBe('tx-2');
39
+ });
40
+ it('getAllByUserId returns every tx newest-first', async () => {
41
+ await store.save({ ...baseSub, originalTransactionId: 'tx-1' });
42
+ await new Promise((resolve) => setTimeout(resolve, 5));
43
+ await store.save({ ...baseSub, originalTransactionId: 'tx-2' });
44
+ const all = await store.getAllByUserId('alice');
45
+ expect(all.map((s) => s.originalTransactionId)).toEqual(['tx-2', 'tx-1']);
46
+ });
47
+ it('listAll surfaces subs across users', async () => {
48
+ await store.save({ ...baseSub, originalTransactionId: 'tx-a', userId: 'alice' });
49
+ await store.save({ ...baseSub, originalTransactionId: 'tx-b', userId: 'bob' });
50
+ const all = await store.listAll();
51
+ expect(all).toHaveLength(2);
52
+ });
53
+ it('listFiltered respects userId + status filters', async () => {
54
+ await store.save({ ...baseSub, originalTransactionId: 'tx-a', userId: 'alice' });
55
+ await store.save({ ...baseSub, originalTransactionId: 'tx-b', userId: 'alice', status: SUBSCRIPTION_STATUS.EXPIRED });
56
+ const result = await store.listFiltered({ userId: 'alice', status: SUBSCRIPTION_STATUS.ACTIVE });
57
+ expect(result.total).toBe(1);
58
+ expect(result.items[0].originalTransactionId).toBe('tx-a');
59
+ });
60
+ });
61
+ const basePurchase = {
62
+ transactionId: 'p-1',
63
+ userId: 'alice',
64
+ productId: 'remove_ads',
65
+ platform: 'apple',
66
+ type: 'non_consumable',
67
+ quantity: 1,
68
+ purchasedAt: '2025-01-01T00:00:00.000Z',
69
+ };
70
+ describe('RedisPurchaseStore', () => {
71
+ let redis;
72
+ let store;
73
+ beforeEach(() => {
74
+ redis = makeRedis();
75
+ store = new RedisPurchaseStore(redis);
76
+ });
77
+ it('savePurchase + getPurchaseByTransactionId round-trip', async () => {
78
+ await store.savePurchase(basePurchase);
79
+ const fetched = await store.getPurchaseByTransactionId('p-1');
80
+ expect(fetched).toEqual(basePurchase);
81
+ });
82
+ it('refuses cross-user reuse of the same transactionId', async () => {
83
+ await store.savePurchase(basePurchase);
84
+ await expect(store.savePurchase({ ...basePurchase, userId: 'mallory' })).rejects.toThrow(/TRANSACTION_BELONGS_TO_OTHER_USER/);
85
+ });
86
+ it('hasPurchased detects a non-consumable buy', async () => {
87
+ await store.savePurchase(basePurchase);
88
+ expect(await store.hasPurchased('alice', 'remove_ads')).toBe(true);
89
+ expect(await store.hasPurchased('alice', 'other_product')).toBe(false);
90
+ });
91
+ it('reassignPurchase moves ownership without losing the row', async () => {
92
+ await store.savePurchase(basePurchase);
93
+ expect(await store.reassignPurchase('p-1', 'bob')).toBe(true);
94
+ const fetched = await store.getPurchaseByTransactionId('p-1');
95
+ expect(fetched?.userId).toBe('bob');
96
+ expect(await store.hasPurchased('alice', 'remove_ads')).toBe(false);
97
+ expect(await store.hasPurchased('bob', 'remove_ads')).toBe(true);
98
+ });
99
+ it('deletePurchaseByTransactionId removes everywhere', async () => {
100
+ await store.savePurchase(basePurchase);
101
+ expect(await store.deletePurchaseByTransactionId('p-1')).toBe(true);
102
+ expect(await store.getPurchaseByTransactionId('p-1')).toBeNull();
103
+ expect(await store.hasPurchased('alice', 'remove_ads')).toBe(false);
104
+ });
105
+ });
106
+ describe('RedisCacheAdapter', () => {
107
+ it('round-trips with TTL', async () => {
108
+ const cache = new RedisCacheAdapter(makeRedis());
109
+ await cache.set('k', { token: 'abc' }, 60);
110
+ expect(await cache.get('k')).toEqual({ token: 'abc' });
111
+ });
112
+ it('returns null for missing keys', async () => {
113
+ const cache = new RedisCacheAdapter(makeRedis());
114
+ expect(await cache.get('absent')).toBeNull();
115
+ });
116
+ });
117
+ //# sourceMappingURL=redis-store.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-store.test.js","sourceRoot":"","sources":["../../src/__tests__/redis-store.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=webhook-events.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook-events.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/webhook-events.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CacheWebhookEventStore, InMemoryWebhookEventStore } from '../webhook-events.js';
3
+ import { InMemoryCacheAdapter } from '../cache.js';
4
+ describe('InMemoryWebhookEventStore', () => {
5
+ it('returns true the first time, false on retry', async () => {
6
+ const store = new InMemoryWebhookEventStore();
7
+ expect(await store.markIfNew('apple', 'uuid-1')).toBe(true);
8
+ expect(await store.markIfNew('apple', 'uuid-1')).toBe(false);
9
+ });
10
+ it('keys by provider so apple/google ids never collide', async () => {
11
+ const store = new InMemoryWebhookEventStore();
12
+ expect(await store.markIfNew('apple', 'shared')).toBe(true);
13
+ expect(await store.markIfNew('google', 'shared')).toBe(true);
14
+ });
15
+ it('expires entries after the TTL', async () => {
16
+ const store = new InMemoryWebhookEventStore(1); // 1-second TTL
17
+ expect(await store.markIfNew('apple', 'uuid-1')).toBe(true);
18
+ await new Promise((resolve) => setTimeout(resolve, 1100));
19
+ // Re-eligible after expiry — Apple/Google retries beyond the window are
20
+ // effectively new events, which is the intended behavior.
21
+ expect(await store.markIfNew('apple', 'uuid-1')).toBe(true);
22
+ });
23
+ });
24
+ describe('CacheWebhookEventStore', () => {
25
+ it('uses any CacheAdapter for cluster-shared dedupe', async () => {
26
+ const cache = new InMemoryCacheAdapter();
27
+ const store = new CacheWebhookEventStore(cache);
28
+ expect(await store.markIfNew('google', 'msg-1')).toBe(true);
29
+ expect(await store.markIfNew('google', 'msg-1')).toBe(false);
30
+ // A second store backed by the same cache sees the prior write — this is
31
+ // the multi-instance idempotency guarantee we ship.
32
+ const store2 = new CacheWebhookEventStore(cache);
33
+ expect(await store2.markIfNew('google', 'msg-1')).toBe(false);
34
+ });
35
+ });
36
+ //# sourceMappingURL=webhook-events.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook-events.test.js","sourceRoot":"","sources":["../../src/__tests__/webhook-events.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,sBAAsB,EAAE,yBAAyB,EAAE,MAAM,sBAAsB,CAAC;AACzF,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAEnD,QAAQ,CAAC,2BAA2B,EAAE,GAAG,EAAE;IACzC,EAAE,CAAC,6CAA6C,EAAE,KAAK,IAAI,EAAE;QAC3D,MAAM,KAAK,GAAG,IAAI,yBAAyB,EAAE,CAAC;QAC9C,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC5D,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAC/D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oDAAoD,EAAE,KAAK,IAAI,EAAE;QAClE,MAAM,KAAK,GAAG,IAAI,yBAAyB,EAAE,CAAC;QAC9C,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC5D,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC/D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+BAA+B,EAAE,KAAK,IAAI,EAAE;QAC7C,MAAM,KAAK,GAAG,IAAI,yBAAyB,CAAC,CAAC,CAAC,CAAC,CAAC,eAAe;QAC/D,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC5D,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC;QAC1D,wEAAwE;QACxE,0DAA0D;QAC1D,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC9D,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACtC,EAAE,CAAC,iDAAiD,EAAE,KAAK,IAAI,EAAE;QAC/D,MAAM,KAAK,GAAG,IAAI,oBAAoB,EAAE,CAAC;QACzC,MAAM,KAAK,GAAG,IAAI,sBAAsB,CAAC,KAAK,CAAC,CAAC;QAChD,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC5D,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAE7D,yEAAyE;QACzE,oDAAoD;QACpD,MAAM,MAAM,GAAG,IAAI,sBAAsB,CAAC,KAAK,CAAC,CAAC;QACjD,MAAM,CAAC,MAAM,MAAM,CAAC,SAAS,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAChE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=webhook-queue.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook-queue.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/webhook-queue.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { InProcessWebhookQueue } from '../webhook-queue.js';
3
+ describe('InProcessWebhookQueue', () => {
4
+ it('runs the handler synchronously inside enqueue', async () => {
5
+ const queue = new InProcessWebhookQueue();
6
+ const seen = [];
7
+ queue.setHandler(async (job) => {
8
+ seen.push(job);
9
+ });
10
+ await queue.enqueue({ provider: 'apple', eventId: 'uuid-1', payload: { foo: 'bar' } });
11
+ expect(seen).toHaveLength(1);
12
+ expect(seen[0].eventId).toBe('uuid-1');
13
+ });
14
+ it('throws if no handler has been registered', async () => {
15
+ const queue = new InProcessWebhookQueue();
16
+ await expect(queue.enqueue({ provider: 'apple', eventId: 'uuid-1', payload: {} })).rejects.toThrow(/no handler/);
17
+ });
18
+ it('propagates handler errors to enqueue', async () => {
19
+ const queue = new InProcessWebhookQueue();
20
+ queue.setHandler(async () => {
21
+ throw new Error('downstream failed');
22
+ });
23
+ await expect(queue.enqueue({ provider: 'apple', eventId: 'uuid-1', payload: {} })).rejects.toThrow(/downstream failed/);
24
+ });
25
+ });
26
+ //# sourceMappingURL=webhook-queue.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook-queue.test.js","sourceRoot":"","sources":["../../src/__tests__/webhook-queue.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAG5D,QAAQ,CAAC,uBAAuB,EAAE,GAAG,EAAE;IACrC,EAAE,CAAC,+CAA+C,EAAE,KAAK,IAAI,EAAE;QAC7D,MAAM,KAAK,GAAG,IAAI,qBAAqB,EAAE,CAAC;QAC1C,MAAM,IAAI,GAAiB,EAAE,CAAC;QAC9B,KAAK,CAAC,UAAU,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE;YAC7B,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACjB,CAAC,CAAC,CAAC;QACH,MAAM,KAAK,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;QACvF,MAAM,CAAC,IAAI,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QAC7B,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACzC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0CAA0C,EAAE,KAAK,IAAI,EAAE;QACxD,MAAM,KAAK,GAAG,IAAI,qBAAqB,EAAE,CAAC;QAC1C,MAAM,MAAM,CACV,KAAK,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CACrE,CAAC,OAAO,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;IAClC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sCAAsC,EAAE,KAAK,IAAI,EAAE;QACpD,MAAM,KAAK,GAAG,IAAI,qBAAqB,EAAE,CAAC;QAC1C,KAAK,CAAC,UAAU,CAAC,KAAK,IAAI,EAAE;YAC1B,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAAC;QACvC,CAAC,CAAC,CAAC;QACH,MAAM,MAAM,CACV,KAAK,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CACrE,CAAC,OAAO,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACzC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Pluggable cache adapter.
3
+ *
4
+ * Used internally by the Apple JWT minter, Google OAuth token minter, and any
5
+ * future per-process cache. The default `InMemoryCacheAdapter` matches the
6
+ * pre-adapter behavior — module-level state, lost on restart, not shared
7
+ * between cluster nodes.
8
+ *
9
+ * For multi-instance deployments, pass a `RedisCacheAdapter` in
10
+ * `OneSubMiddlewareConfig.cache` so all nodes share the same JWKS / token
11
+ * cache and avoid every node re-minting on its own schedule.
12
+ */
13
+ export interface CacheAdapter {
14
+ get<T = unknown>(key: string): Promise<T | null>;
15
+ /** TTL in seconds. Pass 0 / undefined for no expiry. */
16
+ set<T = unknown>(key: string, value: T, ttlSeconds?: number): Promise<void>;
17
+ del(key: string): Promise<void>;
18
+ }
19
+ /**
20
+ * Process-local in-memory cache. Default for single-instance deployments.
21
+ */
22
+ export declare class InMemoryCacheAdapter implements CacheAdapter {
23
+ private readonly entries;
24
+ get<T>(key: string): Promise<T | null>;
25
+ set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
26
+ del(key: string): Promise<void>;
27
+ /** Test helper — clear everything. Not part of the public CacheAdapter contract. */
28
+ clear(): void;
29
+ }
30
+ export declare function getDefaultCache(): CacheAdapter;
31
+ export declare function setDefaultCache(adapter: CacheAdapter): void;
32
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,YAAY;IAC3B,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACjD,wDAAwD;IACxD,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5E,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjC;AAQD;;GAEG;AACH,qBAAa,oBAAqB,YAAW,YAAY;IACvD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4B;IAE9C,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAUtC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKjE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrC,oFAAoF;IACpF,KAAK,IAAI,IAAI;CAGd;AAQD,wBAAgB,eAAe,IAAI,YAAY,CAE9C;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAE3D"}
package/dist/cache.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Process-local in-memory cache. Default for single-instance deployments.
3
+ */
4
+ export class InMemoryCacheAdapter {
5
+ entries = new Map();
6
+ async get(key) {
7
+ const entry = this.entries.get(key);
8
+ if (!entry)
9
+ return null;
10
+ if (entry.expiresAt !== 0 && entry.expiresAt < Date.now()) {
11
+ this.entries.delete(key);
12
+ return null;
13
+ }
14
+ return entry.value;
15
+ }
16
+ async set(key, value, ttlSeconds) {
17
+ const expiresAt = ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : 0;
18
+ this.entries.set(key, { value, expiresAt });
19
+ }
20
+ async del(key) {
21
+ this.entries.delete(key);
22
+ }
23
+ /** Test helper — clear everything. Not part of the public CacheAdapter contract. */
24
+ clear() {
25
+ this.entries.clear();
26
+ }
27
+ }
28
+ /**
29
+ * Module-level singleton used by providers when no per-call adapter is
30
+ * supplied. Keeps the existing single-instance behavior with zero config.
31
+ */
32
+ let defaultAdapter = new InMemoryCacheAdapter();
33
+ export function getDefaultCache() {
34
+ return defaultAdapter;
35
+ }
36
+ export function setDefaultCache(adapter) {
37
+ defaultAdapter = adapter;
38
+ }
39
+ /** Test helper — reset the default adapter to a fresh in-memory one. */
40
+ export function __resetDefaultCacheForTests() {
41
+ defaultAdapter = new InMemoryCacheAdapter();
42
+ }
43
+ //# sourceMappingURL=cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.js","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAyBA;;GAEG;AACH,MAAM,OAAO,oBAAoB;IACd,OAAO,GAAG,IAAI,GAAG,EAAiB,CAAC;IAEpD,KAAK,CAAC,GAAG,CAAI,GAAW;QACtB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACpC,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAC;QACxB,IAAI,KAAK,CAAC,SAAS,KAAK,CAAC,IAAI,KAAK,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;YAC1D,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACzB,OAAO,IAAI,CAAC;QACd,CAAC;QACD,OAAO,KAAK,CAAC,KAAU,CAAC;IAC1B,CAAC;IAED,KAAK,CAAC,GAAG,CAAI,GAAW,EAAE,KAAQ,EAAE,UAAmB;QACrD,MAAM,SAAS,GAAG,UAAU,IAAI,UAAU,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,UAAU,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACpF,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;IAC9C,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC;IAED,oFAAoF;IACpF,KAAK;QACH,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;IACvB,CAAC;CACF;AAED;;;GAGG;AACH,IAAI,cAAc,GAAiB,IAAI,oBAAoB,EAAE,CAAC;AAE9D,MAAM,UAAU,eAAe;IAC7B,OAAO,cAAc,CAAC;AACxB,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,OAAqB;IACnD,cAAc,GAAG,OAAO,CAAC;AAC3B,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,2BAA2B;IACzC,cAAc,GAAG,IAAI,oBAAoB,EAAE,CAAC;AAC9C,CAAC"}