@onesub/server 0.19.0 → 0.20.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.
package/dist/index.cjs CHANGED
@@ -180,9 +180,13 @@ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
180
180
  async function fetchWithTimeout(input, init, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
181
181
  const controller = new AbortController();
182
182
  const callerSignal = init?.signal;
183
+ let onCallerAbort;
183
184
  if (callerSignal) {
184
185
  if (callerSignal.aborted) controller.abort(callerSignal.reason);
185
- else callerSignal.addEventListener("abort", () => controller.abort(callerSignal.reason), { once: true });
186
+ else {
187
+ onCallerAbort = () => controller.abort(callerSignal.reason);
188
+ callerSignal.addEventListener("abort", onCallerAbort, { once: true });
189
+ }
186
190
  }
187
191
  const timer = setTimeout(() => {
188
192
  controller.abort(new Error(`[onesub] fetch timed out after ${timeoutMs}ms`));
@@ -191,6 +195,7 @@ async function fetchWithTimeout(input, init, timeoutMs = DEFAULT_FETCH_TIMEOUT_M
191
195
  return await fetch(input, { ...init, signal: controller.signal });
192
196
  } finally {
193
197
  clearTimeout(timer);
198
+ if (onCallerAbort) callerSignal?.removeEventListener("abort", onCallerAbort);
194
199
  }
195
200
  }
196
201
 
@@ -243,6 +248,70 @@ function getDefaultCache() {
243
248
  function setDefaultCache(adapter) {
244
249
  defaultAdapter = adapter;
245
250
  }
251
+ var DEFAULT_MAX_ENTRIES = 32;
252
+ var DEFAULT_MAX_TTL_MS = 60 * 60 * 1e3;
253
+ var VerifiedKeyCache = class {
254
+ entries = /* @__PURE__ */ new Map();
255
+ maxEntries;
256
+ maxTtlMs;
257
+ now;
258
+ constructor(opts = {}) {
259
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
260
+ this.maxTtlMs = opts.maxTtlMs ?? DEFAULT_MAX_TTL_MS;
261
+ this.now = opts.now ?? Date.now;
262
+ }
263
+ /**
264
+ * Return the key for `parts`, verifying via `produce` only on a miss.
265
+ *
266
+ * `parts` must contain every input the verification depends on — for Apple
267
+ * that is the JWS `alg` plus the full x5c chain. Anything left out would let
268
+ * one input's result be served for a different input.
269
+ */
270
+ async resolve(parts, produce) {
271
+ const cacheKey = digest(parts);
272
+ const now = this.now();
273
+ const hit = this.entries.get(cacheKey);
274
+ if (hit) {
275
+ if (hit.expiresAt > now) return hit.key;
276
+ this.entries.delete(cacheKey);
277
+ }
278
+ const verified = await produce();
279
+ const expiresAt = Math.min(now + this.maxTtlMs, verified.notAfter);
280
+ if (expiresAt > now) {
281
+ this.prune(now);
282
+ this.entries.set(cacheKey, { key: verified.key, expiresAt });
283
+ }
284
+ return verified.key;
285
+ }
286
+ /** Entries currently held, including any not yet pruned. Test/diagnostic use. */
287
+ get size() {
288
+ return this.entries.size;
289
+ }
290
+ /** Drop everything. Test/diagnostic use. */
291
+ clear() {
292
+ this.entries.clear();
293
+ }
294
+ /** Make room: expired entries first, then the oldest insertions. */
295
+ prune(now) {
296
+ for (const [key, entry] of this.entries) {
297
+ if (entry.expiresAt <= now) this.entries.delete(key);
298
+ }
299
+ while (this.entries.size >= this.maxEntries) {
300
+ const oldest = this.entries.keys().next();
301
+ if (oldest.done) break;
302
+ this.entries.delete(oldest.value);
303
+ }
304
+ }
305
+ };
306
+ function digest(parts) {
307
+ const hash = crypto.createHash("sha256");
308
+ for (const part of parts) {
309
+ hash.update(String(part.length));
310
+ hash.update(":");
311
+ hash.update(part);
312
+ }
313
+ return hash.digest("base64");
314
+ }
246
315
  function classifyMockReceipt(receipt) {
247
316
  if (receipt.startsWith(shared.MOCK_RECEIPT_PREFIX.NETWORK_ERROR)) return { kind: "network-error" };
248
317
  if (receipt.startsWith(shared.MOCK_RECEIPT_PREFIX.REVOKED)) return { kind: "revoked" };
@@ -262,8 +331,8 @@ function outcomePasses(outcome, tag) {
262
331
  return false;
263
332
  }
264
333
  function deterministicTransactionId(prefix, receipt) {
265
- const digest = crypto.createHash("sha256").update(receipt).digest("hex").slice(0, 24);
266
- return `${prefix}_${digest}`;
334
+ const digest2 = crypto.createHash("sha256").update(receipt).digest("hex").slice(0, 24);
335
+ return `${prefix}_${digest2}`;
267
336
  }
268
337
  function extractBoundToken(receipt) {
269
338
  return receipt.match(/#token=([^#]+)/)?.[1];
@@ -353,11 +422,15 @@ function verifyAppleCertChain(x5c) {
353
422
  }
354
423
  const chain = x5c.map((der) => new crypto.X509Certificate(derBase64ToPem(der)));
355
424
  const now = /* @__PURE__ */ new Date();
425
+ let notAfter = Infinity;
356
426
  for (let i = 0; i < chain.length; i++) {
357
427
  const cert = chain[i];
358
- if (new Date(cert.validFrom) > now || new Date(cert.validTo) < now) {
428
+ const validTo = new Date(cert.validTo).getTime();
429
+ if (new Date(cert.validFrom) > now || validTo < now.getTime()) {
359
430
  throw new Error(`[onesub/apple] cert[${i}] outside validity window`);
360
431
  }
432
+ if (Number.isNaN(validTo)) notAfter = 0;
433
+ else if (validTo < notAfter) notAfter = validTo;
361
434
  if (i >= 1) assertIssuerCanSign(cert, i);
362
435
  if (i + 1 < chain.length) {
363
436
  if (!cert.checkIssued(chain[i + 1]) || !cert.verify(chain[i + 1].publicKey)) {
@@ -380,8 +453,9 @@ function verifyAppleCertChain(x5c) {
380
453
  if (!trustsRoot) {
381
454
  throw new Error("[onesub/apple] cert chain does not terminate at a trusted Apple root");
382
455
  }
383
- return chain[0].toString();
456
+ return { leafPem: chain[0].toString(), notAfter };
384
457
  }
458
+ var appleLeafKeys = new VerifiedKeyCache();
385
459
  async function decodeJws(jws, skipVerification = false) {
386
460
  if (skipVerification) {
387
461
  if (process.env["NODE_ENV"] === "production") {
@@ -396,9 +470,11 @@ async function decodeJws(jws, skipVerification = false) {
396
470
  if (!x5c || x5c.length === 0) {
397
471
  throw new Error("[onesub/apple] JWS header missing x5c certificate chain");
398
472
  }
399
- const leafPem = verifyAppleCertChain(x5c);
400
473
  const alg = header.alg ?? "ES256";
401
- const key = await jose.importX509(leafPem, alg);
474
+ const key = await appleLeafKeys.resolve([alg, ...x5c], async () => {
475
+ const { leafPem, notAfter } = verifyAppleCertChain(x5c);
476
+ return { key: await jose.importX509(leafPem, alg), notAfter };
477
+ });
402
478
  const { payload } = await jose.jwtVerify(jws, key);
403
479
  return payload;
404
480
  }
@@ -2071,10 +2147,8 @@ function createPurchaseRouter(config, purchaseStore) {
2071
2147
  });
2072
2148
  return router;
2073
2149
  }
2074
- async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2150
+ function evaluateEntitlementFrom(subs, purchases, entitlement, now = Date.now()) {
2075
2151
  const productIdSet = new Set(entitlement.productIds);
2076
- const now = Date.now();
2077
- const subs = await store.getAllByUserId(userId);
2078
2152
  for (const sub of subs) {
2079
2153
  if (!productIdSet.has(sub.productId)) continue;
2080
2154
  const statusAllows = sub.status === shared.SUBSCRIPTION_STATUS.ACTIVE || sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
@@ -2087,7 +2161,6 @@ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2087
2161
  expiresAt: sub.expiresAt
2088
2162
  };
2089
2163
  }
2090
- const purchases = await purchaseStore.getPurchasesByUserId(userId);
2091
2164
  for (const p of purchases) {
2092
2165
  if (p.type !== shared.PURCHASE_TYPE.NON_CONSUMABLE) continue;
2093
2166
  if (!productIdSet.has(p.productId)) continue;
@@ -2099,6 +2172,14 @@ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2099
2172
  }
2100
2173
  return { active: false, source: null };
2101
2174
  }
2175
+ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2176
+ const now = Date.now();
2177
+ const subs = await store.getAllByUserId(userId);
2178
+ const fromSubs = evaluateEntitlementFrom(subs, [], entitlement, now);
2179
+ if (fromSubs.active) return fromSubs;
2180
+ const purchases = await purchaseStore.getPurchasesByUserId(userId);
2181
+ return evaluateEntitlementFrom([], purchases, entitlement, now);
2182
+ }
2102
2183
  var userIdSchema = zod.z.string().min(1).max(256);
2103
2184
  var entitlementIdSchema = zod.z.string().min(1).max(128);
2104
2185
  function createEntitlementRouter(config, store, purchaseStore) {
@@ -2140,11 +2221,13 @@ function createEntitlementRouter(config, store, purchaseStore) {
2140
2221
  return;
2141
2222
  }
2142
2223
  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
- })
2224
+ const [subs, purchases] = await Promise.all([
2225
+ store.getAllByUserId(userId),
2226
+ purchaseStore.getPurchasesByUserId(userId)
2227
+ ]);
2228
+ const now = Date.now();
2229
+ const entries = Object.entries(entitlements).map(
2230
+ ([id, entitlement]) => [id, evaluateEntitlementFrom(subs, purchases, entitlement, now)]
2148
2231
  );
2149
2232
  const response = {
2150
2233
  entitlements: Object.fromEntries(entries)
@@ -2335,9 +2418,10 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2335
2418
  ]);
2336
2419
  let entitlements;
2337
2420
  if (config.entitlements && Object.keys(config.entitlements).length > 0) {
2421
+ const now = Date.now();
2338
2422
  entitlements = {};
2339
2423
  for (const [id, def] of Object.entries(config.entitlements)) {
2340
- entitlements[id] = await evaluateEntitlement(params.userId, def, store, purchaseStore);
2424
+ entitlements[id] = evaluateEntitlementFrom(subscriptions, purchases, def, now);
2341
2425
  }
2342
2426
  }
2343
2427
  const response = {
@@ -2449,11 +2533,135 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2449
2533
  }
2450
2534
  return router;
2451
2535
  }
2536
+ var MS_PER_DAY = 864e5;
2537
+ function bump(map, key) {
2538
+ map[key] = (map[key] ?? 0) + 1;
2539
+ }
2540
+ function utcDateKey(ms) {
2541
+ const d = new Date(ms);
2542
+ const yyyy = d.getUTCFullYear();
2543
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
2544
+ const dd = String(d.getUTCDate()).padStart(2, "0");
2545
+ return `${yyyy}-${mm}-${dd}`;
2546
+ }
2547
+ function emptyDailyBuckets(fromMs, toMs) {
2548
+ const out = [];
2549
+ const start = new Date(fromMs);
2550
+ start.setUTCHours(0, 0, 0, 0);
2551
+ let cur = start.getTime();
2552
+ while (cur <= toMs) {
2553
+ out.push({ date: utcDateKey(cur), count: 0 });
2554
+ cur += MS_PER_DAY;
2555
+ }
2556
+ return out;
2557
+ }
2558
+ function aggregateRange(records, opts) {
2559
+ const byProduct = {};
2560
+ const byPlatform = {};
2561
+ let total = 0;
2562
+ const buckets = opts.groupBy === "day" ? emptyDailyBuckets(opts.fromMs, opts.toMs) : null;
2563
+ const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2564
+ for (const record of records) {
2565
+ if (opts.include && !opts.include(record)) continue;
2566
+ const anchorMs = new Date(opts.anchor(record)).getTime();
2567
+ if (Number.isNaN(anchorMs)) continue;
2568
+ if (anchorMs < opts.fromMs || anchorMs > opts.toMs) continue;
2569
+ total++;
2570
+ bump(byProduct, record.productId);
2571
+ bump(byPlatform, record.platform);
2572
+ if (buckets && bucketIndex) {
2573
+ const idx = bucketIndex.get(utcDateKey(anchorMs));
2574
+ if (idx !== void 0) buckets[idx].count++;
2575
+ }
2576
+ }
2577
+ return { total, byProduct, byPlatform, ...buckets ? { buckets } : {} };
2578
+ }
2579
+ function isActiveSubscription(sub, nowMs) {
2580
+ const statusAllows = sub.status === shared.SUBSCRIPTION_STATUS.ACTIVE || sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
2581
+ return statusAllows && new Date(sub.expiresAt).getTime() > nowMs;
2582
+ }
2583
+ function isEndedSubscription(sub) {
2584
+ return sub.status === shared.SUBSCRIPTION_STATUS.EXPIRED || sub.status === shared.SUBSCRIPTION_STATUS.CANCELED;
2585
+ }
2586
+ function isNonConsumable(purchase) {
2587
+ return purchase.type === shared.PURCHASE_TYPE.NON_CONSUMABLE;
2588
+ }
2589
+ function aggregateActive(subs, purchases, nowMs) {
2590
+ const byProduct = {};
2591
+ const byProductPurchases = {};
2592
+ const byPlatform = {};
2593
+ let activeSubscriptions = 0;
2594
+ let gracePeriodSubscriptions = 0;
2595
+ let nonConsumablePurchases = 0;
2596
+ for (const sub of subs) {
2597
+ if (!isActiveSubscription(sub, nowMs)) continue;
2598
+ activeSubscriptions++;
2599
+ if (sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriodSubscriptions++;
2600
+ bump(byProduct, sub.productId);
2601
+ bump(byPlatform, sub.platform);
2602
+ }
2603
+ for (const purchase of purchases) {
2604
+ if (!isNonConsumable(purchase)) continue;
2605
+ nonConsumablePurchases++;
2606
+ bump(byProductPurchases, purchase.productId);
2607
+ bump(byPlatform, purchase.platform);
2608
+ }
2609
+ return {
2610
+ total: activeSubscriptions + nonConsumablePurchases,
2611
+ activeSubscriptions,
2612
+ gracePeriodSubscriptions,
2613
+ nonConsumablePurchases,
2614
+ byProduct,
2615
+ byProductPurchases,
2616
+ byPlatform
2617
+ };
2618
+ }
2619
+
2620
+ // src/metrics-cache.ts
2621
+ function isDisabled(ttlSeconds) {
2622
+ return !Number.isFinite(ttlSeconds) || ttlSeconds <= 0;
2623
+ }
2624
+ function createMetricsCache(ttlSeconds, storage = new InMemoryCacheAdapter()) {
2625
+ const inflight = /* @__PURE__ */ new Map();
2626
+ const gridMs = Math.max(1, Math.floor(ttlSeconds * 1e3));
2627
+ return {
2628
+ quantizeToWindow(ms) {
2629
+ if (isDisabled(ttlSeconds)) return ms;
2630
+ return Math.floor(ms / gridMs) * gridMs;
2631
+ },
2632
+ async resolve(keyParts, produce) {
2633
+ if (isDisabled(ttlSeconds)) return produce();
2634
+ const key = `metrics:${keyParts.join(":")}`;
2635
+ const cache = storage;
2636
+ const hit = await cache.get(key);
2637
+ if (hit !== null && hit !== void 0) return hit;
2638
+ const pending = inflight.get(key);
2639
+ if (pending) return pending;
2640
+ const promise = (async () => {
2641
+ const value = await produce();
2642
+ await cache.set(key, value, ttlSeconds);
2643
+ return value;
2644
+ })();
2645
+ inflight.set(key, promise);
2646
+ try {
2647
+ return await promise;
2648
+ } finally {
2649
+ inflight.delete(key);
2650
+ }
2651
+ }
2652
+ };
2653
+ }
2654
+
2655
+ // src/routes/metrics.ts
2452
2656
  var ADMIN_SECRET_HEADER2 = "x-admin-secret";
2657
+ var DEFAULT_METRICS_CACHE_TTL_SECONDS = 30;
2453
2658
  function createMetricsRouter(config, store, purchaseStore) {
2454
2659
  if (!config.adminSecret) return null;
2455
2660
  const router = express.Router();
2456
2661
  const adminSecret = config.adminSecret;
2662
+ const metricsCache = createMetricsCache(
2663
+ config.metricsCacheTtlSeconds ?? DEFAULT_METRICS_CACHE_TTL_SECONDS
2664
+ );
2457
2665
  router.use("/onesub/metrics", (req, res, next) => {
2458
2666
  const provided = req.headers[ADMIN_SECRET_HEADER2];
2459
2667
  if (typeof provided !== "string" || !secretsEqual(provided, adminSecret)) {
@@ -2462,50 +2670,15 @@ function createMetricsRouter(config, store, purchaseStore) {
2462
2670
  }
2463
2671
  next();
2464
2672
  });
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
2673
  router.get(shared.ROUTES.METRICS_ACTIVE, async (_req, res) => {
2473
2674
  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++;
2675
+ const response = await metricsCache.resolve(
2676
+ ["active", metricsCache.quantizeToWindow(Date.now())],
2677
+ async () => {
2678
+ const [subs, purchases] = await Promise.all([store.listAll(), purchaseStore.listAll()]);
2679
+ return aggregateActive(subs, purchases, Date.now());
2490
2680
  }
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
- };
2681
+ );
2509
2682
  res.status(200).json(response);
2510
2683
  } catch (err2) {
2511
2684
  log.error("[onesub/metrics/active] error:", err2);
@@ -2536,140 +2709,63 @@ function createMetricsRouter(config, store, purchaseStore) {
2536
2709
  }
2537
2710
  return { fromMs, toMs, groupBy };
2538
2711
  }
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
- }
2712
+ function windowed(name, load, anchor, include) {
2713
+ return async (req, res) => {
2714
+ const range = parseRange(req);
2715
+ if ("error" in range) {
2716
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
2717
+ return;
2619
2718
  }
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
- }
2719
+ try {
2720
+ const aggregate = await metricsCache.resolve(
2721
+ [
2722
+ name,
2723
+ metricsCache.quantizeToWindow(range.fromMs),
2724
+ metricsCache.quantizeToWindow(range.toMs),
2725
+ range.groupBy
2726
+ ],
2727
+ async () => aggregateRange(await load(), {
2728
+ fromMs: range.fromMs,
2729
+ toMs: range.toMs,
2730
+ groupBy: range.groupBy,
2731
+ anchor,
2732
+ ...include ? { include } : {}
2733
+ })
2734
+ );
2735
+ const response = {
2736
+ from: req.query["from"],
2737
+ to: req.query["to"],
2738
+ ...aggregate
2739
+ };
2740
+ res.status(200).json(response);
2741
+ } catch (err2) {
2742
+ log.error(`[onesub/metrics/${name}] error:`, err2);
2743
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2658
2744
  }
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
- });
2745
+ };
2746
+ }
2747
+ router.get(
2748
+ shared.ROUTES.METRICS_STARTED,
2749
+ windowed("started", () => store.listAll(), (sub) => sub.purchasedAt)
2750
+ );
2751
+ router.get(
2752
+ shared.ROUTES.METRICS_EXPIRED,
2753
+ windowed(
2754
+ "expired",
2755
+ () => store.listAll(),
2756
+ (sub) => sub.expiresAt,
2757
+ isEndedSubscription
2758
+ )
2759
+ );
2760
+ router.get(
2761
+ shared.ROUTES.METRICS_PURCHASES_STARTED,
2762
+ windowed(
2763
+ "purchases-started",
2764
+ () => purchaseStore.listAll(),
2765
+ (purchase) => purchase.purchasedAt,
2766
+ isNonConsumable
2767
+ )
2768
+ );
2673
2769
  return router;
2674
2770
  }
2675
2771
  var OFFER_SECRET_HEADER = "x-onesub-offer-secret";
@@ -4150,6 +4246,7 @@ exports.RedisWebhookEventStore = RedisWebhookEventStore;
4150
4246
  exports.createOneSubMiddleware = createOneSubMiddleware;
4151
4247
  exports.createOneSubServer = createOneSubServer;
4152
4248
  exports.evaluateEntitlement = evaluateEntitlement;
4249
+ exports.evaluateEntitlementFrom = evaluateEntitlementFrom;
4153
4250
  exports.fetchAppleSubscriptionStatus = fetchAppleSubscriptionStatus;
4154
4251
  exports.fetchAppleTransactionHistory = fetchAppleTransactionHistory;
4155
4252
  exports.getDefaultCache = getDefaultCache;