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