@onesub/server 0.18.3 → 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.
Files changed (38) hide show
  1. package/README.md +4 -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__/test-overrides.test.d.ts +15 -0
  9. package/dist/__tests__/test-overrides.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 +397 -191
  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 +398 -193
  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/openapi.d.ts.map +1 -1
  24. package/dist/providers/apple.d.ts +2 -0
  25. package/dist/providers/apple.d.ts.map +1 -1
  26. package/dist/providers/verified-key-cache.d.ts +75 -0
  27. package/dist/providers/verified-key-cache.d.ts.map +1 -0
  28. package/dist/routes/admin.d.ts.map +1 -1
  29. package/dist/routes/entitlements.d.ts +18 -1
  30. package/dist/routes/entitlements.d.ts.map +1 -1
  31. package/dist/routes/metrics.d.ts +8 -3
  32. package/dist/routes/metrics.d.ts.map +1 -1
  33. package/dist/routes/validate.d.ts.map +1 -1
  34. package/dist/store.d.ts +3 -2
  35. package/dist/store.d.ts.map +1 -1
  36. package/dist/test-overrides.d.ts +36 -0
  37. package/dist/test-overrides.d.ts.map +1 -0
  38. 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, {
@@ -173,9 +173,13 @@ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
173
173
  async function fetchWithTimeout(input, init, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
174
174
  const controller = new AbortController();
175
175
  const callerSignal = init?.signal;
176
+ let onCallerAbort;
176
177
  if (callerSignal) {
177
178
  if (callerSignal.aborted) controller.abort(callerSignal.reason);
178
- else callerSignal.addEventListener("abort", () => controller.abort(callerSignal.reason), { once: true });
179
+ else {
180
+ onCallerAbort = () => controller.abort(callerSignal.reason);
181
+ callerSignal.addEventListener("abort", onCallerAbort, { once: true });
182
+ }
179
183
  }
180
184
  const timer = setTimeout(() => {
181
185
  controller.abort(new Error(`[onesub] fetch timed out after ${timeoutMs}ms`));
@@ -184,6 +188,7 @@ async function fetchWithTimeout(input, init, timeoutMs = DEFAULT_FETCH_TIMEOUT_M
184
188
  return await fetch(input, { ...init, signal: controller.signal });
185
189
  } finally {
186
190
  clearTimeout(timer);
191
+ if (onCallerAbort) callerSignal?.removeEventListener("abort", onCallerAbort);
187
192
  }
188
193
  }
189
194
 
@@ -236,6 +241,70 @@ function getDefaultCache() {
236
241
  function setDefaultCache(adapter) {
237
242
  defaultAdapter = adapter;
238
243
  }
244
+ var DEFAULT_MAX_ENTRIES = 32;
245
+ var DEFAULT_MAX_TTL_MS = 60 * 60 * 1e3;
246
+ var VerifiedKeyCache = class {
247
+ entries = /* @__PURE__ */ new Map();
248
+ maxEntries;
249
+ maxTtlMs;
250
+ now;
251
+ constructor(opts = {}) {
252
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
253
+ this.maxTtlMs = opts.maxTtlMs ?? DEFAULT_MAX_TTL_MS;
254
+ this.now = opts.now ?? Date.now;
255
+ }
256
+ /**
257
+ * Return the key for `parts`, verifying via `produce` only on a miss.
258
+ *
259
+ * `parts` must contain every input the verification depends on — for Apple
260
+ * that is the JWS `alg` plus the full x5c chain. Anything left out would let
261
+ * one input's result be served for a different input.
262
+ */
263
+ async resolve(parts, produce) {
264
+ const cacheKey = digest(parts);
265
+ const now = this.now();
266
+ const hit = this.entries.get(cacheKey);
267
+ if (hit) {
268
+ if (hit.expiresAt > now) return hit.key;
269
+ this.entries.delete(cacheKey);
270
+ }
271
+ const verified = await produce();
272
+ const expiresAt = Math.min(now + this.maxTtlMs, verified.notAfter);
273
+ if (expiresAt > now) {
274
+ this.prune(now);
275
+ this.entries.set(cacheKey, { key: verified.key, expiresAt });
276
+ }
277
+ return verified.key;
278
+ }
279
+ /** Entries currently held, including any not yet pruned. Test/diagnostic use. */
280
+ get size() {
281
+ return this.entries.size;
282
+ }
283
+ /** Drop everything. Test/diagnostic use. */
284
+ clear() {
285
+ this.entries.clear();
286
+ }
287
+ /** Make room: expired entries first, then the oldest insertions. */
288
+ prune(now) {
289
+ for (const [key, entry] of this.entries) {
290
+ if (entry.expiresAt <= now) this.entries.delete(key);
291
+ }
292
+ while (this.entries.size >= this.maxEntries) {
293
+ const oldest = this.entries.keys().next();
294
+ if (oldest.done) break;
295
+ this.entries.delete(oldest.value);
296
+ }
297
+ }
298
+ };
299
+ function digest(parts) {
300
+ const hash = createHash("sha256");
301
+ for (const part of parts) {
302
+ hash.update(String(part.length));
303
+ hash.update(":");
304
+ hash.update(part);
305
+ }
306
+ return hash.digest("base64");
307
+ }
239
308
  function classifyMockReceipt(receipt) {
240
309
  if (receipt.startsWith(MOCK_RECEIPT_PREFIX.NETWORK_ERROR)) return { kind: "network-error" };
241
310
  if (receipt.startsWith(MOCK_RECEIPT_PREFIX.REVOKED)) return { kind: "revoked" };
@@ -255,8 +324,8 @@ function outcomePasses(outcome, tag) {
255
324
  return false;
256
325
  }
257
326
  function deterministicTransactionId(prefix, receipt) {
258
- const digest = createHash("sha256").update(receipt).digest("hex").slice(0, 24);
259
- return `${prefix}_${digest}`;
327
+ const digest2 = createHash("sha256").update(receipt).digest("hex").slice(0, 24);
328
+ return `${prefix}_${digest2}`;
260
329
  }
261
330
  function extractBoundToken(receipt) {
262
331
  return receipt.match(/#token=([^#]+)/)?.[1];
@@ -346,11 +415,15 @@ function verifyAppleCertChain(x5c) {
346
415
  }
347
416
  const chain = x5c.map((der) => new X509Certificate(derBase64ToPem(der)));
348
417
  const now = /* @__PURE__ */ new Date();
418
+ let notAfter = Infinity;
349
419
  for (let i = 0; i < chain.length; i++) {
350
420
  const cert = chain[i];
351
- if (new Date(cert.validFrom) > now || new Date(cert.validTo) < now) {
421
+ const validTo = new Date(cert.validTo).getTime();
422
+ if (new Date(cert.validFrom) > now || validTo < now.getTime()) {
352
423
  throw new Error(`[onesub/apple] cert[${i}] outside validity window`);
353
424
  }
425
+ if (Number.isNaN(validTo)) notAfter = 0;
426
+ else if (validTo < notAfter) notAfter = validTo;
354
427
  if (i >= 1) assertIssuerCanSign(cert, i);
355
428
  if (i + 1 < chain.length) {
356
429
  if (!cert.checkIssued(chain[i + 1]) || !cert.verify(chain[i + 1].publicKey)) {
@@ -373,8 +446,9 @@ function verifyAppleCertChain(x5c) {
373
446
  if (!trustsRoot) {
374
447
  throw new Error("[onesub/apple] cert chain does not terminate at a trusted Apple root");
375
448
  }
376
- return chain[0].toString();
449
+ return { leafPem: chain[0].toString(), notAfter };
377
450
  }
451
+ var appleLeafKeys = new VerifiedKeyCache();
378
452
  async function decodeJws(jws, skipVerification = false) {
379
453
  if (skipVerification) {
380
454
  if (process.env["NODE_ENV"] === "production") {
@@ -389,9 +463,11 @@ async function decodeJws(jws, skipVerification = false) {
389
463
  if (!x5c || x5c.length === 0) {
390
464
  throw new Error("[onesub/apple] JWS header missing x5c certificate chain");
391
465
  }
392
- const leafPem = verifyAppleCertChain(x5c);
393
466
  const alg = header.alg ?? "ES256";
394
- const key = await importX509(leafPem, alg);
467
+ const key = await appleLeafKeys.resolve([alg, ...x5c], async () => {
468
+ const { leafPem, notAfter } = verifyAppleCertChain(x5c);
469
+ return { key: await importX509(leafPem, alg), notAfter };
470
+ });
395
471
  const { payload } = await jwtVerify(jws, key);
396
472
  return payload;
397
473
  }
@@ -451,7 +527,10 @@ async function validateAppleReceipt(receipt, config) {
451
527
  purchasedAt: new Date(purchasedAt).toISOString(),
452
528
  willRenew: status === SUBSCRIPTION_STATUS.ACTIVE,
453
529
  // refined by renewal info in webhook
454
- ...appAccountToken ? { boundAccountId: appAccountToken } : {}
530
+ ...appAccountToken ? { boundAccountId: appAccountToken } : {},
531
+ // Transient, like boundAccountId: the validate route uses it to decide
532
+ // whether a sandbox-only test override may apply, then strips it.
533
+ ...tx.environment === "Sandbox" ? { sandbox: true } : {}
455
534
  };
456
535
  }
457
536
  async function validateAppleConsumableReceipt(signedTransaction, config, expectedProductId) {
@@ -1255,6 +1334,21 @@ function peekAppleBundleId(jws) {
1255
1334
  }
1256
1335
  }
1257
1336
 
1337
+ // src/test-overrides.ts
1338
+ var overrides = /* @__PURE__ */ new Map();
1339
+ function setTestOverride(userId, entitled) {
1340
+ overrides.set(userId, entitled);
1341
+ }
1342
+ function clearTestOverride(userId) {
1343
+ return overrides.delete(userId);
1344
+ }
1345
+ function getTestOverride(userId) {
1346
+ return overrides.get(userId);
1347
+ }
1348
+ function listTestOverrides() {
1349
+ return [...overrides].map(([userId, entitled]) => ({ userId, entitled }));
1350
+ }
1351
+
1258
1352
  // src/routes/validate.ts
1259
1353
  var NO_SUB = { valid: false, subscription: null };
1260
1354
  var validateSchema = z.object({
@@ -1322,6 +1416,13 @@ function createValidateRouter(config, store) {
1322
1416
  );
1323
1417
  return;
1324
1418
  }
1419
+ const isSandbox = sub.sandbox === true;
1420
+ delete sub.sandbox;
1421
+ if (isSandbox && getTestOverride(userId) === false) {
1422
+ log.warn(`[onesub/validate] sandbox test override active for ${userId}: forcing not-entitled`);
1423
+ sub.status = SUBSCRIPTION_STATUS.EXPIRED;
1424
+ sub.willRenew = false;
1425
+ }
1325
1426
  sub.userId = userId;
1326
1427
  await store.save(sub);
1327
1428
  if (platform === "google" && appConfig.google) {
@@ -2039,10 +2140,8 @@ function createPurchaseRouter(config, purchaseStore) {
2039
2140
  });
2040
2141
  return router;
2041
2142
  }
2042
- async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2143
+ function evaluateEntitlementFrom(subs, purchases, entitlement, now = Date.now()) {
2043
2144
  const productIdSet = new Set(entitlement.productIds);
2044
- const now = Date.now();
2045
- const subs = await store.getAllByUserId(userId);
2046
2145
  for (const sub of subs) {
2047
2146
  if (!productIdSet.has(sub.productId)) continue;
2048
2147
  const statusAllows = sub.status === SUBSCRIPTION_STATUS.ACTIVE || sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD;
@@ -2055,7 +2154,6 @@ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2055
2154
  expiresAt: sub.expiresAt
2056
2155
  };
2057
2156
  }
2058
- const purchases = await purchaseStore.getPurchasesByUserId(userId);
2059
2157
  for (const p of purchases) {
2060
2158
  if (p.type !== PURCHASE_TYPE.NON_CONSUMABLE) continue;
2061
2159
  if (!productIdSet.has(p.productId)) continue;
@@ -2067,6 +2165,14 @@ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2067
2165
  }
2068
2166
  return { active: false, source: null };
2069
2167
  }
2168
+ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2169
+ const now = Date.now();
2170
+ const subs = await store.getAllByUserId(userId);
2171
+ const fromSubs = evaluateEntitlementFrom(subs, [], entitlement, now);
2172
+ if (fromSubs.active) return fromSubs;
2173
+ const purchases = await purchaseStore.getPurchasesByUserId(userId);
2174
+ return evaluateEntitlementFrom([], purchases, entitlement, now);
2175
+ }
2070
2176
  var userIdSchema = z.string().min(1).max(256);
2071
2177
  var entitlementIdSchema = z.string().min(1).max(128);
2072
2178
  function createEntitlementRouter(config, store, purchaseStore) {
@@ -2108,11 +2214,13 @@ function createEntitlementRouter(config, store, purchaseStore) {
2108
2214
  return;
2109
2215
  }
2110
2216
  try {
2111
- const entries = await Promise.all(
2112
- Object.entries(entitlements).map(async ([id, entitlement]) => {
2113
- const status = await evaluateEntitlement(userId, entitlement, store, purchaseStore);
2114
- return [id, status];
2115
- })
2217
+ const [subs, purchases] = await Promise.all([
2218
+ store.getAllByUserId(userId),
2219
+ purchaseStore.getPurchasesByUserId(userId)
2220
+ ]);
2221
+ const now = Date.now();
2222
+ const entries = Object.entries(entitlements).map(
2223
+ ([id, entitlement]) => [id, evaluateEntitlementFrom(subs, purchases, entitlement, now)]
2116
2224
  );
2117
2225
  const response = {
2118
2226
  entitlements: Object.fromEntries(entries)
@@ -2303,9 +2411,10 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2303
2411
  ]);
2304
2412
  let entitlements;
2305
2413
  if (config.entitlements && Object.keys(config.entitlements).length > 0) {
2414
+ const now = Date.now();
2306
2415
  entitlements = {};
2307
2416
  for (const [id, def] of Object.entries(config.entitlements)) {
2308
- entitlements[id] = await evaluateEntitlement(params.userId, def, store, purchaseStore);
2417
+ entitlements[id] = evaluateEntitlementFrom(subscriptions, purchases, def, now);
2309
2418
  }
2310
2419
  }
2311
2420
  const response = {
@@ -2351,6 +2460,42 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2351
2460
  sendError(res, 500, ONESUB_ERROR_CODE.INTERNAL_ERROR, err2.message ?? "sync error");
2352
2461
  }
2353
2462
  });
2463
+ const overrideParamsSchema = z.object({ userId: z.string().min(1).max(256) });
2464
+ const overrideBodySchema = z.object({ entitled: z.boolean() });
2465
+ router.get(ROUTES.ADMIN_TEST_OVERRIDES, (_req, res) => {
2466
+ res.json({ overrides: listTestOverrides() });
2467
+ });
2468
+ router.put(ROUTES.ADMIN_TEST_OVERRIDE, (req, res) => {
2469
+ let params;
2470
+ let body;
2471
+ try {
2472
+ params = overrideParamsSchema.parse(req.params);
2473
+ body = overrideBodySchema.parse(req.body);
2474
+ } catch (err2) {
2475
+ if (err2 instanceof z.ZodError) {
2476
+ sendZodError(res, err2);
2477
+ } else {
2478
+ sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, "userId and { entitled: boolean } required");
2479
+ }
2480
+ return;
2481
+ }
2482
+ setTestOverride(params.userId, body.entitled);
2483
+ log.warn(
2484
+ `[onesub/admin] sandbox test override set for ${params.userId}: entitled=${body.entitled}`
2485
+ );
2486
+ res.json({ ok: true, userId: params.userId, entitled: body.entitled, sandboxOnly: true });
2487
+ });
2488
+ router.delete(ROUTES.ADMIN_TEST_OVERRIDE, (req, res) => {
2489
+ let params;
2490
+ try {
2491
+ params = overrideParamsSchema.parse(req.params);
2492
+ } catch {
2493
+ sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, "userId required");
2494
+ return;
2495
+ }
2496
+ const cleared = clearTestOverride(params.userId);
2497
+ res.json({ ok: true, userId: params.userId, cleared });
2498
+ });
2354
2499
  if (webhookQueue?.listDeadLetters) {
2355
2500
  router.get("/onesub/admin/webhook-deadletters", async (_req, res) => {
2356
2501
  try {
@@ -2381,11 +2526,135 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2381
2526
  }
2382
2527
  return router;
2383
2528
  }
2529
+ var MS_PER_DAY = 864e5;
2530
+ function bump(map, key) {
2531
+ map[key] = (map[key] ?? 0) + 1;
2532
+ }
2533
+ function utcDateKey(ms) {
2534
+ const d = new Date(ms);
2535
+ const yyyy = d.getUTCFullYear();
2536
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
2537
+ const dd = String(d.getUTCDate()).padStart(2, "0");
2538
+ return `${yyyy}-${mm}-${dd}`;
2539
+ }
2540
+ function emptyDailyBuckets(fromMs, toMs) {
2541
+ const out = [];
2542
+ const start = new Date(fromMs);
2543
+ start.setUTCHours(0, 0, 0, 0);
2544
+ let cur = start.getTime();
2545
+ while (cur <= toMs) {
2546
+ out.push({ date: utcDateKey(cur), count: 0 });
2547
+ cur += MS_PER_DAY;
2548
+ }
2549
+ return out;
2550
+ }
2551
+ function aggregateRange(records, opts) {
2552
+ const byProduct = {};
2553
+ const byPlatform = {};
2554
+ let total = 0;
2555
+ const buckets = opts.groupBy === "day" ? emptyDailyBuckets(opts.fromMs, opts.toMs) : null;
2556
+ const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2557
+ for (const record of records) {
2558
+ if (opts.include && !opts.include(record)) continue;
2559
+ const anchorMs = new Date(opts.anchor(record)).getTime();
2560
+ if (Number.isNaN(anchorMs)) continue;
2561
+ if (anchorMs < opts.fromMs || anchorMs > opts.toMs) continue;
2562
+ total++;
2563
+ bump(byProduct, record.productId);
2564
+ bump(byPlatform, record.platform);
2565
+ if (buckets && bucketIndex) {
2566
+ const idx = bucketIndex.get(utcDateKey(anchorMs));
2567
+ if (idx !== void 0) buckets[idx].count++;
2568
+ }
2569
+ }
2570
+ return { total, byProduct, byPlatform, ...buckets ? { buckets } : {} };
2571
+ }
2572
+ function isActiveSubscription(sub, nowMs) {
2573
+ const statusAllows = sub.status === SUBSCRIPTION_STATUS.ACTIVE || sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD;
2574
+ return statusAllows && new Date(sub.expiresAt).getTime() > nowMs;
2575
+ }
2576
+ function isEndedSubscription(sub) {
2577
+ return sub.status === SUBSCRIPTION_STATUS.EXPIRED || sub.status === SUBSCRIPTION_STATUS.CANCELED;
2578
+ }
2579
+ function isNonConsumable(purchase) {
2580
+ return purchase.type === PURCHASE_TYPE.NON_CONSUMABLE;
2581
+ }
2582
+ function aggregateActive(subs, purchases, nowMs) {
2583
+ const byProduct = {};
2584
+ const byProductPurchases = {};
2585
+ const byPlatform = {};
2586
+ let activeSubscriptions = 0;
2587
+ let gracePeriodSubscriptions = 0;
2588
+ let nonConsumablePurchases = 0;
2589
+ for (const sub of subs) {
2590
+ if (!isActiveSubscription(sub, nowMs)) continue;
2591
+ activeSubscriptions++;
2592
+ if (sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriodSubscriptions++;
2593
+ bump(byProduct, sub.productId);
2594
+ bump(byPlatform, sub.platform);
2595
+ }
2596
+ for (const purchase of purchases) {
2597
+ if (!isNonConsumable(purchase)) continue;
2598
+ nonConsumablePurchases++;
2599
+ bump(byProductPurchases, purchase.productId);
2600
+ bump(byPlatform, purchase.platform);
2601
+ }
2602
+ return {
2603
+ total: activeSubscriptions + nonConsumablePurchases,
2604
+ activeSubscriptions,
2605
+ gracePeriodSubscriptions,
2606
+ nonConsumablePurchases,
2607
+ byProduct,
2608
+ byProductPurchases,
2609
+ byPlatform
2610
+ };
2611
+ }
2612
+
2613
+ // src/metrics-cache.ts
2614
+ function isDisabled(ttlSeconds) {
2615
+ return !Number.isFinite(ttlSeconds) || ttlSeconds <= 0;
2616
+ }
2617
+ function createMetricsCache(ttlSeconds, storage = new InMemoryCacheAdapter()) {
2618
+ const inflight = /* @__PURE__ */ new Map();
2619
+ const gridMs = Math.max(1, Math.floor(ttlSeconds * 1e3));
2620
+ return {
2621
+ quantizeToWindow(ms) {
2622
+ if (isDisabled(ttlSeconds)) return ms;
2623
+ return Math.floor(ms / gridMs) * gridMs;
2624
+ },
2625
+ async resolve(keyParts, produce) {
2626
+ if (isDisabled(ttlSeconds)) return produce();
2627
+ const key = `metrics:${keyParts.join(":")}`;
2628
+ const cache = storage;
2629
+ const hit = await cache.get(key);
2630
+ if (hit !== null && hit !== void 0) return hit;
2631
+ const pending = inflight.get(key);
2632
+ if (pending) return pending;
2633
+ const promise = (async () => {
2634
+ const value = await produce();
2635
+ await cache.set(key, value, ttlSeconds);
2636
+ return value;
2637
+ })();
2638
+ inflight.set(key, promise);
2639
+ try {
2640
+ return await promise;
2641
+ } finally {
2642
+ inflight.delete(key);
2643
+ }
2644
+ }
2645
+ };
2646
+ }
2647
+
2648
+ // src/routes/metrics.ts
2384
2649
  var ADMIN_SECRET_HEADER2 = "x-admin-secret";
2650
+ var DEFAULT_METRICS_CACHE_TTL_SECONDS = 30;
2385
2651
  function createMetricsRouter(config, store, purchaseStore) {
2386
2652
  if (!config.adminSecret) return null;
2387
2653
  const router = Router();
2388
2654
  const adminSecret = config.adminSecret;
2655
+ const metricsCache = createMetricsCache(
2656
+ config.metricsCacheTtlSeconds ?? DEFAULT_METRICS_CACHE_TTL_SECONDS
2657
+ );
2389
2658
  router.use("/onesub/metrics", (req, res, next) => {
2390
2659
  const provided = req.headers[ADMIN_SECRET_HEADER2];
2391
2660
  if (typeof provided !== "string" || !secretsEqual(provided, adminSecret)) {
@@ -2394,50 +2663,15 @@ function createMetricsRouter(config, store, purchaseStore) {
2394
2663
  }
2395
2664
  next();
2396
2665
  });
2397
- const isActiveSub = (sub, now) => {
2398
- const statusAllows = sub.status === SUBSCRIPTION_STATUS.ACTIVE || sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD;
2399
- return statusAllows && new Date(sub.expiresAt).getTime() > now;
2400
- };
2401
- const bump = (map, key) => {
2402
- map[key] = (map[key] ?? 0) + 1;
2403
- };
2404
2666
  router.get(ROUTES.METRICS_ACTIVE, async (_req, res) => {
2405
2667
  try {
2406
- const [subs, purchases] = await Promise.all([
2407
- store.listAll(),
2408
- purchaseStore.listAll()
2409
- ]);
2410
- const now = Date.now();
2411
- const byProduct = {};
2412
- const byProductPurchases = {};
2413
- const byPlatform = {};
2414
- let activeSubscriptions = 0;
2415
- let gracePeriodSubscriptions = 0;
2416
- let nonConsumablePurchases = 0;
2417
- for (const sub of subs) {
2418
- if (!isActiveSub(sub, now)) continue;
2419
- activeSubscriptions++;
2420
- if (sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD) {
2421
- gracePeriodSubscriptions++;
2668
+ const response = await metricsCache.resolve(
2669
+ ["active", metricsCache.quantizeToWindow(Date.now())],
2670
+ async () => {
2671
+ const [subs, purchases] = await Promise.all([store.listAll(), purchaseStore.listAll()]);
2672
+ return aggregateActive(subs, purchases, Date.now());
2422
2673
  }
2423
- bump(byProduct, sub.productId);
2424
- bump(byPlatform, sub.platform);
2425
- }
2426
- for (const p of purchases) {
2427
- if (p.type !== PURCHASE_TYPE.NON_CONSUMABLE) continue;
2428
- nonConsumablePurchases++;
2429
- bump(byProductPurchases, p.productId);
2430
- bump(byPlatform, p.platform);
2431
- }
2432
- const response = {
2433
- total: activeSubscriptions + nonConsumablePurchases,
2434
- activeSubscriptions,
2435
- gracePeriodSubscriptions,
2436
- nonConsumablePurchases,
2437
- byProduct,
2438
- byProductPurchases,
2439
- byPlatform
2440
- };
2674
+ );
2441
2675
  res.status(200).json(response);
2442
2676
  } catch (err2) {
2443
2677
  log.error("[onesub/metrics/active] error:", err2);
@@ -2468,140 +2702,63 @@ function createMetricsRouter(config, store, purchaseStore) {
2468
2702
  }
2469
2703
  return { fromMs, toMs, groupBy };
2470
2704
  }
2471
- function utcDateKey(ms) {
2472
- const d = new Date(ms);
2473
- const yyyy = d.getUTCFullYear();
2474
- const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
2475
- const dd = String(d.getUTCDate()).padStart(2, "0");
2476
- return `${yyyy}-${mm}-${dd}`;
2477
- }
2478
- function emptyDailyBuckets(fromMs, toMs) {
2479
- const out = [];
2480
- const start = new Date(fromMs);
2481
- start.setUTCHours(0, 0, 0, 0);
2482
- let cur = start.getTime();
2483
- while (cur <= toMs) {
2484
- out.push({ date: utcDateKey(cur), count: 0 });
2485
- cur += 864e5;
2486
- }
2487
- return out;
2488
- }
2489
- router.get(ROUTES.METRICS_STARTED, async (req, res) => {
2490
- const range = parseRange(req);
2491
- if ("error" in range) {
2492
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
2493
- return;
2494
- }
2495
- try {
2496
- const subs = await store.listAll();
2497
- const byProduct = {};
2498
- const byPlatform = {};
2499
- let total = 0;
2500
- const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
2501
- const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2502
- for (const sub of subs) {
2503
- const purchasedMs = new Date(sub.purchasedAt).getTime();
2504
- if (purchasedMs < range.fromMs || purchasedMs > range.toMs) continue;
2505
- total++;
2506
- bump(byProduct, sub.productId);
2507
- bump(byPlatform, sub.platform);
2508
- if (buckets && bucketIndex) {
2509
- const idx = bucketIndex.get(utcDateKey(purchasedMs));
2510
- if (idx !== void 0) buckets[idx].count++;
2511
- }
2512
- }
2513
- const response = {
2514
- from: req.query["from"],
2515
- to: req.query["to"],
2516
- total,
2517
- byProduct,
2518
- byPlatform,
2519
- ...buckets ? { buckets } : {}
2520
- };
2521
- res.status(200).json(response);
2522
- } catch (err2) {
2523
- log.error("[onesub/metrics/started] error:", err2);
2524
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2525
- }
2526
- });
2527
- router.get(ROUTES.METRICS_EXPIRED, async (req, res) => {
2528
- const range = parseRange(req);
2529
- if ("error" in range) {
2530
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
2531
- return;
2532
- }
2533
- try {
2534
- const subs = await store.listAll();
2535
- const byProduct = {};
2536
- const byPlatform = {};
2537
- let total = 0;
2538
- const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
2539
- const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2540
- for (const sub of subs) {
2541
- if (sub.status !== SUBSCRIPTION_STATUS.EXPIRED && sub.status !== SUBSCRIPTION_STATUS.CANCELED) continue;
2542
- const expiredMs = new Date(sub.expiresAt).getTime();
2543
- if (expiredMs < range.fromMs || expiredMs > range.toMs) continue;
2544
- total++;
2545
- bump(byProduct, sub.productId);
2546
- bump(byPlatform, sub.platform);
2547
- if (buckets && bucketIndex) {
2548
- const idx = bucketIndex.get(utcDateKey(expiredMs));
2549
- if (idx !== void 0) buckets[idx].count++;
2550
- }
2705
+ function windowed(name, load, anchor, include) {
2706
+ return async (req, res) => {
2707
+ const range = parseRange(req);
2708
+ if ("error" in range) {
2709
+ sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
2710
+ return;
2551
2711
  }
2552
- const response = {
2553
- from: req.query["from"],
2554
- to: req.query["to"],
2555
- total,
2556
- byProduct,
2557
- byPlatform,
2558
- ...buckets ? { buckets } : {}
2559
- };
2560
- res.status(200).json(response);
2561
- } catch (err2) {
2562
- log.error("[onesub/metrics/expired] error:", err2);
2563
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2564
- }
2565
- });
2566
- router.get(ROUTES.METRICS_PURCHASES_STARTED, async (req, res) => {
2567
- const range = parseRange(req);
2568
- if ("error" in range) {
2569
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
2570
- return;
2571
- }
2572
- try {
2573
- const purchases = await purchaseStore.listAll();
2574
- const byProduct = {};
2575
- const byPlatform = {};
2576
- let total = 0;
2577
- const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
2578
- const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2579
- for (const p of purchases) {
2580
- if (p.type !== PURCHASE_TYPE.NON_CONSUMABLE) continue;
2581
- const purchasedMs = new Date(p.purchasedAt).getTime();
2582
- if (purchasedMs < range.fromMs || purchasedMs > range.toMs) continue;
2583
- total++;
2584
- bump(byProduct, p.productId);
2585
- bump(byPlatform, p.platform);
2586
- if (buckets && bucketIndex) {
2587
- const idx = bucketIndex.get(utcDateKey(purchasedMs));
2588
- if (idx !== void 0) buckets[idx].count++;
2589
- }
2712
+ try {
2713
+ const aggregate = await metricsCache.resolve(
2714
+ [
2715
+ name,
2716
+ metricsCache.quantizeToWindow(range.fromMs),
2717
+ metricsCache.quantizeToWindow(range.toMs),
2718
+ range.groupBy
2719
+ ],
2720
+ async () => aggregateRange(await load(), {
2721
+ fromMs: range.fromMs,
2722
+ toMs: range.toMs,
2723
+ groupBy: range.groupBy,
2724
+ anchor,
2725
+ ...include ? { include } : {}
2726
+ })
2727
+ );
2728
+ const response = {
2729
+ from: req.query["from"],
2730
+ to: req.query["to"],
2731
+ ...aggregate
2732
+ };
2733
+ res.status(200).json(response);
2734
+ } catch (err2) {
2735
+ log.error(`[onesub/metrics/${name}] error:`, err2);
2736
+ sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2590
2737
  }
2591
- const response = {
2592
- from: req.query["from"],
2593
- to: req.query["to"],
2594
- total,
2595
- byProduct,
2596
- byPlatform,
2597
- ...buckets ? { buckets } : {}
2598
- };
2599
- res.status(200).json(response);
2600
- } catch (err2) {
2601
- log.error("[onesub/metrics/purchases/started] error:", err2);
2602
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2603
- }
2604
- });
2738
+ };
2739
+ }
2740
+ router.get(
2741
+ ROUTES.METRICS_STARTED,
2742
+ windowed("started", () => store.listAll(), (sub) => sub.purchasedAt)
2743
+ );
2744
+ router.get(
2745
+ ROUTES.METRICS_EXPIRED,
2746
+ windowed(
2747
+ "expired",
2748
+ () => store.listAll(),
2749
+ (sub) => sub.expiresAt,
2750
+ isEndedSubscription
2751
+ )
2752
+ );
2753
+ router.get(
2754
+ ROUTES.METRICS_PURCHASES_STARTED,
2755
+ windowed(
2756
+ "purchases-started",
2757
+ () => purchaseStore.listAll(),
2758
+ (purchase) => purchase.purchasedAt,
2759
+ isNonConsumable
2760
+ )
2761
+ );
2605
2762
  return router;
2606
2763
  }
2607
2764
  var OFFER_SECRET_HEADER = "x-onesub-offer-secret";
@@ -3514,6 +3671,54 @@ var ONESUB_OPENAPI = {
3514
3671
  }
3515
3672
  },
3516
3673
  // ── admin (mounted when config.adminSecret is set) ───────────────────────
3674
+ [ROUTES.ADMIN_TEST_OVERRIDES]: {
3675
+ get: {
3676
+ summary: "List sandbox-only entitlement overrides (admin).",
3677
+ parameters: [ADMIN_SECRET_PARAM],
3678
+ responses: {
3679
+ 200: { description: "OK" },
3680
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3681
+ }
3682
+ }
3683
+ },
3684
+ "/onesub/admin/test-overrides/{userId}": {
3685
+ put: {
3686
+ summary: "Force an entitlement verdict for one userId. Honoured ONLY for Sandbox receipts \u2014 a Production receipt ignores it. Exists because Apple cannot cancel a sandbox subscription bought with a real Apple Account, which otherwise locks a tester in.",
3687
+ parameters: [
3688
+ ADMIN_SECRET_PARAM,
3689
+ { in: "path", name: "userId", required: true, schema: { type: "string" } }
3690
+ ],
3691
+ requestBody: {
3692
+ required: true,
3693
+ content: {
3694
+ "application/json": {
3695
+ schema: {
3696
+ type: "object",
3697
+ required: ["entitled"],
3698
+ properties: { entitled: { type: "boolean" } }
3699
+ }
3700
+ }
3701
+ }
3702
+ },
3703
+ responses: {
3704
+ 200: { description: "OK" },
3705
+ 400: err("Invalid input (INVALID_INPUT)."),
3706
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3707
+ }
3708
+ },
3709
+ delete: {
3710
+ summary: "Clear the sandbox entitlement override for one userId (admin).",
3711
+ parameters: [
3712
+ ADMIN_SECRET_PARAM,
3713
+ { in: "path", name: "userId", required: true, schema: { type: "string" } }
3714
+ ],
3715
+ responses: {
3716
+ 200: { description: "OK" },
3717
+ 400: err("Invalid input (INVALID_INPUT)."),
3718
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3719
+ }
3720
+ }
3721
+ },
3517
3722
  [ROUTES.ADMIN_SUBSCRIPTIONS]: {
3518
3723
  get: {
3519
3724
  summary: "Filtered, paginated subscription list (admin).",
@@ -4017,6 +4222,6 @@ if (isMain) {
4017
4222
  });
4018
4223
  }
4019
4224
 
4020
- 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 };
4225
+ 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 };
4021
4226
  //# sourceMappingURL=index.js.map
4022
4227
  //# sourceMappingURL=index.js.map