@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.js CHANGED
@@ -2,7 +2,7 @@ import express, { Router } from 'express';
2
2
  import { PURCHASE_TYPE, ONESUB_ERROR_CODE, ROUTES, DEFAULT_PORT, SUBSCRIPTION_STATUS, MOCK_RECEIPT_PREFIX } from '@onesub/shared';
3
3
  import { z } from 'zod';
4
4
  import { decodeJwt, decodeProtectedHeader, importX509, jwtVerify, importPKCS8, SignJWT, createRemoteJWKSet } from 'jose';
5
- import { X509Certificate, randomUUID, timingSafeEqual, createSign, createHash } from 'crypto';
5
+ import { X509Certificate, createHash, randomUUID, timingSafeEqual, createSign } from 'crypto';
6
6
  import { createRequire } from 'module';
7
7
 
8
8
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
@@ -84,11 +84,17 @@ var InMemoryPurchaseStore = class {
84
84
  }
85
85
  this.byTransactionId.set(purchase.transactionId, purchase);
86
86
  const list = this.byUserId.get(purchase.userId) ?? [];
87
- list.push(purchase);
87
+ const at = Date.parse(purchase.purchasedAt);
88
+ const idx = list.findIndex((p) => Date.parse(p.purchasedAt) < at);
89
+ if (idx === -1) list.push(purchase);
90
+ else list.splice(idx, 0, purchase);
88
91
  this.byUserId.set(purchase.userId, list);
89
92
  }
90
93
  async getPurchasesByUserId(userId) {
91
- return this.byUserId.get(userId) ?? [];
94
+ return [...this.byUserId.get(userId) ?? []];
95
+ }
96
+ async getPurchasesForProduct(userId, productId) {
97
+ return (this.byUserId.get(userId) ?? []).filter((p) => p.productId === productId);
92
98
  }
