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