@onesub/server 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/logger.test.d.ts +11 -0
- package/dist/__tests__/logger.test.d.ts.map +1 -0
- package/dist/__tests__/postgres-store.test.d.ts +16 -0
- package/dist/__tests__/postgres-store.test.d.ts.map +1 -0
- package/dist/index.cjs +181 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +181 -29
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/metrics-aggregate.d.ts +27 -8
- package/dist/metrics-aggregate.d.ts.map +1 -1
- package/dist/routes/metrics.d.ts +18 -6
- package/dist/routes/metrics.d.ts.map +1 -1
- package/dist/store.d.ts +65 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/stores/postgres.d.ts +21 -1
- package/dist/stores/postgres.d.ts.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The process-wide logger, and specifically that a caller cannot forge log lines.
|
|
3
|
+
*
|
|
4
|
+
* Much of what this server logs is attacker-influenced — `userId` arrives in the
|
|
5
|
+
* request body, and bundle ids, package names and receipt previews come out of
|
|
6
|
+
* submitted receipts. A newline in any of those would let a caller end the current
|
|
7
|
+
* line and write an entry of their own, and these logs are what support and fraud
|
|
8
|
+
* decisions get read from.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=logger.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/logger.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgresSubscriptionStore / PostgresPurchaseStore against a real database.
|
|
3
|
+
*
|
|
4
|
+
* Until now nothing executed this SQL. `schema.test.ts` compares the embedded DDL
|
|
5
|
+
* strings to `sql/schema.sql` as text, which catches the two drifting apart but
|
|
6
|
+
* says nothing about whether either one works, and every other suite runs on the
|
|
7
|
+
* in-memory store. So the store that production actually uses was the least
|
|
8
|
+
* verified code in the package — and two things shipped on the strength of
|
|
9
|
+
* reading it: `getPurchasesForProduct`, and the claim in ARCHITECTURE.md that the
|
|
10
|
+
* partial unique index is "the atomic guarantee" behind non-consumable dedup.
|
|
11
|
+
*
|
|
12
|
+
* Skipped unless `DATABASE_URL` is set, so a local `npm test` without a database
|
|
13
|
+
* still passes. CI provides one through a service container.
|
|
14
|
+
*/
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=postgres-store.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"postgres-store.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/postgres-store.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG"}
|
package/dist/index.cjs
CHANGED
|
@@ -175,10 +175,20 @@ var current = console;
|
|
|
175
175
|
function setLogger(logger) {
|
|
176
176
|
if (logger) current = logger;
|
|
177
177
|
}
|
|
178
|
+
function escapeLineBreaks(value) {
|
|
179
|
+
return value.replace(/[\r\n\u2028\u2029]/g, (ch) => {
|
|
180
|
+
if (ch === "\n") return "\\n";
|
|
181
|
+
if (ch === "\r") return "\\r";
|
|
182
|
+
return `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
function scrub(args) {
|
|
186
|
+
return args.map((arg) => typeof arg === "string" ? escapeLineBreaks(arg) : arg);
|
|
187
|
+
}
|
|
178
188
|
var log = {
|
|
179
|
-
info: (...args) => current.info(...args),
|
|
180
|
-
warn: (...args) => current.warn(...args),
|
|
181
|
-
error: (...args) => current.error(...args)
|
|
189
|
+
info: (...args) => current.info(...scrub(args)),
|
|
190
|
+
warn: (...args) => current.warn(...scrub(args)),
|
|
191
|
+
error: (...args) => current.error(...scrub(args))
|
|
182
192
|
};
|
|
183
193
|
|
|
184
194
|
// src/http.ts
|
|
@@ -2549,33 +2559,44 @@ function isEndedSubscription(sub) {
|
|
|
2549
2559
|
function isNonConsumable(purchase) {
|
|
2550
2560
|
return purchase.type === shared.PURCHASE_TYPE.NON_CONSUMABLE;
|
|
2551
2561
|
}
|
|
2552
|
-
function
|
|
2562
|
+
function aggregateActiveSubscriptions(subs, nowMs) {
|
|
2553
2563
|
const byProduct = {};
|
|
2554
|
-
const byProductPurchases = {};
|
|
2555
2564
|
const byPlatform = {};
|
|
2556
|
-
let
|
|
2557
|
-
let
|
|
2558
|
-
let nonConsumablePurchases = 0;
|
|
2565
|
+
let active = 0;
|
|
2566
|
+
let gracePeriod = 0;
|
|
2559
2567
|
for (const sub of subs) {
|
|
2560
2568
|
if (!isActiveSubscription(sub, nowMs)) continue;
|
|
2561
|
-
|
|
2562
|
-
if (sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD)
|
|
2569
|
+
active++;
|
|
2570
|
+
if (sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriod++;
|
|
2563
2571
|
bump(byProduct, sub.productId);
|
|
2564
2572
|
bump(byPlatform, sub.platform);
|
|
2565
2573
|
}
|
|
2574
|
+
return { active, gracePeriod, byProduct, byPlatform };
|
|
2575
|
+
}
|
|
2576
|
+
function aggregateNonConsumablePurchases(purchases) {
|
|
2577
|
+
const byProduct = {};
|
|
2578
|
+
const byPlatform = {};
|
|
2579
|
+
let total = 0;
|
|
2566
2580
|
for (const purchase of purchases) {
|
|
2567
2581
|
if (!isNonConsumable(purchase)) continue;
|
|
2568
|
-
|
|
2569
|
-
bump(
|
|
2582
|
+
total++;
|
|
2583
|
+
bump(byProduct, purchase.productId);
|
|
2570
2584
|
bump(byPlatform, purchase.platform);
|
|
2571
2585
|
}
|
|
2586
|
+
return { total, byProduct, byPlatform };
|
|
2587
|
+
}
|
|
2588
|
+
function composeActiveResponse(subs, purchases) {
|
|
2589
|
+
const byPlatform = { ...subs.byPlatform };
|
|
2590
|
+
for (const [platform, count] of Object.entries(purchases.byPlatform)) {
|
|
2591
|
+
byPlatform[platform] = (byPlatform[platform] ?? 0) + count;
|
|
2592
|
+
}
|
|
2572
2593
|
return {
|
|
2573
|
-
total:
|
|
2574
|
-
activeSubscriptions,
|
|
2575
|
-
gracePeriodSubscriptions,
|
|
2576
|
-
nonConsumablePurchases,
|
|
2577
|
-
byProduct,
|
|
2578
|
-
byProductPurchases,
|
|
2594
|
+
total: subs.active + purchases.total,
|
|
2595
|
+
activeSubscriptions: subs.active,
|
|
2596
|
+
gracePeriodSubscriptions: subs.gracePeriod,
|
|
2597
|
+
nonConsumablePurchases: purchases.total,
|
|
2598
|
+
byProduct: subs.byProduct,
|
|
2599
|
+
byProductPurchases: purchases.byProduct,
|
|
2579
2600
|
byPlatform
|
|
2580
2601
|
};
|
|
2581
2602
|
}
|
|
@@ -2638,8 +2659,12 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2638
2659
|
const response = await metricsCache.resolve(
|
|
2639
2660
|
["active", metricsCache.quantizeToWindow(Date.now())],
|
|
2640
2661
|
async () => {
|
|
2641
|
-
const
|
|
2642
|
-
|
|
2662
|
+
const now = /* @__PURE__ */ new Date();
|
|
2663
|
+
const [subsAgg, purchasesAgg] = await Promise.all([
|
|
2664
|
+
store.aggregateActive ? store.aggregateActive(now) : store.listAll().then((subs) => aggregateActiveSubscriptions(subs, now.getTime())),
|
|
2665
|
+
purchaseStore.aggregateNonConsumable ? purchaseStore.aggregateNonConsumable() : purchaseStore.listAll().then(aggregateNonConsumablePurchases)
|
|
2666
|
+
]);
|
|
2667
|
+
return composeActiveResponse(subsAgg, purchasesAgg);
|
|
2643
2668
|
}
|
|
2644
2669
|
);
|
|
2645
2670
|
res.status(200).json(response);
|
|
@@ -2672,7 +2697,7 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2672
2697
|
}
|
|
2673
2698
|
return { fromMs, toMs, groupBy };
|
|
2674
2699
|
}
|
|
2675
|
-
function windowed(name, load, anchor, include) {
|
|
2700
|
+
function windowed(name, sqlAggregate, load, anchor, include) {
|
|
2676
2701
|
return async (req, res) => {
|
|
2677
2702
|
const range = parseRange(req);
|
|
2678
2703
|
if ("error" in range) {
|
|
@@ -2687,13 +2712,22 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2687
2712
|
metricsCache.quantizeToWindow(range.toMs),
|
|
2688
2713
|
range.groupBy
|
|
2689
2714
|
],
|
|
2690
|
-
async () =>
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2715
|
+
async () => {
|
|
2716
|
+
if (sqlAggregate) {
|
|
2717
|
+
return sqlAggregate({
|
|
2718
|
+
from: new Date(range.fromMs),
|
|
2719
|
+
to: new Date(range.toMs),
|
|
2720
|
+
groupBy: range.groupBy
|
|
2721
|
+
});
|
|
2722
|
+
}
|
|
2723
|
+
return aggregateRange(await load(), {
|
|
2724
|
+
fromMs: range.fromMs,
|
|
2725
|
+
toMs: range.toMs,
|
|
2726
|
+
groupBy: range.groupBy,
|
|
2727
|
+
anchor,
|
|
2728
|
+
...include ? { include } : {}
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2697
2731
|
);
|
|
2698
2732
|
const response = {
|
|
2699
2733
|
from: req.query["from"],
|
|
@@ -2709,12 +2743,18 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2709
2743
|
}
|
|
2710
2744
|
router.get(
|
|
2711
2745
|
shared.ROUTES.METRICS_STARTED,
|
|
2712
|
-
windowed(
|
|
2746
|
+
windowed(
|
|
2747
|
+
"started",
|
|
2748
|
+
store.aggregateStarted?.bind(store),
|
|
2749
|
+
() => store.listAll(),
|
|
2750
|
+
(sub) => sub.purchasedAt
|
|
2751
|
+
)
|
|
2713
2752
|
);
|
|
2714
2753
|
router.get(
|
|
2715
2754
|
shared.ROUTES.METRICS_EXPIRED,
|
|
2716
2755
|
windowed(
|
|
2717
2756
|
"expired",
|
|
2757
|
+
store.aggregateExpired?.bind(store),
|
|
2718
2758
|
() => store.listAll(),
|
|
2719
2759
|
(sub) => sub.expiresAt,
|
|
2720
2760
|
isEndedSubscription
|
|
@@ -2724,6 +2764,7 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2724
2764
|
shared.ROUTES.METRICS_PURCHASES_STARTED,
|
|
2725
2765
|
windowed(
|
|
2726
2766
|
"purchases-started",
|
|
2767
|
+
purchaseStore.aggregateStarted?.bind(purchaseStore),
|
|
2727
2768
|
() => purchaseStore.listAll(),
|
|
2728
2769
|
(purchase) => purchase.purchasedAt,
|
|
2729
2770
|
isNonConsumable
|
|
@@ -2999,6 +3040,52 @@ var PostgresSubscriptionStore = class {
|
|
|
2999
3040
|
offset
|
|
3000
3041
|
};
|
|
3001
3042
|
}
|
|
3043
|
+
/**
|
|
3044
|
+
* Counts of subscriptions currently granting entitlement.
|
|
3045
|
+
*
|
|
3046
|
+
* One `GROUP BY` instead of reading every row into the process. Grouping by
|
|
3047
|
+
* status as well as product/platform is what lets the grace-period subset come
|
|
3048
|
+
* out of the same query.
|
|
3049
|
+
*/
|
|
3050
|
+
async aggregateActive(now) {
|
|
3051
|
+
const pool = await this.getPool();
|
|
3052
|
+
const result = await pool.query(
|
|
3053
|
+
`SELECT product_id, platform, status, COUNT(*)::text AS n
|
|
3054
|
+
FROM onesub_subscriptions
|
|
3055
|
+
WHERE status IN ($1, $2) AND expires_at > $3
|
|
3056
|
+
GROUP BY product_id, platform, status`,
|
|
3057
|
+
[shared.SUBSCRIPTION_STATUS.ACTIVE, shared.SUBSCRIPTION_STATUS.GRACE_PERIOD, now]
|
|
3058
|
+
);
|
|
3059
|
+
const byProduct = {};
|
|
3060
|
+
const byPlatform = {};
|
|
3061
|
+
let active = 0;
|
|
3062
|
+
let gracePeriod = 0;
|
|
3063
|
+
for (const row of result.rows) {
|
|
3064
|
+
const n = parseInt(row.n, 10);
|
|
3065
|
+
active += n;
|
|
3066
|
+
if (row.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriod += n;
|
|
3067
|
+
byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
|
|
3068
|
+
byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
|
|
3069
|
+
}
|
|
3070
|
+
return { active, gracePeriod, byProduct, byPlatform };
|
|
3071
|
+
}
|
|
3072
|
+
/** Cohort-start counts: subscriptions whose `purchased_at` is in the window. */
|
|
3073
|
+
async aggregateStarted(query) {
|
|
3074
|
+
const pool = await this.getPool();
|
|
3075
|
+
return aggregateViaSql(pool, "onesub_subscriptions", "purchased_at", query);
|
|
3076
|
+
}
|
|
3077
|
+
/**
|
|
3078
|
+
* Subscriptions that have ended AND whose `expires_at` is in the window. A
|
|
3079
|
+
* still-active record does not count even if its expiry lands inside it — see
|
|
3080
|
+
* `isEndedSubscription`, which this mirrors.
|
|
3081
|
+
*/
|
|
3082
|
+
async aggregateExpired(query) {
|
|
3083
|
+
const pool = await this.getPool();
|
|
3084
|
+
return aggregateViaSql(pool, "onesub_subscriptions", "expires_at", query, {
|
|
3085
|
+
column: "status",
|
|
3086
|
+
values: [shared.SUBSCRIPTION_STATUS.EXPIRED, shared.SUBSCRIPTION_STATUS.CANCELED]
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
3002
3089
|
/** Gracefully close the underlying connection pool. */
|
|
3003
3090
|
async close() {
|
|
3004
3091
|
if (this.poolPromise) {
|
|
@@ -3139,6 +3226,35 @@ var PostgresPurchaseStore = class {
|
|
|
3139
3226
|
const result = await pool.query(`SELECT * FROM onesub_purchases`);
|
|
3140
3227
|
return result.rows.map(rowToPurchaseInfo);
|
|
3141
3228
|
}
|
|
3229
|
+
/** Counts of non-consumable purchases. Consumables are excluded. */
|
|
3230
|
+
async aggregateNonConsumable() {
|
|
3231
|
+
const pool = await this.getPool();
|
|
3232
|
+
const result = await pool.query(
|
|
3233
|
+
`SELECT product_id, platform, COUNT(*)::text AS n
|
|
3234
|
+
FROM onesub_purchases
|
|
3235
|
+
WHERE type = $1
|
|
3236
|
+
GROUP BY product_id, platform`,
|
|
3237
|
+
[shared.PURCHASE_TYPE.NON_CONSUMABLE]
|
|
3238
|
+
);
|
|
3239
|
+
const byProduct = {};
|
|
3240
|
+
const byPlatform = {};
|
|
3241
|
+
let total = 0;
|
|
3242
|
+
for (const row of result.rows) {
|
|
3243
|
+
const n = parseInt(row.n, 10);
|
|
3244
|
+
total += n;
|
|
3245
|
+
byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
|
|
3246
|
+
byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
|
|
3247
|
+
}
|
|
3248
|
+
return { total, byProduct, byPlatform };
|
|
3249
|
+
}
|
|
3250
|
+
/** Non-consumable purchases whose `purchased_at` is in the window. */
|
|
3251
|
+
async aggregateStarted(query) {
|
|
3252
|
+
const pool = await this.getPool();
|
|
3253
|
+
return aggregateViaSql(pool, "onesub_purchases", "purchased_at", query, {
|
|
3254
|
+
column: "type",
|
|
3255
|
+
values: [shared.PURCHASE_TYPE.NON_CONSUMABLE]
|
|
3256
|
+
});
|
|
3257
|
+
}
|
|
3142
3258
|
/** Gracefully close the underlying connection pool. */
|
|
3143
3259
|
async close() {
|
|
3144
3260
|
if (this.poolPromise) {
|
|
@@ -3148,6 +3264,42 @@ var PostgresPurchaseStore = class {
|
|
|
3148
3264
|
}
|
|
3149
3265
|
}
|
|
3150
3266
|
};
|
|
3267
|
+
async function aggregateViaSql(pool, table, anchorColumn, query, filter) {
|
|
3268
|
+
const params = [query.from, query.to];
|
|
3269
|
+
let filterClause = "";
|
|
3270
|
+
if (filter) {
|
|
3271
|
+
const placeholders = filter.values.map((_, i) => `$${params.length + i + 1}`).join(", ");
|
|
3272
|
+
filterClause = `AND ${filter.column} IN (${placeholders})`;
|
|
3273
|
+
params.push(...filter.values);
|
|
3274
|
+
}
|
|
3275
|
+
const bucketing = query.groupBy === "day";
|
|
3276
|
+
const dayExpr = `to_char(date_trunc('day', ${anchorColumn} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`;
|
|
3277
|
+
const result = await pool.query(
|
|
3278
|
+
`SELECT product_id, platform,${bucketing ? ` ${dayExpr} AS day,` : ""}
|
|
3279
|
+
COUNT(*)::text AS n
|
|
3280
|
+
FROM ${table}
|
|
3281
|
+
WHERE ${anchorColumn} >= $1 AND ${anchorColumn} <= $2 ${filterClause}
|
|
3282
|
+
GROUP BY product_id, platform${bucketing ? ", day" : ""}`,
|
|
3283
|
+
params
|
|
3284
|
+
);
|
|
3285
|
+
const byProduct = {};
|
|
3286
|
+
const byPlatform = {};
|
|
3287
|
+
const perDay = /* @__PURE__ */ new Map();
|
|
3288
|
+
let total = 0;
|
|
3289
|
+
for (const row of result.rows) {
|
|
3290
|
+
const n = parseInt(row.n, 10);
|
|
3291
|
+
total += n;
|
|
3292
|
+
byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
|
|
3293
|
+
byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
|
|
3294
|
+
if (bucketing && row.day) perDay.set(row.day, (perDay.get(row.day) ?? 0) + n);
|
|
3295
|
+
}
|
|
3296
|
+
if (!bucketing) return { total, byProduct, byPlatform };
|
|
3297
|
+
const buckets = emptyDailyBuckets(query.from.getTime(), query.to.getTime()).map((b) => ({
|
|
3298
|
+
date: b.date,
|
|
3299
|
+
count: perDay.get(b.date) ?? 0
|
|
3300
|
+
}));
|
|
3301
|
+
return { total, byProduct, byPlatform, buckets };
|
|
3302
|
+
}
|
|
3151
3303
|
function rowToSubscriptionInfo(row) {
|
|
3152
3304
|
return {
|
|
3153
3305
|
originalTransactionId: row.original_transaction_id,
|