@onesub/server 0.19.0 → 0.21.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 (39) hide show
  1. package/README.md +1 -0
  2. package/dist/__tests__/apple-jws-cache.test.d.ts +16 -0
  3. package/dist/__tests__/apple-jws-cache.test.d.ts.map +1 -0
  4. package/dist/__tests__/metrics-aggregate.test.d.ts +10 -0
  5. package/dist/__tests__/metrics-aggregate.test.d.ts.map +1 -0
  6. package/dist/__tests__/metrics-cache.test.d.ts +11 -0
  7. package/dist/__tests__/metrics-cache.test.d.ts.map +1 -0
  8. package/dist/__tests__/purchase-product-lookup.test.d.ts +17 -0
  9. package/dist/__tests__/purchase-product-lookup.test.d.ts.map +1 -0
  10. package/dist/__tests__/verified-key-cache.test.d.ts +18 -0
  11. package/dist/__tests__/verified-key-cache.test.d.ts.map +1 -0
  12. package/dist/http.d.ts.map +1 -1
  13. package/dist/index.cjs +341 -199
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +342 -201
  18. package/dist/index.js.map +1 -1
  19. package/dist/metrics-aggregate.d.ts +82 -0
  20. package/dist/metrics-aggregate.d.ts.map +1 -0
  21. package/dist/metrics-cache.d.ts +20 -0
  22. package/dist/metrics-cache.d.ts.map +1 -0
  23. package/dist/providers/apple.d.ts +2 -0
  24. package/dist/providers/apple.d.ts.map +1 -1
  25. package/dist/providers/verified-key-cache.d.ts +75 -0
  26. package/dist/providers/verified-key-cache.d.ts.map +1 -0
  27. package/dist/routes/admin.d.ts.map +1 -1
  28. package/dist/routes/entitlements.d.ts +18 -1
  29. package/dist/routes/entitlements.d.ts.map +1 -1
  30. package/dist/routes/metrics.d.ts +8 -3
  31. package/dist/routes/metrics.d.ts.map +1 -1
  32. package/dist/routes/purchase.d.ts.map +1 -1
  33. package/dist/store.d.ts +25 -2
  34. package/dist/store.d.ts.map +1 -1
  35. package/dist/stores/postgres.d.ts +10 -0
  36. package/dist/stores/postgres.d.ts.map +1 -1
  37. package/dist/stores/redis.d.ts +12 -0
  38. package/dist/stores/redis.d.ts.map +1 -1
  39. package/package.json +2 -2
package/dist/index.cjs CHANGED
@@ -91,11 +91,17 @@ var InMemoryPurchaseStore = class {
91
91
  }
92
92
  this.byTransactionId.set(purchase.transactionId, purchase);
93
93
  const list = this.byUserId.get(purchase.userId) ?? [];
94
- list.push(purchase);
94
+ const at = Date.parse(purchase.purchasedAt);
95
+ const idx = list.findIndex((p) => Date.parse(p.purchasedAt) < at);
96
+ if (idx === -1) list.push(purchase);
97
+ else list.splice(idx, 0, purchase);
95
98
  this.byUserId.set(purchase.userId, list);
96
99
  }
97
100
  async getPurchasesByUserId(userId) {
98
- return this.byUserId.get(userId) ?? [];
101
+ return [...this.byUserId.get(userId) ?? []];
102
+ }
103
+ async getPurchasesForProduct(userId, productId) {
104
+ return (this.byUserId.get(userId) ?? []).filter((p) => p.productId === productId);
99
105
  }