93
99
  async getPurchaseByTransactionId(txId) {
94
100
  return this.byTransactionId.get(txId) ?? null;
@@ -173,9 +179,13 @@ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
173
179
  async function fetchWithTimeout(input, init, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
174
180
  const controller = new AbortController();
175
181
  const callerSignal = init?.signal;
182
+ let onCallerAbort;
176
183
  if (callerSignal) {
177
184
  if (callerSignal.aborted) controller.abort(callerSignal.reason);
178
- else callerSignal.addEventListener("abort", () => controller.abort(callerSignal.reason), { once: true });
185
+ else {
186
+ onCallerAbort = () => controller.abort(callerSignal.reason);
187
+ callerSignal.addEventListener("abort", onCallerAbort, { once: true });
188
+ }
179
189
  }
180
190
  const timer = setTimeout(() => {
181
191
  controller.abort(new Error(`[onesub] fetch timed out after ${timeoutMs}ms`));
@@ -184,6 +194,7 @@ async function fetchWithTimeout(input, init, timeoutMs = DEFAULT_FETCH_TIMEOUT_M
184
194
  return await fetch(input, { ...init, signal: controller.signal });
185
195
  } finally {
186
196
  clearTimeout(timer);
197
+ if (onCallerAbort) callerSignal?.removeEventListener("abort", onCallerAbort);
187
198
  }
188
199
  }
189
200
 
@@ -236,6 +247,70 @@ function getDefaultCache() {
236
247
  function setDefaultCache(adapter) {
237
248
  defaultAdapter = adapter;
238
249
  }
250
+ var DEFAULT_MAX_ENTRIES = 32;
251
+ var DEFAULT_MAX_TTL_MS = 60 * 60 * 1e3;
252
+ var VerifiedKeyCache = class {
253
+ entries = /* @__PURE__ */ new Map();
254
+ maxEntries;
255
+ maxTtlMs;
256
+ now;
257
+ constructor(opts = {}) {
258
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
259
+ this.maxTtlMs = opts.maxTtlMs ?? DEFAULT_MAX_TTL_MS;
260
+ this.now = opts.now ?? Date.now;
261
+ }
262
+ /**
263
+ * Return the key for `parts`, verifying via `produce` only on a miss.
264
+ *
265
+ * `parts` must contain every input the verification depends on — for Apple
266
+ * that is the JWS `alg` plus the full x5c chain. Anything left out would let
267
+ * one input's result be served for a different input.
268
+ */
269
+ async resolve(parts, produce) {
270
+ const cacheKey = digest(parts);
271
+ const now = this.now();
272
+ const hit = this.entries.get(cacheKey);
273
+ if (hit) {
274
+ if (hit.expiresAt > now) return hit.key;
275
+ this.entries.delete(cacheKey);
276
+ }
277
+ const verified = await produce();
278
+ const expiresAt = Math.min(now + this.maxTtlMs, verified.notAfter);
279
+ if (expiresAt > now) {
280
+ this.prune(now);
281
+ this.entries.set(cacheKey, { key: verified.key, expiresAt });
282
+ }
283
+ return verified.key;
284
+ }
285
+ /** Entries currently held, including any not yet pruned. Test/diagnostic use. */
286
+ get size() {
287
+ return this.entries.size;
288
+ }
289
+ /** Drop everything. Test/diagnostic use. */
290
+ clear() {
291
+ this.entries.clear();
292
+ }
293
+ /** Make room: expired entries first, then the oldest insertions. */
294
+ prune(now) {
295
+ for (const [key, entry] of this.entries) {
296
+ if (entry.expiresAt <= now) this.entries.delete(key);
297
+ }
298
+ while (this.entries.size >= this.maxEntries) {
299
+ const oldest = this.entries.keys().next();
300
+ if (oldest.done) break;
301
+ this.entries.delete(oldest.value);
302
+ }
303
+ }
304
+ };
305
+ function digest(parts) {
306
+ const hash = createHash("sha256");
307
+ for (const part of parts) {
308
+ hash.update(String(part.length));
309
+ hash.update(":");
310
+ hash.update(part);
311
+ }
312
+ return hash.digest("base64");
313
+ }
239
314
  function classifyMockReceipt(receipt) {
240
315
  if (receipt.startsWith(MOCK_RECEIPT_PREFIX.NETWORK_ERROR)) return { kind: "network-error" };
241
316
  if (receipt.startsWith(MOCK_RECEIPT_PREFIX.REVOKED)) return { kind: "revoked" };
@@ -255,8 +330,8 @@ function outcomePasses(outcome, tag) {
255
330
  return false;
256
331
  }
257
332
  function deterministicTransactionId(prefix, receipt) {
258
- const digest = createHash("sha256").update(receipt).digest("hex").slice(0, 24);
259
- return `${prefix}_${digest}`;
333
+ const digest2 = createHash("sha256").update(receipt).digest("hex").slice(0, 24);
334
+ return `${prefix}_${digest2}`;
260
335
  }
261
336
  function extractBoundToken(receipt) {
262
337
  return receipt.match(/#token=([^#]+)/)?.[1];
@@ -346,11 +421,15 @@ function verifyAppleCertChain(x5c) {
346
421
  }
347
422
  const chain = x5c.map((der) => new X509Certificate(derBase64ToPem(der)));
348
423
  const now = /* @__PURE__ */ new Date();
424
+ let notAfter = Infinity;
349
425
  for (let i = 0; i < chain.length; i++) {
350
426
  const cert = chain[i];
351
- if (new Date(cert.validFrom) > now || new Date(cert.validTo) < now) {
427
+ const validTo = new Date(cert.validTo).getTime();
428
+ if (new Date(cert.validFrom) > now || validTo < now.getTime()) {
352
429
  throw new Error(`[onesub/apple] cert[${i}] outside validity window`);
353
430
  }
431
+ if (Number.isNaN(validTo)) notAfter = 0;
432
+ else if (validTo < notAfter) notAfter = validTo;
354
433
  if (i >= 1) assertIssuerCanSign(cert, i);
355
434
  if (i + 1 < chain.length) {
356
435
  if (!cert.checkIssued(chain[i + 1]) || !cert.verify(chain[i + 1].publicKey)) {
@@ -373,8 +452,9 @@ function verifyAppleCertChain(x5c) {
373
452
  if (!trustsRoot) {
374
453
  throw new Error("[onesub/apple] cert chain does not terminate at a trusted Apple root");
375
454
  }
376
- return chain[0].toString();
455
+ return { leafPem: chain[0].toString(), notAfter };
377
456
  }
457
+ var appleLeafKeys = new VerifiedKeyCache();
378
458
  async function decodeJws(jws, skipVerification = false) {
379
459
  if (skipVerification) {
380
460
  if (process.env["NODE_ENV"] === "production") {
@@ -389,9 +469,11 @@ async function decodeJws(jws, skipVerification = false) {
389
469
  if (!x5c || x5c.length === 0) {
390
470
  throw new Error("[onesub/apple] JWS header missing x5c certificate chain");
391
471
  }
392
- const leafPem = verifyAppleCertChain(x5c);
393
472
  const alg = header.alg ?? "ES256";
394
- const key = await importX509(leafPem, alg);
473
+ const key = await appleLeafKeys.resolve([alg, ...x5c], async () => {
474
+ const { leafPem, notAfter } = verifyAppleCertChain(x5c);
475
+ return { key: await importX509(leafPem, alg), notAfter };
476
+ });
395
477
  const { payload } = await jwtVerify(jws, key);
396
478
  return payload;
397
479
  }
@@ -1898,6 +1980,13 @@ var purchaseStatusQuerySchema = z.object({
1898
1980
  userId: z.string().min(1).max(256),
1899
1981
  productId: z.string().min(1).max(256).optional()
1900
1982
  });
1983
+ async function purchasesForProduct(purchaseStore, userId, productId) {
1984
+ if (purchaseStore.getPurchasesForProduct) {
1985
+ return purchaseStore.getPurchasesForProduct(userId, productId);
1986
+ }
1987
+ const all = await purchaseStore.getPurchasesByUserId(userId);
1988
+ return all.filter((p) => p.productId === productId);
1989
+ }
1901
1990
  function createPurchaseRouter(config, purchaseStore) {
1902
1991
  const router = Router();
1903
1992
  const registry = getAppRegistry(config);
@@ -1919,9 +2008,7 @@ function createPurchaseRouter(config, purchaseStore) {
1919
2008
  });
1920
2009
  try {
1921
2010
  if (type === PURCHASE_TYPE.NON_CONSUMABLE) {
1922
- const owned = (await purchaseStore.getPurchasesByUserId(userId)).find(
1923
- (p) => p.productId === productId
1924
- );
2011
+ const owned = (await purchasesForProduct(purchaseStore, userId, productId))[0];
1925
2012
  if (owned) {
1926
2013
  const response2 = {
1927
2014
  valid: true,
@@ -2051,10 +2138,7 @@ function createPurchaseRouter(config, purchaseStore) {
2051
2138
  }
2052
2139
  const { userId, productId } = query;
2053
2140
  try {
2054
- let purchases = await purchaseStore.getPurchasesByUserId(userId);
2055
- if (productId !== void 0) {
2056
- purchases = purchases.filter((p) => p.productId === productId);
2057
- }
2141
+ const purchases = productId !== void 0 ? await purchasesForProduct(purchaseStore, userId, productId) : await purchaseStore.getPurchasesByUserId(userId);
2058
2142
  const response = { purchases };
2059
2143
  res.status(200).json(response);
2060
2144
  } catch (err2) {
@@ -2064,10 +2148,8 @@ function createPurchaseRouter(config, purchaseStore) {
2064
2148
  });
2065
2149
  return router;
2066
2150
  }
2067
- async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2151
+ function evaluateEntitlementFrom(subs, purchases, entitlement, now = Date.now()) {
2068
2152
  const productIdSet = new Set(entitlement.productIds);
2069
- const now = Date.now();
2070
- const subs = await store.getAllByUserId(userId);
2071
2153
  for (const sub of subs) {
2072
2154
  if (!productIdSet.has(sub.productId)) continue;
2073
2155
  const statusAllows = sub.status === SUBSCRIPTION_STATUS.ACTIVE || sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD;
@@ -2080,7 +2162,6 @@ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2080
2162
  expiresAt: sub.expiresAt
2081
2163
  };
2082
2164
  }
2083
- const purchases = await purchaseStore.getPurchasesByUserId(userId);
2084
2165
  for (const p of purchases) {
2085
2166
  if (p.type !== PURCHASE_TYPE.NON_CONSUMABLE) continue;
2086
2167
  if (!productIdSet.has(p.productId)) continue;
@@ -2092,6 +2173,14 @@ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2092
2173
  }
2093
2174
  return { active: false, source: null };
2094
2175
  }
2176
+ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2177
+ const now = Date.now();
2178
+ const subs = await store.getAllByUserId(userId);
2179
+ const fromSubs = evaluateEntitlementFrom(subs, [], entitlement, now);
2180
+ if (fromSubs.active) return fromSubs;
2181
+ const purchases = await purchaseStore.getPurchasesByUserId(userId);
2182
+ return evaluateEntitlementFrom([], purchases, entitlement, now);
2183
+ }
2095
2184
  var userIdSchema = z.string().min(1).max(256);
2096
2185
  var entitlementIdSchema = z.string().min(1).max(128);
2097
2186
  function createEntitlementRouter(config, store, purchaseStore) {
@@ -2133,11 +2222,13 @@ function createEntitlementRouter(config, store, purchaseStore) {
2133
2222
  return;
2134
2223
  }
2135
2224
  try {
2136
- const entries = await Promise.all(
2137
- Object.entries(entitlements).map(async ([id, entitlement]) => {
2138
- const status = await evaluateEntitlement(userId, entitlement, store, purchaseStore);
2139
- return [id, status];
2140
- })
2225
+ const [subs, purchases] = await Promise.all([
2226
+ store.getAllByUserId(userId),
2227
+ purchaseStore.getPurchasesByUserId(userId)
2228
+ ]);
2229
+ const now = Date.now();
2230
+ const entries = Object.entries(entitlements).map(
2231
+ ([id, entitlement]) => [id, evaluateEntitlementFrom(subs, purchases, entitlement, now)]
2141
2232
  );
2142
2233
  const response = {
2143
2234
  entitlements: Object.fromEntries(entries)
@@ -2328,9 +2419,10 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2328
2419
  ]);
2329
2420
  let entitlements;
2330
2421
  if (config.entitlements && Object.keys(config.entitlements).length > 0) {
2422
+ const now = Date.now();
2331
2423
  entitlements = {};
2332
2424
  for (const [id, def] of Object.entries(config.entitlements)) {
2333
- entitlements[id] = await evaluateEntitlement(params.userId, def, store, purchaseStore);
2425
+ entitlements[id] = evaluateEntitlementFrom(subscriptions, purchases, def, now);
2334
2426
  }
2335
2427
  }
2336
2428
  const response = {
@@ -2442,11 +2534,135 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2442
2534
  }
2443
2535
  return router;
2444
2536
  }
2537
+ var MS_PER_DAY = 864e5;
2538
+ function bump(map, key) {
2539
+ map[key] = (map[key] ?? 0) + 1;
2540
+ }
2541
+ function utcDateKey(ms) {
2542
+ const d = new Date(ms);
2543
+ const yyyy = d.getUTCFullYear();
2544
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
2545
+ const dd = String(d.getUTCDate()).padStart(2, "0");
2546
+ return `${yyyy}-${mm}-${dd}`;
2547
+ }
2548
+ function emptyDailyBuckets(fromMs, toMs) {
2549
+ const out = [];
2550
+ const start = new Date(fromMs);
2551
+ start.setUTCHours(0, 0, 0, 0);
2552
+ let cur = start.getTime();
2553
+ while (cur <= toMs) {
2554
+ out.push({ date: utcDateKey(cur), count: 0 });
2555
+ cur += MS_PER_DAY;
2556
+ }
2557
+ return out;
2558
+ }
2559
+ function aggregateRange(records, opts) {
2560
+ const byProduct = {};
2561
+ const byPlatform = {};
2562
+ let total = 0;
2563
+ const buckets = opts.groupBy === "day" ? emptyDailyBuckets(opts.fromMs, opts.toMs) : null;
2564
+ const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2565
+ for (const record of records) {
2566
+ if (opts.include && !opts.include(record)) continue;
2567
+ const anchorMs = new Date(opts.anchor(record)).getTime();
2568
+ if (Number.isNaN(anchorMs)) continue;
2569
+ if (anchorMs < opts.fromMs || anchorMs > opts.toMs) continue;
2570
+ total++;
2571
+ bump(byProduct, record.productId);
2572
+ bump(byPlatform, record.platform);
2573
+ if (buckets && bucketIndex) {
2574
+ const idx = bucketIndex.get(utcDateKey(anchorMs));
2575
+ if (idx !== void 0) buckets[idx].count++;
2576
+ }
2577
+ }
2578
+ return { total, byProduct, byPlatform, ...buckets ? { buckets } : {} };
2579
+ }
2580
+ function isActiveSubscription(sub, nowMs) {
2581
+ const statusAllows = sub.status === SUBSCRIPTION_STATUS.ACTIVE || sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD;
2582
+ return statusAllows && new Date(sub.expiresAt).getTime() > nowMs;
2583
+ }
2584
+ function isEndedSubscription(sub) {
2585
+ return sub.status === SUBSCRIPTION_STATUS.EXPIRED || sub.status === SUBSCRIPTION_STATUS.CANCELED;
2586
+ }
2587
+ function isNonConsumable(purchase) {
2588
+ return purchase.type === PURCHASE_TYPE.NON_CONSUMABLE;
2589
+ }
2590
+ function aggregateActive(subs, purchases, nowMs) {
2591
+ const byProduct = {};
2592
+ const byProductPurchases = {};
2593
+ const byPlatform = {};
2594
+ let activeSubscriptions = 0;
2595
+ let gracePeriodSubscriptions = 0;
2596
+ let nonConsumablePurchases = 0;
2597
+ for (const sub of subs) {
2598
+ if (!isActiveSubscription(sub, nowMs)) continue;
2599
+ activeSubscriptions++;
2600
+ if (sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriodSubscriptions++;
2601
+ bump(byProduct, sub.productId);
2602
+ bump(byPlatform, sub.platform);
2603
+ }
2604
+ for (const purchase of purchases) {
2605
+ if (!isNonConsumable(purchase)) continue;
2606
+ nonConsumablePurchases++;
2607
+ bump(byProductPurchases, purchase.productId);
2608
+ bump(byPlatform, purchase.platform);
2609
+ }
2610
+ return {
2611
+ total: activeSubscriptions + nonConsumablePurchases,
2612
+ activeSubscriptions,
2613
+ gracePeriodSubscriptions,
2614
+ nonConsumablePurchases,
2615
+ byProduct,
2616
+ byProductPurchases,
2617
+ byPlatform
2618
+ };
2619
+ }
2620
+
2621
+ // src/metrics-cache.ts
2622
+ function isDisabled(ttlSeconds) {
2623
+ return !Number.isFinite(ttlSeconds) || ttlSeconds <= 0;
2624
+ }
2625
+ function createMetricsCache(ttlSeconds, storage = new InMemoryCacheAdapter()) {
2626
+ const inflight = /* @__PURE__ */ new Map();
2627
+ const gridMs = Math.max(1, Math.floor(ttlSeconds * 1e3));
2628
+ return {
2629
+ quantizeToWindow(ms) {
2630
+ if (isDisabled(ttlSeconds)) return ms;
2631
+ return Math.floor(ms / gridMs) * gridMs;
2632
+ },
2633
+ async resolve(keyParts, produce) {
2634
+ if (isDisabled(ttlSeconds)) return produce();
2635
+ const key = `metrics:${keyParts.join(":")}`;
2636
+ const cache = storage;
2637
+ const hit = await cache.get(key);
2638
+ if (hit !== null && hit !== void 0) return hit;
2639
+ const pending = inflight.get(key);
2640
+ if (pending) return pending;
2641
+ const promise = (async () => {
2642
+ const value = await produce();
2643
+ await cache.set(key, value, ttlSeconds);
2644
+ return value;
2645
+ })();
2646
+ inflight.set(key, promise);
2647
+ try {
2648
+ return await promise;
2649
+ } finally {
2650
+ inflight.delete(key);
2651
+ }
2652
+ }
2653
+ };
2654
+ }
2655
+
2656
+ // src/routes/metrics.ts
2445
2657
  var ADMIN_SECRET_HEADER2 = "x-admin-secret";
2658
+ var DEFAULT_METRICS_CACHE_TTL_SECONDS = 30;
2446
2659
  function createMetricsRouter(config, store, purchaseStore) {
2447
2660
  if (!config.adminSecret) return null;
2448
2661
  const router = Router();
2449
2662
  const adminSecret = config.adminSecret;
2663
+ const metricsCache = createMetricsCache(
2664
+ config.metricsCacheTtlSeconds ?? DEFAULT_METRICS_CACHE_TTL_SECONDS
2665
+ );
2450
2666
  router.use("/onesub/metrics", (req, res, next) => {
2451
2667
  const provided = req.headers[ADMIN_SECRET_HEADER2];
2452
2668
  if (typeof provided !== "string" || !secretsEqual(provided, adminSecret)) {
@@ -2455,50 +2671,15 @@ function createMetricsRouter(config, store, purchaseStore) {
2455
2671
  }
2456
2672
  next();
2457
2673
  });
2458
- const isActiveSub = (sub, now) => {
2459
- const statusAllows = sub.status === SUBSCRIPTION_STATUS.ACTIVE || sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD;
2460
- return statusAllows && new Date(sub.expiresAt).getTime() > now;
2461
- };
2462
- const bump = (map, key) => {
2463
- map[key] = (map[key] ?? 0) + 1;
2464
- };
2465
2674
  router.get(ROUTES.METRICS_ACTIVE, async (_req, res) => {
2466
2675
  try {
2467
- const [subs, purchases] = await Promise.all([
2468
- store.listAll(),
2469
- purchaseStore.listAll()
2470
- ]);
2471
- const now = Date.now();
2472
- const byProduct = {};
2473
- const byProductPurchases = {};
2474
- const byPlatform = {};
2475
- let activeSubscriptions = 0;
2476
- let gracePeriodSubscriptions = 0;
2477
- let nonConsumablePurchases = 0;
2478
- for (const sub of subs) {
2479
- if (!isActiveSub(sub, now)) continue;
2480
- activeSubscriptions++;
2481
- if (sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD) {
2482
- gracePeriodSubscriptions++;
2676
+ const response = await metricsCache.resolve(
2677
+ ["active", metricsCache.quantizeToWindow(Date.now())],
2678
+ async () => {
2679
+ const [subs, purchases] = await Promise.all([store.listAll(), purchaseStore.listAll()]);
2680
+ return aggregateActive(subs, purchases, Date.now());
2483
2681
  }
2484
- bump(byProduct, sub.productId);
2485
- bump(byPlatform, sub.platform);
2486
- }
2487
- for (const p of purchases) {
2488
- if (p.type !== PURCHASE_TYPE.NON_CONSUMABLE) continue;
2489
- nonConsumablePurchases++;
2490
- bump(byProductPurchases, p.productId);
2491
- bump(byPlatform, p.platform);
2492
- }
2493
- const response = {
2494
- total: activeSubscriptions + nonConsumablePurchases,
2495
- activeSubscriptions,
2496
- gracePeriodSubscriptions,
2497
- nonConsumablePurchases,
2498
- byProduct,
2499
- byProductPurchases,
2500
- byPlatform
2501
- };
2682
+ );
2502
2683
  res.status(200).json(response);
2503
2684
  } catch (err2) {
2504
2685
  log.error("[onesub/metrics/active] error:", err2);
@@ -2529,140 +2710,63 @@ function createMetricsRouter(config, store, purchaseStore) {
2529
2710
  }
2530
2711
  return { fromMs, toMs, groupBy };
2531
2712
  }
2532
- function utcDateKey(ms) {
2533
- const d = new Date(ms);
2534
- const yyyy = d.getUTCFullYear();
2535
- const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
2536
- const dd = String(d.getUTCDate()).padStart(2, "0");
2537
- return `${yyyy}-${mm}-${dd}`;
2538
- }
2539
- function emptyDailyBuckets(fromMs, toMs) {
2540
- const out = [];
2541
- const start = new Date(fromMs);
2542
- start.setUTCHours(0, 0, 0, 0);
2543
- let cur = start.getTime();
2544
- while (cur <= toMs) {
2545
- out.push({ date: utcDateKey(cur), count: 0 });
2546
- cur += 864e5;
2547
- }
2548
- return out;
2549
- }
2550
- router.get(ROUTES.METRICS_STARTED, async (req, res) => {
2551
- const range = parseRange(req);
2552
- if ("error" in range) {
2553
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
2554
- return;
2555
- }
2556
- try {
2557
- const subs = await store.listAll();
2558
- const byProduct = {};
2559
- const byPlatform = {};
2560
- let total = 0;
2561
- const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
2562
- const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2563
- for (const sub of subs) {
2564
- const purchasedMs = new Date(sub.purchasedAt).getTime();
2565
- if (purchasedMs < range.fromMs || purchasedMs > range.toMs) continue;
2566
- total++;
2567
- bump(byProduct, sub.productId);
2568
- bump(byPlatform, sub.platform);
2569
- if (buckets && bucketIndex) {
2570
- const idx = bucketIndex.get(utcDateKey(purchasedMs));
2571
- if (idx !== void 0) buckets[idx].count++;
2572
- }
2573
- }
2574
- const response = {
2575
- from: req.query["from"],
2576
- to: req.query["to"],
2577
- total,
2578
- byProduct,
2579
- byPlatform,
2580
- ...buckets ? { buckets } : {}
2581
- };
2582
- res.status(200).json(response);
2583
- } catch (err2) {
2584
- log.error("[onesub/metrics/started] error:", err2);
2585
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2586
- }
2587
- });
2588
- router.get(ROUTES.METRICS_EXPIRED, async (req, res) => {
2589
- const range = parseRange(req);
2590
- if ("error" in range) {
2591
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
2592
- return;
2593
- }
2594
- try {
2595
- const subs = await store.listAll();
2596
- const byProduct = {};
2597
- const byPlatform = {};
2598
- let total = 0;
2599
- const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
2600
- const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2601
- for (const sub of subs) {
2602
- if (sub.status !== SUBSCRIPTION_STATUS.EXPIRED && sub.status !== SUBSCRIPTION_STATUS.CANCELED) continue;
2603
- const expiredMs = new Date(sub.expiresAt).getTime();
2604
- if (expiredMs < range.fromMs || expiredMs > range.toMs) continue;
2605
- total++;
2606
- bump(byProduct, sub.productId);
2607
- bump(byPlatform, sub.platform);
2608
- if (buckets && bucketIndex) {
2609
- const idx = bucketIndex.get(utcDateKey(expiredMs));
2610
- if (idx !== void 0) buckets[idx].count++;
2611
- }
2713
+ function windowed(name, load, anchor, include) {
2714
+ return async (req, res) => {
2715
+ const range = parseRange(req);
2716
+ if ("error" in range) {
2717
+ sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
2718
+ return;
2612
2719
  }
2613
- const response = {
2614
- from: req.query["from"],
2615
- to: req.query["to"],
2616
- total,
2617
- byProduct,
2618
- byPlatform,
2619
- ...buckets ? { buckets } : {}
2620
- };
2621
- res.status(200).json(response);
2622
- } catch (err2) {
2623
- log.error("[onesub/metrics/expired] error:", err2);
2624
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2625
- }
2626
- });
2627
- router.get(ROUTES.METRICS_PURCHASES_STARTED, async (req, res) => {
2628
- const range = parseRange(req);
2629
- if ("error" in range) {
2630
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
2631
- return;
2632
- }
2633
- try {
2634
- const purchases = await purchaseStore.listAll();
2635
- const byProduct = {};
2636
- const byPlatform = {};
2637
- let total = 0;
2638
- const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
2639
- const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2640
- for (const p of purchases) {
2641
- if (p.type !== PURCHASE_TYPE.NON_CONSUMABLE) continue;
2642
- const purchasedMs = new Date(p.purchasedAt).getTime();
2643
- if (purchasedMs < range.fromMs || purchasedMs > range.toMs) continue;
2644
- total++;
2645
- bump(byProduct, p.productId);
2646
- bump(byPlatform, p.platform);
2647
- if (buckets && bucketIndex) {
2648
- const idx = bucketIndex.get(utcDateKey(purchasedMs));
2649
- if (idx !== void 0) buckets[idx].count++;
2650
- }
2720
+ try {
2721
+ const aggregate = await metricsCache.resolve(
2722
+ [
2723
+ name,
2724
+ metricsCache.quantizeToWindow(range.fromMs),
2725
+ metricsCache.quantizeToWindow(range.toMs),
2726
+ range.groupBy
2727
+ ],
2728
+ async () => aggregateRange(await load(), {
2729
+ fromMs: range.fromMs,
2730
+ toMs: range.toMs,
2731
+ groupBy: range.groupBy,
2732
+ anchor,
2733
+ ...include ? { include } : {}
2734
+ })
2735
+ );
2736
+ const response = {
2737
+ from: req.query["from"],
2738
+ to: req.query["to"],
2739
+ ...aggregate
2740
+ };
2741
+ res.status(200).json(response);
2742
+ } catch (err2) {
2743
+ log.error(`[onesub/metrics/${name}] error:`, err2);
2744
+ sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2651
2745
  }
2652
- const response = {
2653
- from: req.query["from"],
2654
- to: req.query["to"],
2655
- total,
2656
- byProduct,
2657
- byPlatform,
2658
- ...buckets ? { buckets } : {}
2659
- };
2660
- res.status(200).json(response);
2661
- } catch (err2) {
2662
- log.error("[onesub/metrics/purchases/started] error:", err2);
2663
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2664
- }
2665
- });
2746
+ };
2747
+ }
2748
+ router.get(
2749
+ ROUTES.METRICS_STARTED,
2750
+ windowed("started", () => store.listAll(), (sub) => sub.purchasedAt)
2751
+ );
2752
+ router.get(
2753
+ ROUTES.METRICS_EXPIRED,
2754
+ windowed(
2755
+ "expired",
2756
+ () => store.listAll(),
2757
+ (sub) => sub.expiresAt,
2758
+ isEndedSubscription
2759
+ )
2760
+ );
2761
+ router.get(
2762
+ ROUTES.METRICS_PURCHASES_STARTED,
2763
+ windowed(
2764
+ "purchases-started",
2765
+ () => purchaseStore.listAll(),
2766
+ (purchase) => purchase.purchasedAt,
2767
+ isNonConsumable
2768
+ )
2769
+ );
2666
2770
  return router;
2667
2771
  }
2668
2772
  var OFFER_SECRET_HEADER = "x-onesub-offer-secret";
@@ -3010,6 +3114,26 @@ var PostgresPurchaseStore = class {
3010
3114
  );
3011
3115
  return result.rows.map(rowToPurchaseInfo);
3012
3116
  }
3117
+ /**
3118
+ * Purchases for one user + product, most-recent-first.
3119
+ *
3120
+ * Same shape and ordering as `getPurchasesByUserId`, with `product_id` pushed
3121
+ * into the WHERE clause instead of filtered in process. Non-consumables also
3122
+ * have a partial unique index on `(user_id, product_id)`, so the ownership
3123
+ * check this backs is a single index lookup rather than a full read of the
3124
+ * user's purchase history.
3125
+ */
3126
+ async getPurchasesForProduct(userId, productId) {
3127
+ const pool = await this.getPool();
3128
+ const result = await pool.query(
3129
+ `SELECT *
3130
+ FROM onesub_purchases
3131
+ WHERE user_id = $1 AND product_id = $2
3132
+ ORDER BY purchased_at DESC`,
3133
+ [userId, productId]
3134
+ );
3135
+ return result.rows.map(rowToPurchaseInfo);
3136
+ }
3013
3137
  async getPurchaseByTransactionId(txId) {
3014
3138
  const pool = await this.getPool();
3015
3139
  const result = await pool.query(
@@ -3220,6 +3344,23 @@ var RedisPurchaseStore = class {
3220
3344
  const raws = await this.redis.mget(...ids.map((id) => PUR_TX_PREFIX + id));
3221
3345
  return raws.filter((r) => r != null).map((r) => JSON.parse(r));
3222
3346
  }
3347
+ /**
3348
+ * Purchases for one user + product, most-recent-first.
3349
+ *
3350
+ * Served from the `user_product` set that `savePurchase` already maintains for
3351
+ * `hasPurchased`, so this reads only the rows for this product rather than the
3352
+ * user's whole purchase history.
3353
+ *
3354
+ * That set is unordered — unlike the per-user sorted set — so the
3355
+ * most-recent-first contract is restored by an explicit sort here. The row
3356
+ * count is per-product, so this is small.
3357
+ */
3358
+ async getPurchasesForProduct(userId, productId) {
3359
+ const ids = await this.redis.smembers(PUR_USER_PRODUCT_PREFIX + userId + ":" + productId);
3360
+ if (ids.length === 0) return [];
3361
+ const raws = await this.redis.mget(...ids.map((id) => PUR_TX_PREFIX + id));
3362
+ return raws.filter((r) => r != null).map((r) => JSON.parse(r)).sort((a, b) => Date.parse(b.purchasedAt) - Date.parse(a.purchasedAt));
3363
+ }
3223
3364
  async getPurchaseByTransactionId(txId) {
3224
3365
  const raw = await this.redis.get(PUR_TX_PREFIX + txId);
3225
3366
  return raw ? JSON.parse(raw) : null;
@@ -4126,6 +4267,6 @@ if (isMain) {
4126
4267
  });
4127
4268
  }
4128
4269
 
4129
- export { BullMQWebhookQueue, CacheWebhookEventStore, InMemoryCacheAdapter, InMemoryPurchaseStore, InMemorySubscriptionStore, InMemoryWebhookEventStore, InProcessWebhookQueue, ONESUB_OPENAPI, PostgresPurchaseStore, PostgresSubscriptionStore, RedisCacheAdapter, RedisPurchaseStore, RedisSubscriptionStore, RedisWebhookEventStore, createOneSubMiddleware, createOneSubServer, evaluateEntitlement, fetchAppleSubscriptionStatus, fetchAppleTransactionHistory, getDefaultCache, log, openapiHandler, processAppleNotification, processGoogleNotification, setDefaultCache, setLogger, signApplePromotionalOffer, unmarkWebhookEvent, validateAppleReceipt, validateGoogleReceipt, withSpan };
4270
+ export { BullMQWebhookQueue, CacheWebhookEventStore, InMemoryCacheAdapter, InMemoryPurchaseStore, InMemorySubscriptionStore, InMemoryWebhookEventStore, InProcessWebhookQueue, ONESUB_OPENAPI, PostgresPurchaseStore, PostgresSubscriptionStore, RedisCacheAdapter, RedisPurchaseStore, RedisSubscriptionStore, RedisWebhookEventStore, createOneSubMiddleware, createOneSubServer, evaluateEntitlement, evaluateEntitlementFrom, fetchAppleSubscriptionStatus, fetchAppleTransactionHistory, getDefaultCache, log, openapiHandler, processAppleNotification, processGoogleNotification, setDefaultCache, setLogger, signApplePromotionalOffer, unmarkWebhookEvent, validateAppleReceipt, validateGoogleReceipt, withSpan };
4130
4271
  //# sourceMappingURL=index.js.map
4131
4272
  //# sourceMappingURL=index.js.map