@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.
- package/README.md +4 -0
- package/dist/__tests__/apple-jws-cache.test.d.ts +16 -0
- package/dist/__tests__/apple-jws-cache.test.d.ts.map +1 -0
- package/dist/__tests__/metrics-aggregate.test.d.ts +10 -0
- package/dist/__tests__/metrics-aggregate.test.d.ts.map +1 -0
- package/dist/__tests__/metrics-cache.test.d.ts +11 -0
- package/dist/__tests__/metrics-cache.test.d.ts.map +1 -0
- package/dist/__tests__/test-overrides.test.d.ts +15 -0
- package/dist/__tests__/test-overrides.test.d.ts.map +1 -0
- package/dist/__tests__/verified-key-cache.test.d.ts +18 -0
- package/dist/__tests__/verified-key-cache.test.d.ts.map +1 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/index.cjs +397 -191
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +398 -193
- package/dist/index.js.map +1 -1
- package/dist/metrics-aggregate.d.ts +82 -0
- package/dist/metrics-aggregate.d.ts.map +1 -0
- package/dist/metrics-cache.d.ts +20 -0
- package/dist/metrics-cache.d.ts.map +1 -0
- package/dist/openapi.d.ts.map +1 -1
- package/dist/providers/apple.d.ts +2 -0
- package/dist/providers/apple.d.ts.map +1 -1
- package/dist/providers/verified-key-cache.d.ts +75 -0
- package/dist/providers/verified-key-cache.d.ts.map +1 -0
- package/dist/routes/admin.d.ts.map +1 -1
- package/dist/routes/entitlements.d.ts +18 -1
- package/dist/routes/entitlements.d.ts.map +1 -1
- package/dist/routes/metrics.d.ts +8 -3
- package/dist/routes/metrics.d.ts.map +1 -1
- package/dist/routes/validate.d.ts.map +1 -1
- package/dist/store.d.ts +3 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/test-overrides.d.ts +36 -0
- package/dist/test-overrides.d.ts.map +1 -0
- package/package.json +2 -2
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
|
|
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
|
|
266
|
-
return `${prefix}_${
|
|
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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -458,7 +534,10 @@ async function validateAppleReceipt(receipt, config) {
|
|
|
458
534
|
purchasedAt: new Date(purchasedAt).toISOString(),
|
|
459
535
|
willRenew: status === shared.SUBSCRIPTION_STATUS.ACTIVE,
|
|
460
536
|
// refined by renewal info in webhook
|
|
461
|
-
...appAccountToken ? { boundAccountId: appAccountToken } : {}
|
|
537
|
+
...appAccountToken ? { boundAccountId: appAccountToken } : {},
|
|
538
|
+
// Transient, like boundAccountId: the validate route uses it to decide
|
|
539
|
+
// whether a sandbox-only test override may apply, then strips it.
|
|
540
|
+
...tx.environment === "Sandbox" ? { sandbox: true } : {}
|
|
462
541
|
};
|
|
463
542
|
}
|
|
464
543
|
async function validateAppleConsumableReceipt(signedTransaction, config, expectedProductId) {
|
|
@@ -1262,6 +1341,21 @@ function peekAppleBundleId(jws) {
|
|
|
1262
1341
|
}
|
|
1263
1342
|
}
|
|
1264
1343
|
|
|
1344
|
+
// src/test-overrides.ts
|
|
1345
|
+
var overrides = /* @__PURE__ */ new Map();
|
|
1346
|
+
function setTestOverride(userId, entitled) {
|
|
1347
|
+
overrides.set(userId, entitled);
|
|
1348
|
+
}
|
|
1349
|
+
function clearTestOverride(userId) {
|
|
1350
|
+
return overrides.delete(userId);
|
|
1351
|
+
}
|
|
1352
|
+
function getTestOverride(userId) {
|
|
1353
|
+
return overrides.get(userId);
|
|
1354
|
+
}
|
|
1355
|
+
function listTestOverrides() {
|
|
1356
|
+
return [...overrides].map(([userId, entitled]) => ({ userId, entitled }));
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1265
1359
|
// src/routes/validate.ts
|
|
1266
1360
|
var NO_SUB = { valid: false, subscription: null };
|
|
1267
1361
|
var validateSchema = zod.z.object({
|
|
@@ -1329,6 +1423,13 @@ function createValidateRouter(config, store) {
|
|
|
1329
1423
|
);
|
|
1330
1424
|
return;
|
|
1331
1425
|
}
|
|
1426
|
+
const isSandbox = sub.sandbox === true;
|
|
1427
|
+
delete sub.sandbox;
|
|
1428
|
+
if (isSandbox && getTestOverride(userId) === false) {
|
|
1429
|
+
log.warn(`[onesub/validate] sandbox test override active for ${userId}: forcing not-entitled`);
|
|
1430
|
+
sub.status = shared.SUBSCRIPTION_STATUS.EXPIRED;
|
|
1431
|
+
sub.willRenew = false;
|
|
1432
|
+
}
|
|
1332
1433
|
sub.userId = userId;
|
|
1333
1434
|
await store.save(sub);
|
|
1334
1435
|
if (platform === "google" && appConfig.google) {
|
|
@@ -2046,10 +2147,8 @@ function createPurchaseRouter(config, purchaseStore) {
|
|
|
2046
2147
|
});
|
|
2047
2148
|
return router;
|
|
2048
2149
|
}
|
|
2049
|
-
|
|
2150
|
+
function evaluateEntitlementFrom(subs, purchases, entitlement, now = Date.now()) {
|
|
2050
2151
|
const productIdSet = new Set(entitlement.productIds);
|
|
2051
|
-
const now = Date.now();
|
|
2052
|
-
const subs = await store.getAllByUserId(userId);
|
|
2053
2152
|
for (const sub of subs) {
|
|
2054
2153
|
if (!productIdSet.has(sub.productId)) continue;
|
|
2055
2154
|
const statusAllows = sub.status === shared.SUBSCRIPTION_STATUS.ACTIVE || sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
|
|
@@ -2062,7 +2161,6 @@ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
|
|
|
2062
2161
|
expiresAt: sub.expiresAt
|
|
2063
2162
|
};
|
|
2064
2163
|
}
|
|
2065
|
-
const purchases = await purchaseStore.getPurchasesByUserId(userId);
|
|
2066
2164
|
for (const p of purchases) {
|
|
2067
2165
|
if (p.type !== shared.PURCHASE_TYPE.NON_CONSUMABLE) continue;
|
|
2068
2166
|
if (!productIdSet.has(p.productId)) continue;
|
|
@@ -2074,6 +2172,14 @@ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
|
|
|
2074
2172
|
}
|
|
2075
2173
|
return { active: false, source: null };
|
|
2076
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
|
+
}
|
|
2077
2183
|
var userIdSchema = zod.z.string().min(1).max(256);
|
|
2078
2184
|
var entitlementIdSchema = zod.z.string().min(1).max(128);
|
|
2079
2185
|
function createEntitlementRouter(config, store, purchaseStore) {
|
|
@@ -2115,11 +2221,13 @@ function createEntitlementRouter(config, store, purchaseStore) {
|
|
|
2115
2221
|
return;
|
|
2116
2222
|
}
|
|
2117
2223
|
try {
|
|
2118
|
-
const
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
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)]
|
|
2123
2231
|
);
|
|
2124
2232
|
const response = {
|
|
2125
2233
|
entitlements: Object.fromEntries(entries)
|
|
@@ -2310,9 +2418,10 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2310
2418
|
]);
|
|
2311
2419
|
let entitlements;
|
|
2312
2420
|
if (config.entitlements && Object.keys(config.entitlements).length > 0) {
|
|
2421
|
+
const now = Date.now();
|
|
2313
2422
|
entitlements = {};
|
|
2314
2423
|
for (const [id, def] of Object.entries(config.entitlements)) {
|
|
2315
|
-
entitlements[id] =
|
|
2424
|
+
entitlements[id] = evaluateEntitlementFrom(subscriptions, purchases, def, now);
|
|
2316
2425
|
}
|
|
2317
2426
|
}
|
|
2318
2427
|
const response = {
|
|
@@ -2358,6 +2467,42 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2358
2467
|
sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, err2.message ?? "sync error");
|
|
2359
2468
|
}
|
|
2360
2469
|
});
|
|
2470
|
+
const overrideParamsSchema = zod.z.object({ userId: zod.z.string().min(1).max(256) });
|
|
2471
|
+
const overrideBodySchema = zod.z.object({ entitled: zod.z.boolean() });
|
|
2472
|
+
router.get(shared.ROUTES.ADMIN_TEST_OVERRIDES, (_req, res) => {
|
|
2473
|
+
res.json({ overrides: listTestOverrides() });
|
|
2474
|
+
});
|
|
2475
|
+
router.put(shared.ROUTES.ADMIN_TEST_OVERRIDE, (req, res) => {
|
|
2476
|
+
let params;
|
|
2477
|
+
let body;
|
|
2478
|
+
try {
|
|
2479
|
+
params = overrideParamsSchema.parse(req.params);
|
|
2480
|
+
body = overrideBodySchema.parse(req.body);
|
|
2481
|
+
} catch (err2) {
|
|
2482
|
+
if (err2 instanceof zod.z.ZodError) {
|
|
2483
|
+
sendZodError(res, err2);
|
|
2484
|
+
} else {
|
|
2485
|
+
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId and { entitled: boolean } required");
|
|
2486
|
+
}
|
|
2487
|
+
return;
|
|
2488
|
+
}
|
|
2489
|
+
setTestOverride(params.userId, body.entitled);
|
|
2490
|
+
log.warn(
|
|
2491
|
+
`[onesub/admin] sandbox test override set for ${params.userId}: entitled=${body.entitled}`
|
|
2492
|
+
);
|
|
2493
|
+
res.json({ ok: true, userId: params.userId, entitled: body.entitled, sandboxOnly: true });
|
|
2494
|
+
});
|
|
2495
|
+
router.delete(shared.ROUTES.ADMIN_TEST_OVERRIDE, (req, res) => {
|
|
2496
|
+
let params;
|
|
2497
|
+
try {
|
|
2498
|
+
params = overrideParamsSchema.parse(req.params);
|
|
2499
|
+
} catch {
|
|
2500
|
+
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId required");
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
const cleared = clearTestOverride(params.userId);
|
|
2504
|
+
res.json({ ok: true, userId: params.userId, cleared });
|
|
2505
|
+
});
|
|
2361
2506
|
if (webhookQueue?.listDeadLetters) {
|
|
2362
2507
|
router.get("/onesub/admin/webhook-deadletters", async (_req, res) => {
|
|
2363
2508
|
try {
|
|
@@ -2388,11 +2533,135 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2388
2533
|
}
|
|
2389
2534
|
return router;
|
|
2390
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
|
|
2391
2656
|
var ADMIN_SECRET_HEADER2 = "x-admin-secret";
|
|
2657
|
+
var DEFAULT_METRICS_CACHE_TTL_SECONDS = 30;
|
|
2392
2658
|
function createMetricsRouter(config, store, purchaseStore) {
|
|
2393
2659
|
if (!config.adminSecret) return null;
|
|
2394
2660
|
const router = express.Router();
|
|
2395
2661
|
const adminSecret = config.adminSecret;
|
|
2662
|
+
const metricsCache = createMetricsCache(
|
|
2663
|
+
config.metricsCacheTtlSeconds ?? DEFAULT_METRICS_CACHE_TTL_SECONDS
|
|
2664
|
+
);
|
|
2396
2665
|
router.use("/onesub/metrics", (req, res, next) => {
|
|
2397
2666
|
const provided = req.headers[ADMIN_SECRET_HEADER2];
|
|
2398
2667
|
if (typeof provided !== "string" || !secretsEqual(provided, adminSecret)) {
|
|
@@ -2401,50 +2670,15 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2401
2670
|
}
|
|
2402
2671
|
next();
|
|
2403
2672
|
});
|
|
2404
|
-
const isActiveSub = (sub, now) => {
|
|
2405
|
-
const statusAllows = sub.status === shared.SUBSCRIPTION_STATUS.ACTIVE || sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
|
|
2406
|
-
return statusAllows && new Date(sub.expiresAt).getTime() > now;
|
|
2407
|
-
};
|
|
2408
|
-
const bump = (map, key) => {
|
|
2409
|
-
map[key] = (map[key] ?? 0) + 1;
|
|
2410
|
-
};
|
|
2411
2673
|
router.get(shared.ROUTES.METRICS_ACTIVE, async (_req, res) => {
|
|
2412
2674
|
try {
|
|
2413
|
-
const
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
const byProduct = {};
|
|
2419
|
-
const byProductPurchases = {};
|
|
2420
|
-
const byPlatform = {};
|
|
2421
|
-
let activeSubscriptions = 0;
|
|
2422
|
-
let gracePeriodSubscriptions = 0;
|
|
2423
|
-
let nonConsumablePurchases = 0;
|
|
2424
|
-
for (const sub of subs) {
|
|
2425
|
-
if (!isActiveSub(sub, now)) continue;
|
|
2426
|
-
activeSubscriptions++;
|
|
2427
|
-
if (sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD) {
|
|
2428
|
-
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());
|
|
2429
2680
|
}
|
|
2430
|
-
|
|
2431
|
-
bump(byPlatform, sub.platform);
|
|
2432
|
-
}
|
|
2433
|
-
for (const p of purchases) {
|
|
2434
|
-
if (p.type !== shared.PURCHASE_TYPE.NON_CONSUMABLE) continue;
|
|
2435
|
-
nonConsumablePurchases++;
|
|
2436
|
-
bump(byProductPurchases, p.productId);
|
|
2437
|
-
bump(byPlatform, p.platform);
|
|
2438
|
-
}
|
|
2439
|
-
const response = {
|
|
2440
|
-
total: activeSubscriptions + nonConsumablePurchases,
|
|
2441
|
-
activeSubscriptions,
|
|
2442
|
-
gracePeriodSubscriptions,
|
|
2443
|
-
nonConsumablePurchases,
|
|
2444
|
-
byProduct,
|
|
2445
|
-
byProductPurchases,
|
|
2446
|
-
byPlatform
|
|
2447
|
-
};
|
|
2681
|
+
);
|
|
2448
2682
|
res.status(200).json(response);
|
|
2449
2683
|
} catch (err2) {
|
|
2450
2684
|
log.error("[onesub/metrics/active] error:", err2);
|
|
@@ -2475,140 +2709,63 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2475
2709
|
}
|
|
2476
2710
|
return { fromMs, toMs, groupBy };
|
|
2477
2711
|
}
|
|
2478
|
-
function
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
}
|
|
2485
|
-
function emptyDailyBuckets(fromMs, toMs) {
|
|
2486
|
-
const out = [];
|
|
2487
|
-
const start = new Date(fromMs);
|
|
2488
|
-
start.setUTCHours(0, 0, 0, 0);
|
|
2489
|
-
let cur = start.getTime();
|
|
2490
|
-
while (cur <= toMs) {
|
|
2491
|
-
out.push({ date: utcDateKey(cur), count: 0 });
|
|
2492
|
-
cur += 864e5;
|
|
2493
|
-
}
|
|
2494
|
-
return out;
|
|
2495
|
-
}
|
|
2496
|
-
router.get(shared.ROUTES.METRICS_STARTED, async (req, res) => {
|
|
2497
|
-
const range = parseRange(req);
|
|
2498
|
-
if ("error" in range) {
|
|
2499
|
-
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
|
|
2500
|
-
return;
|
|
2501
|
-
}
|
|
2502
|
-
try {
|
|
2503
|
-
const subs = await store.listAll();
|
|
2504
|
-
const byProduct = {};
|
|
2505
|
-
const byPlatform = {};
|
|
2506
|
-
let total = 0;
|
|
2507
|
-
const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
|
|
2508
|
-
const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
|
|
2509
|
-
for (const sub of subs) {
|
|
2510
|
-
const purchasedMs = new Date(sub.purchasedAt).getTime();
|
|
2511
|
-
if (purchasedMs < range.fromMs || purchasedMs > range.toMs) continue;
|
|
2512
|
-
total++;
|
|
2513
|
-
bump(byProduct, sub.productId);
|
|
2514
|
-
bump(byPlatform, sub.platform);
|
|
2515
|
-
if (buckets && bucketIndex) {
|
|
2516
|
-
const idx = bucketIndex.get(utcDateKey(purchasedMs));
|
|
2517
|
-
if (idx !== void 0) buckets[idx].count++;
|
|
2518
|
-
}
|
|
2519
|
-
}
|
|
2520
|
-
const response = {
|
|
2521
|
-
from: req.query["from"],
|
|
2522
|
-
to: req.query["to"],
|
|
2523
|
-
total,
|
|
2524
|
-
byProduct,
|
|
2525
|
-
byPlatform,
|
|
2526
|
-
...buckets ? { buckets } : {}
|
|
2527
|
-
};
|
|
2528
|
-
res.status(200).json(response);
|
|
2529
|
-
} catch (err2) {
|
|
2530
|
-
log.error("[onesub/metrics/started] error:", err2);
|
|
2531
|
-
sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
|
|
2532
|
-
}
|
|
2533
|
-
});
|
|
2534
|
-
router.get(shared.ROUTES.METRICS_EXPIRED, async (req, res) => {
|
|
2535
|
-
const range = parseRange(req);
|
|
2536
|
-
if ("error" in range) {
|
|
2537
|
-
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
|
|
2538
|
-
return;
|
|
2539
|
-
}
|
|
2540
|
-
try {
|
|
2541
|
-
const subs = await store.listAll();
|
|
2542
|
-
const byProduct = {};
|
|
2543
|
-
const byPlatform = {};
|
|
2544
|
-
let total = 0;
|
|
2545
|
-
const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
|
|
2546
|
-
const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
|
|
2547
|
-
for (const sub of subs) {
|
|
2548
|
-
if (sub.status !== shared.SUBSCRIPTION_STATUS.EXPIRED && sub.status !== shared.SUBSCRIPTION_STATUS.CANCELED) continue;
|
|
2549
|
-
const expiredMs = new Date(sub.expiresAt).getTime();
|
|
2550
|
-
if (expiredMs < range.fromMs || expiredMs > range.toMs) continue;
|
|
2551
|
-
total++;
|
|
2552
|
-
bump(byProduct, sub.productId);
|
|
2553
|
-
bump(byPlatform, sub.platform);
|
|
2554
|
-
if (buckets && bucketIndex) {
|
|
2555
|
-
const idx = bucketIndex.get(utcDateKey(expiredMs));
|
|
2556
|
-
if (idx !== void 0) buckets[idx].count++;
|
|
2557
|
-
}
|
|
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;
|
|
2558
2718
|
}
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
|
|
2585
|
-
const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
|
|
2586
|
-
for (const p of purchases) {
|
|
2587
|
-
if (p.type !== shared.PURCHASE_TYPE.NON_CONSUMABLE) continue;
|
|
2588
|
-
const purchasedMs = new Date(p.purchasedAt).getTime();
|
|
2589
|
-
if (purchasedMs < range.fromMs || purchasedMs > range.toMs) continue;
|
|
2590
|
-
total++;
|
|
2591
|
-
bump(byProduct, p.productId);
|
|
2592
|
-
bump(byPlatform, p.platform);
|
|
2593
|
-
if (buckets && bucketIndex) {
|
|
2594
|
-
const idx = bucketIndex.get(utcDateKey(purchasedMs));
|
|
2595
|
-
if (idx !== void 0) buckets[idx].count++;
|
|
2596
|
-
}
|
|
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");
|
|
2597
2744
|
}
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
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
|
+
);
|
|
2612
2769
|
return router;
|
|
2613
2770
|
}
|
|
2614
2771
|
var OFFER_SECRET_HEADER = "x-onesub-offer-secret";
|
|
@@ -3521,6 +3678,54 @@ var ONESUB_OPENAPI = {
|
|
|
3521
3678
|
}
|
|
3522
3679
|
},
|
|
3523
3680
|
// ── admin (mounted when config.adminSecret is set) ───────────────────────
|
|
3681
|
+
[shared.ROUTES.ADMIN_TEST_OVERRIDES]: {
|
|
3682
|
+
get: {
|
|
3683
|
+
summary: "List sandbox-only entitlement overrides (admin).",
|
|
3684
|
+
parameters: [ADMIN_SECRET_PARAM],
|
|
3685
|
+
responses: {
|
|
3686
|
+
200: { description: "OK" },
|
|
3687
|
+
401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3690
|
+
},
|
|
3691
|
+
"/onesub/admin/test-overrides/{userId}": {
|
|
3692
|
+
put: {
|
|
3693
|
+
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.",
|
|
3694
|
+
parameters: [
|
|
3695
|
+
ADMIN_SECRET_PARAM,
|
|
3696
|
+
{ in: "path", name: "userId", required: true, schema: { type: "string" } }
|
|
3697
|
+
],
|
|
3698
|
+
requestBody: {
|
|
3699
|
+
required: true,
|
|
3700
|
+
content: {
|
|
3701
|
+
"application/json": {
|
|
3702
|
+
schema: {
|
|
3703
|
+
type: "object",
|
|
3704
|
+
required: ["entitled"],
|
|
3705
|
+
properties: { entitled: { type: "boolean" } }
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
},
|
|
3710
|
+
responses: {
|
|
3711
|
+
200: { description: "OK" },
|
|
3712
|
+
400: err("Invalid input (INVALID_INPUT)."),
|
|
3713
|
+
401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
|
|
3714
|
+
}
|
|
3715
|
+
},
|
|
3716
|
+
delete: {
|
|
3717
|
+
summary: "Clear the sandbox entitlement override for one userId (admin).",
|
|
3718
|
+
parameters: [
|
|
3719
|
+
ADMIN_SECRET_PARAM,
|
|
3720
|
+
{ in: "path", name: "userId", required: true, schema: { type: "string" } }
|
|
3721
|
+
],
|
|
3722
|
+
responses: {
|
|
3723
|
+
200: { description: "OK" },
|
|
3724
|
+
400: err("Invalid input (INVALID_INPUT)."),
|
|
3725
|
+
401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
},
|
|
3524
3729
|
[shared.ROUTES.ADMIN_SUBSCRIPTIONS]: {
|
|
3525
3730
|
get: {
|
|
3526
3731
|
summary: "Filtered, paginated subscription list (admin).",
|
|
@@ -4041,6 +4246,7 @@ exports.RedisWebhookEventStore = RedisWebhookEventStore;
|
|
|
4041
4246
|
exports.createOneSubMiddleware = createOneSubMiddleware;
|
|
4042
4247
|
exports.createOneSubServer = createOneSubServer;
|
|
4043
4248
|
exports.evaluateEntitlement = evaluateEntitlement;
|
|
4249
|
+
exports.evaluateEntitlementFrom = evaluateEntitlementFrom;
|
|
4044
4250
|
exports.fetchAppleSubscriptionStatus = fetchAppleSubscriptionStatus;
|
|
4045
4251
|
exports.fetchAppleTransactionHistory = fetchAppleTransactionHistory;
|
|
4046
4252
|
exports.getDefaultCache = getDefaultCache;
|