100
106
  async getPurchaseByTransactionId(txId) {
101
107
  return this.byTransactionId.get(txId) ?? null;
@@ -180,9 +186,13 @@ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
180
186
  async function fetchWithTimeout(input, init, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
181
187
  const controller = new AbortController();
182
188
  const callerSignal = init?.signal;
189
+ let onCallerAbort;
183
190
  if (callerSignal) {
184
191
  if (callerSignal.aborted) controller.abort(callerSignal.reason);
185
- else callerSignal.addEventListener("abort", () => controller.abort(callerSignal.reason), { once: true });
192
+ else {
193
+ onCallerAbort = () => controller.abort(callerSignal.reason);
194
+ callerSignal.addEventListener("abort", onCallerAbort, { once: true });
195
+ }
186
196
  }
187
197
  const timer = setTimeout(() => {
188
198
  controller.abort(new Error(`[onesub] fetch timed out after ${timeoutMs}ms`));
@@ -191,6 +201,7 @@ async function fetchWithTimeout(input, init, timeoutMs = DEFAULT_FETCH_TIMEOUT_M
191
201
  return await fetch(input, { ...init, signal: controller.signal });
192
202
  } finally {
193
203
  clearTimeout(timer);
204
+ if (onCallerAbort) callerSignal?.removeEventListener("abort", onCallerAbort);
194
205
  }
195
206
  }
196
207
 
@@ -243,6 +254,70 @@ function getDefaultCache() {
243
254
  function setDefaultCache(adapter) {
244
255
  defaultAdapter = adapter;
245
256
  }
257
+ var DEFAULT_MAX_ENTRIES = 32;
258
+ var DEFAULT_MAX_TTL_MS = 60 * 60 * 1e3;
259
+ var VerifiedKeyCache = class {
260
+ entries = /* @__PURE__ */ new Map();
261
+ maxEntries;
262
+ maxTtlMs;
263
+ now;
264
+ constructor(opts = {}) {
265
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
266
+ this.maxTtlMs = opts.maxTtlMs ?? DEFAULT_MAX_TTL_MS;
267
+ this.now = opts.now ?? Date.now;
268
+ }
269
+ /**
270
+ * Return the key for `parts`, verifying via `produce` only on a miss.
271
+ *
272
+ * `parts` must contain every input the verification depends on — for Apple
273
+ * that is the JWS `alg` plus the full x5c chain. Anything left out would let
274
+ * one input's result be served for a different input.
275
+ */
276
+ async resolve(parts, produce) {
277
+ const cacheKey = digest(parts);
278
+ const now = this.now();
279
+ const hit = this.entries.get(cacheKey);
280
+ if (hit) {
281
+ if (hit.expiresAt > now) return hit.key;
282
+ this.entries.delete(cacheKey);
283
+ }
284
+ const verified = await produce();
285
+ const expiresAt = Math.min(now + this.maxTtlMs, verified.notAfter);
286
+ if (expiresAt > now) {
287
+ this.prune(now);
288
+ this.entries.set(cacheKey, { key: verified.key, expiresAt });
289
+ }
290
+ return verified.key;
291
+ }
292
+ /** Entries currently held, including any not yet pruned. Test/diagnostic use. */
293
+ get size() {
294
+ return this.entries.size;
295
+ }
296
+ /** Drop everything. Test/diagnostic use. */
297
+ clear() {
298
+ this.entries.clear();
299
+ }
300
+ /** Make room: expired entries first, then the oldest insertions. */
301
+ prune(now) {
302
+ for (const [key, entry] of this.entries) {
303
+ if (entry.expiresAt <= now) this.entries.delete(key);
304
+ }
305
+ while (this.entries.size >= this.maxEntries) {
306
+ const oldest = this.entries.keys().next();
307
+ if (oldest.done) break;
308
+ this.entries.delete(oldest.value);
309
+ }
310
+ }
311
+ };
312
+ function digest(parts) {
313
+ const hash = crypto.createHash("sha256");
314
+ for (const part of parts) {
315
+ hash.update(String(part.length));
316
+ hash.update(":");
317
+ hash.update(part);
318
+ }
319
+ return hash.digest("base64");
320
+ }
246
321
  function classifyMockReceipt(receipt) {
247
322
  if (receipt.startsWith(shared.MOCK_RECEIPT_PREFIX.NETWORK_ERROR)) return { kind: "network-error" };
248
323
  if (receipt.startsWith(shared.MOCK_RECEIPT_PREFIX.REVOKED)) return { kind: "revoked" };
@@ -262,8 +337,8 @@ function outcomePasses(outcome, tag) {
262
337
  return false;
263
338
  }
264
339
  function deterministicTransactionId(prefix, receipt) {
265
- const digest = crypto.createHash("sha256").update(receipt).digest("hex").slice(0, 24);
266
- return `${prefix}_${digest}`;
340
+ const digest2 = crypto.createHash("sha256").update(receipt).digest("hex").slice(0, 24);
341
+ return `${prefix}_${digest2}`;
267
342
  }
268
343
  function extractBoundToken(receipt) {
269
344
  return receipt.match(/#token=([^#]+)/)?.[1];
@@ -353,11 +428,15 @@ function verifyAppleCertChain(x5c) {
353
428
  }
354
429
  const chain = x5c.map((der) => new crypto.X509Certificate(derBase64ToPem(der)));
355
430
  const now = /* @__PURE__ */ new Date();
431
+ let notAfter = Infinity;
356
432
  for (let i = 0; i < chain.length; i++) {
357
433
  const cert = chain[i];
358
- if (new Date(cert.validFrom) > now || new Date(cert.validTo) < now) {
434
+ const validTo = new Date(cert.validTo).getTime();
435
+ if (new Date(cert.validFrom) > now || validTo < now.getTime()) {
359
436
  throw new Error(`[onesub/apple] cert[${i}] outside validity window`);
360
437
  }
438
+ if (Number.isNaN(validTo)) notAfter = 0;
439
+ else if (validTo < notAfter) notAfter = validTo;
361
440
  if (i >= 1) assertIssuerCanSign(cert, i);
362
441
  if (i + 1 < chain.length) {
363
442
  if (!cert.checkIssued(chain[i + 1]) || !cert.verify(chain[i + 1].publicKey)) {
@@ -380,8 +459,9 @@ function verifyAppleCertChain(x5c) {
380
459
  if (!trustsRoot) {
381
460
  throw new Error("[onesub/apple] cert chain does not terminate at a trusted Apple root");
382
461
  }
383
- return chain[0].toString();
462
+ return { leafPem: chain[0].toString(), notAfter };
384
463
  }
464
+ var appleLeafKeys = new VerifiedKeyCache();
385
465
  async function decodeJws(jws, skipVerification = false) {
386
466
  if (skipVerification) {
387
467
  if (process.env["NODE_ENV"] === "production") {
@@ -396,9 +476,11 @@ async function decodeJws(jws, skipVerification = false) {
396
476
  if (!x5c || x5c.length === 0) {
397
477
  throw new Error("[onesub/apple] JWS header missing x5c certificate chain");
398
478
  }
399
- const leafPem = verifyAppleCertChain(x5c);
400
479
  const alg = header.alg ?? "ES256";
401
- const key = await jose.importX509(leafPem, alg);
480
+ const key = await appleLeafKeys.resolve([alg, ...x5c], async () => {
481
+ const { leafPem, notAfter } = verifyAppleCertChain(x5c);
482
+ return { key: await jose.importX509(leafPem, alg), notAfter };
483
+ });
402
484
  const { payload } = await jose.jwtVerify(jws, key);
403
485
  return payload;
404
486
  }
@@ -1905,6 +1987,13 @@ var purchaseStatusQuerySchema = zod.z.object({
1905
1987
  userId: zod.z.string().min(1).max(256),
1906
1988
  productId: zod.z.string().min(1).max(256).optional()
1907
1989
  });
1990
+ async function purchasesForProduct(purchaseStore, userId, productId) {
1991
+ if (purchaseStore.getPurchasesForProduct) {
1992
+ return purchaseStore.getPurchasesForProduct(userId, productId);
1993
+ }
1994
+ const all = await purchaseStore.getPurchasesByUserId(userId);
1995
+ return all.filter((p) => p.productId === productId);
1996
+ }
1908
1997
  function createPurchaseRouter(config, purchaseStore) {
1909
1998
  const router = express.Router();
1910
1999
  const registry = getAppRegistry(config);
@@ -1926,9 +2015,7 @@ function createPurchaseRouter(config, purchaseStore) {
1926
2015
  });
1927
2016
  try {
1928
2017
  if (type === shared.PURCHASE_TYPE.NON_CONSUMABLE) {
1929
- const owned = (await purchaseStore.getPurchasesByUserId(userId)).find(
1930
- (p) => p.productId === productId
1931
- );
2018
+ const owned = (await purchasesForProduct(purchaseStore, userId, productId))[0];
1932
2019
  if (owned) {
1933
2020
  const response2 = {
1934
2021
  valid: true,
@@ -2058,10 +2145,7 @@ function createPurchaseRouter(config, purchaseStore) {
2058
2145
  }
2059
2146
  const { userId, productId } = query;
2060
2147
  try {
2061
- let purchases = await purchaseStore.getPurchasesByUserId(userId);
2062
- if (productId !== void 0) {
2063
- purchases = purchases.filter((p) => p.productId === productId);
2064
- }
2148
+ const purchases = productId !== void 0 ? await purchasesForProduct(purchaseStore, userId, productId) : await purchaseStore.getPurchasesByUserId(userId);
2065
2149
  const response = { purchases };
2066
2150
  res.status(200).json(response);
2067
2151
  } catch (err2) {
@@ -2071,10 +2155,8 @@ function createPurchaseRouter(config, purchaseStore) {
2071
2155
  });
2072
2156
  return router;
2073
2157
  }
2074
- async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2158
+ function evaluateEntitlementFrom(subs, purchases, entitlement, now = Date.now()) {
2075
2159
  const productIdSet = new Set(entitlement.productIds);
2076
- const now = Date.now();
2077
- const subs = await store.getAllByUserId(userId);
2078
2160
  for (const sub of subs) {
2079
2161
  if (!productIdSet.has(sub.productId)) continue;
2080
2162
  const statusAllows = sub.status === shared.SUBSCRIPTION_STATUS.ACTIVE || sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
@@ -2087,7 +2169,6 @@ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2087
2169
  expiresAt: sub.expiresAt
2088
2170
  };
2089
2171
  }
2090
- const purchases = await purchaseStore.getPurchasesByUserId(userId);
2091
2172
  for (const p of purchases) {
2092
2173
  if (p.type !== shared.PURCHASE_TYPE.NON_CONSUMABLE) continue;
2093
2174
  if (!productIdSet.has(p.productId)) continue;
@@ -2099,6 +2180,14 @@ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2099
2180
  }
2100
2181
  return { active: false, source: null };
2101
2182
  }
2183
+ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2184
+ const now = Date.now();
2185
+ const subs = await store.getAllByUserId(userId);
2186
+ const fromSubs = evaluateEntitlementFrom(subs, [], entitlement, now);
2187
+ if (fromSubs.active) return fromSubs;
2188
+ const purchases = await purchaseStore.getPurchasesByUserId(userId);
2189
+ return evaluateEntitlementFrom([], purchases, entitlement, now);
2190
+ }
2102
2191
  var userIdSchema = zod.z.string().min(1).max(256);
2103
2192
  var entitlementIdSchema = zod.z.string().min(1).max(128);
2104
2193
  function createEntitlementRouter(config, store, purchaseStore) {
@@ -2140,11 +2229,13 @@ function createEntitlementRouter(config, store, purchaseStore) {
2140
2229
  return;
2141
2230
  }
2142
2231
  try {
2143
- const entries = await Promise.all(
2144
- Object.entries(entitlements).map(async ([id, entitlement]) => {
2145
- const status = await evaluateEntitlement(userId, entitlement, store, purchaseStore);
2146
- return [id, status];
2147
- })
2232
+ const [subs, purchases] = await Promise.all([
2233
+ store.getAllByUserId(userId),
2234
+ purchaseStore.getPurchasesByUserId(userId)
2235
+ ]);
2236
+ const now = Date.now();
2237
+ const entries = Object.entries(entitlements).map(
2238
+ ([id, entitlement]) => [id, evaluateEntitlementFrom(subs, purchases, entitlement, now)]
2148
2239
  );
2149
2240
  const response = {
2150
2241
  entitlements: Object.fromEntries(entries)
@@ -2335,9 +2426,10 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2335
2426
  ]);
2336
2427
  let entitlements;
2337
2428
  if (config.entitlements && Object.keys(config.entitlements).length > 0) {
2429
+ const now = Date.now();
2338
2430
  entitlements = {};
2339
2431
  for (const [id, def] of Object.entries(config.entitlements)) {
2340
- entitlements[id] = await evaluateEntitlement(params.userId, def, store, purchaseStore);
2432
+ entitlements[id] = evaluateEntitlementFrom(subscriptions, purchases, def, now);
2341
2433
  }
2342
2434
  }
2343
2435
  const response = {
@@ -2449,11 +2541,135 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2449
2541
  }
2450
2542
  return router;
2451
2543
  }
2544
+ var MS_PER_DAY = 864e5;
2545
+ function bump(map, key) {
2546
+ map[key] = (map[key] ?? 0) + 1;
2547
+ }
2548
+ function utcDateKey(ms) {
2549
+ const d = new Date(ms);
2550
+ const yyyy = d.getUTCFullYear();
2551
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
2552
+ const dd = String(d.getUTCDate()).padStart(2, "0");
2553
+ return `${yyyy}-${mm}-${dd}`;
2554
+ }
2555
+ function emptyDailyBuckets(fromMs, toMs) {
2556
+ const out = [];
2557
+ const start = new Date(fromMs);
2558
+ start.setUTCHours(0, 0, 0, 0);
2559
+ let cur = start.getTime();
2560
+ while (cur <= toMs) {
2561
+ out.push({ date: utcDateKey(cur), count: 0 });
2562
+ cur += MS_PER_DAY;
2563
+ }
2564
+ return out;
2565
+ }
2566
+ function aggregateRange(records, opts) {
2567
+ const byProduct = {};
2568
+ const byPlatform = {};
2569
+ let total = 0;
2570
+ const buckets = opts.groupBy === "day" ? emptyDailyBuckets(opts.fromMs, opts.toMs) : null;
2571
+ const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2572
+ for (const record of records) {
2573
+ if (opts.include && !opts.include(record)) continue;
2574
+ const anchorMs = new Date(opts.anchor(record)).getTime();
2575
+ if (Number.isNaN(anchorMs)) continue;
2576
+ if (anchorMs < opts.fromMs || anchorMs > opts.toMs) continue;
2577
+ total++;
2578
+ bump(byProduct, record.productId);
2579
+ bump(byPlatform, record.platform);
2580
+ if (buckets && bucketIndex) {
2581
+ const idx = bucketIndex.get(utcDateKey(anchorMs));
2582
+ if (idx !== void 0) buckets[idx].count++;
2583
+ }
2584
+ }
2585
+ return { total, byProduct, byPlatform, ...buckets ? { buckets } : {} };
2586
+ }
2587
+ function isActiveSubscription(sub, nowMs) {
2588
+ const statusAllows = sub.status === shared.SUBSCRIPTION_STATUS.ACTIVE || sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
2589
+ return statusAllows && new Date(sub.expiresAt).getTime() > nowMs;
2590
+ }
2591
+ function isEndedSubscription(sub) {
2592
+ return sub.status === shared.SUBSCRIPTION_STATUS.EXPIRED || sub.status === shared.SUBSCRIPTION_STATUS.CANCELED;
2593
+ }
2594
+ function isNonConsumable(purchase) {
2595
+ return purchase.type === shared.PURCHASE_TYPE.NON_CONSUMABLE;
2596
+ }
2597
+ function aggregateActive(subs, purchases, nowMs) {
2598
+ const byProduct = {};
2599
+ const byProductPurchases = {};
2600
+ const byPlatform = {};
2601
+ let activeSubscriptions = 0;
2602
+ let gracePeriodSubscriptions = 0;
2603
+ let nonConsumablePurchases = 0;
2604
+ for (const sub of subs) {
2605
+ if (!isActiveSubscription(sub, nowMs)) continue;
2606
+ activeSubscriptions++;
2607
+ if (sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriodSubscriptions++;
2608
+ bump(byProduct, sub.productId);
2609
+ bump(byPlatform, sub.platform);
2610
+ }
2611
+ for (const purchase of purchases) {
2612
+ if (!isNonConsumable(purchase)) continue;
2613
+ nonConsumablePurchases++;
2614
+ bump(byProductPurchases, purchase.productId);
2615
+ bump(byPlatform, purchase.platform);
2616
+ }
2617
+ return {
2618
+ total: activeSubscriptions + nonConsumablePurchases,
2619
+ activeSubscriptions,
2620
+ gracePeriodSubscriptions,
2621
+ nonConsumablePurchases,
2622
+ byProduct,
2623
+ byProductPurchases,
2624
+ byPlatform
2625
+ };
2626
+ }
2627
+
2628
+ // src/metrics-cache.ts
2629
+ function isDisabled(ttlSeconds) {
2630
+ return !Number.isFinite(ttlSeconds) || ttlSeconds <= 0;
2631
+ }
2632
+ function createMetricsCache(ttlSeconds, storage = new InMemoryCacheAdapter()) {
2633
+ const inflight = /* @__PURE__ */ new Map();
2634
+ const gridMs = Math.max(1, Math.floor(ttlSeconds * 1e3));
2635
+ return {
2636
+ quantizeToWindow(ms) {
2637
+ if (isDisabled(ttlSeconds)) return ms;
2638
+ return Math.floor(ms / gridMs) * gridMs;
2639
+ },
2640
+ async resolve(keyParts, produce) {
2641
+ if (isDisabled(ttlSeconds)) return produce();
2642
+ const key = `metrics:${keyParts.join(":")}`;
2643
+ const cache = storage;
2644
+ const hit = await cache.get(key);
2645
+ if (hit !== null && hit !== void 0) return hit;
2646
+ const pending = inflight.get(key);
2647
+ if (pending) return pending;
2648
+ const promise = (async () => {
2649
+ const value = await produce();
2650
+ await cache.set(key, value, ttlSeconds);
2651
+ return value;
2652
+ })();
2653
+ inflight.set(key, promise);
2654
+ try {
2655
+ return await promise;
2656
+ } finally {
2657
+ inflight.delete(key);
2658
+ }
2659
+ }
2660
+ };
2661
+ }
2662
+
2663
+ // src/routes/metrics.ts
2452
2664
  var ADMIN_SECRET_HEADER2 = "x-admin-secret";
2665
+ var DEFAULT_METRICS_CACHE_TTL_SECONDS = 30;
2453
2666
  function createMetricsRouter(config, store, purchaseStore) {
2454
2667
  if (!config.adminSecret) return null;
2455
2668
  const router = express.Router();
2456
2669
  const adminSecret = config.adminSecret;
2670
+ const metricsCache = createMetricsCache(
2671
+ config.metricsCacheTtlSeconds ?? DEFAULT_METRICS_CACHE_TTL_SECONDS
2672
+ );
2457
2673
  router.use("/onesub/metrics", (req, res, next) => {
2458
2674
  const provided = req.headers[ADMIN_SECRET_HEADER2];
2459
2675
  if (typeof provided !== "string" || !secretsEqual(provided, adminSecret)) {
@@ -2462,50 +2678,15 @@ function createMetricsRouter(config, store, purchaseStore) {
2462
2678
  }
2463
2679
  next();
2464
2680
  });
2465
- const isActiveSub = (sub, now) => {
2466
- const statusAllows = sub.status === shared.SUBSCRIPTION_STATUS.ACTIVE || sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
2467
- return statusAllows && new Date(sub.expiresAt).getTime() > now;
2468
- };
2469
- const bump = (map, key) => {
2470
- map[key] = (map[key] ?? 0) + 1;
2471
- };
2472
2681
  router.get(shared.ROUTES.METRICS_ACTIVE, async (_req, res) => {
2473
2682
  try {
2474
- const [subs, purchases] = await Promise.all([
2475
- store.listAll(),
2476
- purchaseStore.listAll()
2477
- ]);
2478
- const now = Date.now();
2479
- const byProduct = {};
2480
- const byProductPurchases = {};
2481
- const byPlatform = {};
2482
- let activeSubscriptions = 0;
2483
- let gracePeriodSubscriptions = 0;
2484
- let nonConsumablePurchases = 0;
2485
- for (const sub of subs) {
2486
- if (!isActiveSub(sub, now)) continue;
2487
- activeSubscriptions++;
2488
- if (sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD) {
2489
- gracePeriodSubscriptions++;
2683
+ const response = await metricsCache.resolve(
2684
+ ["active", metricsCache.quantizeToWindow(Date.now())],
2685
+ async () => {
2686
+ const [subs, purchases] = await Promise.all([store.listAll(), purchaseStore.listAll()]);
2687
+ return aggregateActive(subs, purchases, Date.now());
2490
2688
  }
2491
- bump(byProduct, sub.productId);
2492
- bump(byPlatform, sub.platform);
2493
- }
2494
- for (const p of purchases) {
2495
- if (p.type !== shared.PURCHASE_TYPE.NON_CONSUMABLE) continue;
2496
- nonConsumablePurchases++;
2497
- bump(byProductPurchases, p.productId);
2498
- bump(byPlatform, p.platform);
2499
- }
2500
- const response = {
2501
- total: activeSubscriptions + nonConsumablePurchases,
2502
- activeSubscriptions,
2503
- gracePeriodSubscriptions,
2504
- nonConsumablePurchases,
2505
- byProduct,
2506
- byProductPurchases,
2507
- byPlatform
2508
- };
2689
+ );
2509
2690
  res.status(200).json(response);
2510
2691
  } catch (err2) {
2511
2692
  log.error("[onesub/metrics/active] error:", err2);
@@ -2536,140 +2717,63 @@ function createMetricsRouter(config, store, purchaseStore) {
2536
2717
  }
2537
2718
  return { fromMs, toMs, groupBy };
2538
2719
  }
2539
- function utcDateKey(ms) {
2540
- const d = new Date(ms);
2541
- const yyyy = d.getUTCFullYear();
2542
- const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
2543
- const dd = String(d.getUTCDate()).padStart(2, "0");
2544
- return `${yyyy}-${mm}-${dd}`;
2545
- }
2546
- function emptyDailyBuckets(fromMs, toMs) {
2547
- const out = [];
2548
- const start = new Date(fromMs);
2549
- start.setUTCHours(0, 0, 0, 0);
2550
- let cur = start.getTime();
2551
- while (cur <= toMs) {
2552
- out.push({ date: utcDateKey(cur), count: 0 });
2553
- cur += 864e5;
2554
- }
2555
- return out;
2556
- }
2557
- router.get(shared.ROUTES.METRICS_STARTED, async (req, res) => {
2558
- const range = parseRange(req);
2559
- if ("error" in range) {
2560
- sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
2561
- return;
2562
- }
2563
- try {
2564
- const subs = await store.listAll();
2565
- const byProduct = {};
2566
- const byPlatform = {};
2567
- let total = 0;
2568
- const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
2569
- const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2570
- for (const sub of subs) {
2571
- const purchasedMs = new Date(sub.purchasedAt).getTime();
2572
- if (purchasedMs < range.fromMs || purchasedMs > range.toMs) continue;
2573
- total++;
2574
- bump(byProduct, sub.productId);
2575
- bump(byPlatform, sub.platform);
2576
- if (buckets && bucketIndex) {
2577
- const idx = bucketIndex.get(utcDateKey(purchasedMs));
2578
- if (idx !== void 0) buckets[idx].count++;
2579
- }
2580
- }
2581
- const response = {
2582
- from: req.query["from"],
2583
- to: req.query["to"],
2584
- total,
2585
- byProduct,
2586
- byPlatform,
2587
- ...buckets ? { buckets } : {}
2588
- };
2589
- res.status(200).json(response);
2590
- } catch (err2) {
2591
- log.error("[onesub/metrics/started] error:", err2);
2592
- sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2593
- }
2594
- });
2595
- router.get(shared.ROUTES.METRICS_EXPIRED, async (req, res) => {
2596
- const range = parseRange(req);
2597
- if ("error" in range) {
2598
- sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
2599
- return;
2600
- }
2601
- try {
2602
- const subs = await store.listAll();
2603
- const byProduct = {};
2604
- const byPlatform = {};
2605
- let total = 0;
2606
- const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
2607
- const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2608
- for (const sub of subs) {
2609
- if (sub.status !== shared.SUBSCRIPTION_STATUS.EXPIRED && sub.status !== shared.SUBSCRIPTION_STATUS.CANCELED) continue;
2610
- const expiredMs = new Date(sub.expiresAt).getTime();
2611
- if (expiredMs < range.fromMs || expiredMs > range.toMs) continue;
2612
- total++;
2613
- bump(byProduct, sub.productId);
2614
- bump(byPlatform, sub.platform);
2615
- if (buckets && bucketIndex) {
2616
- const idx = bucketIndex.get(utcDateKey(expiredMs));
2617
- if (idx !== void 0) buckets[idx].count++;
2618
- }
2720
+ function windowed(name, load, anchor, include) {
2721
+ return async (req, res) => {
2722
+ const range = parseRange(req);
2723
+ if ("error" in range) {
2724
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
2725
+ return;
2619
2726
  }
2620
- const response = {
2621
- from: req.query["from"],
2622
- to: req.query["to"],
2623
- total,
2624
- byProduct,
2625
- byPlatform,
2626
- ...buckets ? { buckets } : {}
2627
- };
2628
- res.status(200).json(response);
2629
- } catch (err2) {
2630
- log.error("[onesub/metrics/expired] error:", err2);
2631
- sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2632
- }
2633
- });
2634
- router.get(shared.ROUTES.METRICS_PURCHASES_STARTED, async (req, res) => {
2635
- const range = parseRange(req);
2636
- if ("error" in range) {
2637
- sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
2638
- return;
2639
- }
2640
- try {
2641
- const purchases = await purchaseStore.listAll();
2642
- const byProduct = {};
2643
- const byPlatform = {};
2644
- let total = 0;
2645
- const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
2646
- const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2647
- for (const p of purchases) {
2648
- if (p.type !== shared.PURCHASE_TYPE.NON_CONSUMABLE) continue;
2649
- const purchasedMs = new Date(p.purchasedAt).getTime();
2650
- if (purchasedMs < range.fromMs || purchasedMs > range.toMs) continue;
2651
- total++;
2652
- bump(byProduct, p.productId);
2653
- bump(byPlatform, p.platform);
2654
- if (buckets && bucketIndex) {
2655
- const idx = bucketIndex.get(utcDateKey(purchasedMs));
2656
- if (idx !== void 0) buckets[idx].count++;
2657
- }
2727
+ try {
2728
+ const aggregate = await metricsCache.resolve(
2729
+ [
2730
+ name,
2731
+ metricsCache.quantizeToWindow(range.fromMs),
2732
+ metricsCache.quantizeToWindow(range.toMs),
2733
+ range.groupBy
2734
+ ],
2735
+ async () => aggregateRange(await load(), {
2736
+ fromMs: range.fromMs,
2737
+ toMs: range.toMs,
2738
+ groupBy: range.groupBy,
2739
+ anchor,
2740
+ ...include ? { include } : {}
2741
+ })
2742
+ );
2743
+ const response = {
2744
+ from: req.query["from"],
2745
+ to: req.query["to"],
2746
+ ...aggregate
2747
+ };
2748
+ res.status(200).json(response);
2749
+ } catch (err2) {
2750
+ log.error(`[onesub/metrics/${name}] error:`, err2);
2751
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2658
2752
  }
2659
- const response = {
2660
- from: req.query["from"],
2661
- to: req.query["to"],
2662
- total,
2663
- byProduct,
2664
- byPlatform,
2665
- ...buckets ? { buckets } : {}
2666
- };
2667
- res.status(200).json(response);
2668
- } catch (err2) {
2669
- log.error("[onesub/metrics/purchases/started] error:", err2);
2670
- sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2671
- }
2672
- });
2753
+ };
2754
+ }
2755
+ router.get(
2756
+ shared.ROUTES.METRICS_STARTED,
2757
+ windowed("started", () => store.listAll(), (sub) => sub.purchasedAt)
2758
+ );
2759
+ router.get(
2760
+ shared.ROUTES.METRICS_EXPIRED,
2761
+ windowed(
2762
+ "expired",
2763
+ () => store.listAll(),
2764
+ (sub) => sub.expiresAt,
2765
+ isEndedSubscription
2766
+ )
2767
+ );
2768
+ router.get(
2769
+ shared.ROUTES.METRICS_PURCHASES_STARTED,
2770
+ windowed(
2771
+ "purchases-started",
2772
+ () => purchaseStore.listAll(),
2773
+ (purchase) => purchase.purchasedAt,
2774
+ isNonConsumable
2775
+ )
2776
+ );
2673
2777
  return router;
2674
2778
  }
2675
2779
  var OFFER_SECRET_HEADER = "x-onesub-offer-secret";
@@ -3017,6 +3121,26 @@ var PostgresPurchaseStore = class {
3017
3121
  );
3018
3122
  return result.rows.map(rowToPurchaseInfo);
3019
3123
  }
3124
+ /**
3125
+ * Purchases for one user + product, most-recent-first.
3126
+ *
3127
+ * Same shape and ordering as `getPurchasesByUserId`, with `product_id` pushed
3128
+ * into the WHERE clause instead of filtered in process. Non-consumables also
3129
+ * have a partial unique index on `(user_id, product_id)`, so the ownership
3130
+ * check this backs is a single index lookup rather than a full read of the
3131
+ * user's purchase history.
3132
+ */
3133
+ async getPurchasesForProduct(userId, productId) {
3134
+ const pool = await this.getPool();
3135
+ const result = await pool.query(
3136
+ `SELECT *
3137
+ FROM onesub_purchases
3138
+ WHERE user_id = $1 AND product_id = $2
3139
+ ORDER BY purchased_at DESC`,
3140
+ [userId, productId]
3141
+ );
3142
+ return result.rows.map(rowToPurchaseInfo);
3143
+ }
3020
3144
  async getPurchaseByTransactionId(txId) {
3021
3145
  const pool = await this.getPool();
3022
3146
  const result = await pool.query(
@@ -3227,6 +3351,23 @@ var RedisPurchaseStore = class {
3227
3351
  const raws = await this.redis.mget(...ids.map((id) => PUR_TX_PREFIX + id));
3228
3352
  return raws.filter((r) => r != null).map((r) => JSON.parse(r));
3229
3353
  }
3354
+ /**
3355
+ * Purchases for one user + product, most-recent-first.
3356
+ *
3357
+ * Served from the `user_product` set that `savePurchase` already maintains for
3358
+ * `hasPurchased`, so this reads only the rows for this product rather than the
3359
+ * user's whole purchase history.
3360
+ *
3361
+ * That set is unordered — unlike the per-user sorted set — so the
3362
+ * most-recent-first contract is restored by an explicit sort here. The row
3363
+ * count is per-product, so this is small.
3364
+ */
3365
+ async getPurchasesForProduct(userId, productId) {
3366
+ const ids = await this.redis.smembers(PUR_USER_PRODUCT_PREFIX + userId + ":" + productId);
3367
+ if (ids.length === 0) return [];
3368
+ const raws = await this.redis.mget(...ids.map((id) => PUR_TX_PREFIX + id));
3369
+ return raws.filter((r) => r != null).map((r) => JSON.parse(r)).sort((a, b) => Date.parse(b.purchasedAt) - Date.parse(a.purchasedAt));
3370
+ }
3230
3371
  async getPurchaseByTransactionId(txId) {
3231
3372
  const raw = await this.redis.get(PUR_TX_PREFIX + txId);
3232
3373
  return raw ? JSON.parse(raw) : null;
@@ -4150,6 +4291,7 @@ exports.RedisWebhookEventStore = RedisWebhookEventStore;
4150
4291
  exports.createOneSubMiddleware = createOneSubMiddleware;
4151
4292
  exports.createOneSubServer = createOneSubServer;
4152
4293
  exports.evaluateEntitlement = evaluateEntitlement;
4294
+ exports.evaluateEntitlementFrom = evaluateEntitlementFrom;
4153
4295
  exports.fetchAppleSubscriptionStatus = fetchAppleSubscriptionStatus;
4154
4296
  exports.fetchAppleTransactionHistory = fetchAppleTransactionHistory;
4155
4297
  exports.getDefaultCache = getDefaultCache;