@onesub/server 0.21.2 → 0.23.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/dist/__tests__/postgres-store.test.d.ts +16 -0
- package/dist/__tests__/postgres-store.test.d.ts.map +1 -0
- package/dist/__tests__/webhook-google-mount.test.d.ts +18 -0
- package/dist/__tests__/webhook-google-mount.test.d.ts.map +1 -0
- package/dist/index.cjs +180 -32
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +180 -32
- package/dist/index.js.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/routes/webhook-google.d.ts +11 -0
- package/dist/routes/webhook-google.d.ts.map +1 -1
- package/dist/routes/webhook.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
package/dist/index.js
CHANGED
|
@@ -1744,18 +1744,22 @@ var GOOGLE_FAILURE_MESSAGES = {
|
|
|
1744
1744
|
message: "Failed to process notification"
|
|
1745
1745
|
}
|
|
1746
1746
|
};
|
|
1747
|
+
function servesGoogle(config) {
|
|
1748
|
+
return getAppRegistry(config).apps.some((app) => !!app.google);
|
|
1749
|
+
}
|
|
1747
1750
|
function warnIfGoogleWebhookOpen(config) {
|
|
1748
1751
|
if (process.env["NODE_ENV"] !== "production") return;
|
|
1752
|
+
if (!servesGoogle(config)) return;
|
|
1749
1753
|
const apps = getAppRegistry(config).apps;
|
|
1750
1754
|
const googleApps = apps.map((a) => a.google).filter((g) => !!g);
|
|
1751
|
-
if (
|
|
1755
|
+
if (!googleApps.some((g) => g.pushAudience)) {
|
|
1752
1756
|
log.warn(
|
|
1753
1757
|
"[onesub] SECURITY: POST /onesub/webhook/google accepts unauthenticated requests \u2014 no configured app sets google.pushAudience, so the Pub/Sub OIDC token is never verified. A caller who knows a purchaseToken or orderId can cancel a subscription or delete a one-time purchase. Set google.pushAudience (and google.pushServiceAccountEmail)."
|
|
1754
1758
|
);
|
|
1755
1759
|
}
|
|
1756
1760
|
if (!apps.some((a) => a.google?.packageName)) {
|
|
1757
1761
|
log.warn(
|
|
1758
|
-
"[onesub] POST /onesub/webhook/google is in open mode \u2014 no configured app declares google.packageName, so notifications for ANY package are accepted.
|
|
1762
|
+
"[onesub] POST /onesub/webhook/google is in open mode \u2014 no configured app declares google.packageName, so notifications for ANY package are accepted. Set google.packageName on each app you serve."
|
|
1759
1763
|
);
|
|
1760
1764
|
}
|
|
1761
1765
|
}
|
|
@@ -1974,10 +1978,12 @@ function createWebhookRouter(config, store, purchaseStore, webhookEventStore, we
|
|
|
1974
1978
|
ROUTES.WEBHOOK_APPLE,
|
|
1975
1979
|
(req, res) => handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue)
|
|
1976
1980
|
);
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
+
if (servesGoogle(config)) {
|
|
1982
|
+
router.post(
|
|
1983
|
+
ROUTES.WEBHOOK_GOOGLE,
|
|
1984
|
+
(req, res) => handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue)
|
|
1985
|
+
);
|
|
1986
|
+
}
|
|
1981
1987
|
return router;
|
|
1982
1988
|
}
|
|
1983
1989
|
var NO_PURCHASE = { valid: false, purchase: null };
|
|
@@ -2536,33 +2542,44 @@ function isEndedSubscription(sub) {
|
|
|
2536
2542
|
function isNonConsumable(purchase) {
|
|
2537
2543
|
return purchase.type === PURCHASE_TYPE.NON_CONSUMABLE;
|
|
2538
2544
|
}
|
|
2539
|
-
function
|
|
2545
|
+
function aggregateActiveSubscriptions(subs, nowMs) {
|
|
2540
2546
|
const byProduct = {};
|
|
2541
|
-
const byProductPurchases = {};
|
|
2542
2547
|
const byPlatform = {};
|
|
2543
|
-
let
|
|
2544
|
-
let
|
|
2545
|
-
let nonConsumablePurchases = 0;
|
|
2548
|
+
let active = 0;
|
|
2549
|
+
let gracePeriod = 0;
|
|
2546
2550
|
for (const sub of subs) {
|
|
2547
2551
|
if (!isActiveSubscription(sub, nowMs)) continue;
|
|
2548
|
-
|
|
2549
|
-
if (sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD)
|
|
2552
|
+
active++;
|
|
2553
|
+
if (sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriod++;
|
|
2550
2554
|
bump(byProduct, sub.productId);
|
|
2551
2555
|
bump(byPlatform, sub.platform);
|
|
2552
2556
|
}
|
|
2557
|
+
return { active, gracePeriod, byProduct, byPlatform };
|
|
2558
|
+
}
|
|
2559
|
+
function aggregateNonConsumablePurchases(purchases) {
|
|
2560
|
+
const byProduct = {};
|
|
2561
|
+
const byPlatform = {};
|
|
2562
|
+
let total = 0;
|
|
2553
2563
|
for (const purchase of purchases) {
|
|
2554
2564
|
if (!isNonConsumable(purchase)) continue;
|
|
2555
|
-
|
|
2556
|
-
bump(
|
|
2565
|
+
total++;
|
|
2566
|
+
bump(byProduct, purchase.productId);
|
|
2557
2567
|
bump(byPlatform, purchase.platform);
|
|
2558
2568
|
}
|
|
2569
|
+
return { total, byProduct, byPlatform };
|
|
2570
|
+
}
|
|
2571
|
+
function composeActiveResponse(subs, purchases) {
|
|
2572
|
+
const byPlatform = { ...subs.byPlatform };
|
|
2573
|
+
for (const [platform, count] of Object.entries(purchases.byPlatform)) {
|
|
2574
|
+
byPlatform[platform] = (byPlatform[platform] ?? 0) + count;
|
|
2575
|
+
}
|
|
2559
2576
|
return {
|
|
2560
|
-
total:
|
|
2561
|
-
activeSubscriptions,
|
|
2562
|
-
gracePeriodSubscriptions,
|
|
2563
|
-
nonConsumablePurchases,
|
|
2564
|
-
byProduct,
|
|
2565
|
-
byProductPurchases,
|
|
2577
|
+
total: subs.active + purchases.total,
|
|
2578
|
+
activeSubscriptions: subs.active,
|
|
2579
|
+
gracePeriodSubscriptions: subs.gracePeriod,
|
|
2580
|
+
nonConsumablePurchases: purchases.total,
|
|
2581
|
+
byProduct: subs.byProduct,
|
|
2582
|
+
byProductPurchases: purchases.byProduct,
|
|
2566
2583
|
byPlatform
|
|
2567
2584
|
};
|
|
2568
2585
|
}
|
|
@@ -2625,8 +2642,12 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2625
2642
|
const response = await metricsCache.resolve(
|
|
2626
2643
|
["active", metricsCache.quantizeToWindow(Date.now())],
|
|
2627
2644
|
async () => {
|
|
2628
|
-
const
|
|
2629
|
-
|
|
2645
|
+
const now = /* @__PURE__ */ new Date();
|
|
2646
|
+
const [subsAgg, purchasesAgg] = await Promise.all([
|
|
2647
|
+
store.aggregateActive ? store.aggregateActive(now) : store.listAll().then((subs) => aggregateActiveSubscriptions(subs, now.getTime())),
|
|
2648
|
+
purchaseStore.aggregateNonConsumable ? purchaseStore.aggregateNonConsumable() : purchaseStore.listAll().then(aggregateNonConsumablePurchases)
|
|
2649
|
+
]);
|
|
2650
|
+
return composeActiveResponse(subsAgg, purchasesAgg);
|
|
2630
2651
|
}
|
|
2631
2652
|
);
|
|
2632
2653
|
res.status(200).json(response);
|
|
@@ -2659,7 +2680,7 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2659
2680
|
}
|
|
2660
2681
|
return { fromMs, toMs, groupBy };
|
|
2661
2682
|
}
|
|
2662
|
-
function windowed(name, load, anchor, include) {
|
|
2683
|
+
function windowed(name, sqlAggregate, load, anchor, include) {
|
|
2663
2684
|
return async (req, res) => {
|
|
2664
2685
|
const range = parseRange(req);
|
|
2665
2686
|
if ("error" in range) {
|
|
@@ -2674,13 +2695,22 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2674
2695
|
metricsCache.quantizeToWindow(range.toMs),
|
|
2675
2696
|
range.groupBy
|
|
2676
2697
|
],
|
|
2677
|
-
async () =>
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2698
|
+
async () => {
|
|
2699
|
+
if (sqlAggregate) {
|
|
2700
|
+
return sqlAggregate({
|
|
2701
|
+
from: new Date(range.fromMs),
|
|
2702
|
+
to: new Date(range.toMs),
|
|
2703
|
+
groupBy: range.groupBy
|
|
2704
|
+
});
|
|
2705
|
+
}
|
|
2706
|
+
return aggregateRange(await load(), {
|
|
2707
|
+
fromMs: range.fromMs,
|
|
2708
|
+
toMs: range.toMs,
|
|
2709
|
+
groupBy: range.groupBy,
|
|
2710
|
+
anchor,
|
|
2711
|
+
...include ? { include } : {}
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2684
2714
|
);
|
|
2685
2715
|
const response = {
|
|
2686
2716
|
from: req.query["from"],
|
|
@@ -2696,12 +2726,18 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2696
2726
|
}
|
|
2697
2727
|
router.get(
|
|
2698
2728
|
ROUTES.METRICS_STARTED,
|
|
2699
|
-
windowed(
|
|
2729
|
+
windowed(
|
|
2730
|
+
"started",
|
|
2731
|
+
store.aggregateStarted?.bind(store),
|
|
2732
|
+
() => store.listAll(),
|
|
2733
|
+
(sub) => sub.purchasedAt
|
|
2734
|
+
)
|
|
2700
2735
|
);
|
|
2701
2736
|
router.get(
|
|
2702
2737
|
ROUTES.METRICS_EXPIRED,
|
|
2703
2738
|
windowed(
|
|
2704
2739
|
"expired",
|
|
2740
|
+
store.aggregateExpired?.bind(store),
|
|
2705
2741
|
() => store.listAll(),
|
|
2706
2742
|
(sub) => sub.expiresAt,
|
|
2707
2743
|
isEndedSubscription
|
|
@@ -2711,6 +2747,7 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2711
2747
|
ROUTES.METRICS_PURCHASES_STARTED,
|
|
2712
2748
|
windowed(
|
|
2713
2749
|
"purchases-started",
|
|
2750
|
+
purchaseStore.aggregateStarted?.bind(purchaseStore),
|
|
2714
2751
|
() => purchaseStore.listAll(),
|
|
2715
2752
|
(purchase) => purchase.purchasedAt,
|
|
2716
2753
|
isNonConsumable
|
|
@@ -2986,6 +3023,52 @@ var PostgresSubscriptionStore = class {
|
|
|
2986
3023
|
offset
|
|
2987
3024
|
};
|
|
2988
3025
|
}
|
|
3026
|
+
/**
|
|
3027
|
+
* Counts of subscriptions currently granting entitlement.
|
|
3028
|
+
*
|
|
3029
|
+
* One `GROUP BY` instead of reading every row into the process. Grouping by
|
|
3030
|
+
* status as well as product/platform is what lets the grace-period subset come
|
|
3031
|
+
* out of the same query.
|
|
3032
|
+
*/
|
|
3033
|
+
async aggregateActive(now) {
|
|
3034
|
+
const pool = await this.getPool();
|
|
3035
|
+
const result = await pool.query(
|
|
3036
|
+
`SELECT product_id, platform, status, COUNT(*)::text AS n
|
|
3037
|
+
FROM onesub_subscriptions
|
|
3038
|
+
WHERE status IN ($1, $2) AND expires_at > $3
|
|
3039
|
+
GROUP BY product_id, platform, status`,
|
|
3040
|
+
[SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.GRACE_PERIOD, now]
|
|
3041
|
+
);
|
|
3042
|
+
const byProduct = {};
|
|
3043
|
+
const byPlatform = {};
|
|
3044
|
+
let active = 0;
|
|
3045
|
+
let gracePeriod = 0;
|
|
3046
|
+
for (const row of result.rows) {
|
|
3047
|
+
const n = parseInt(row.n, 10);
|
|
3048
|
+
active += n;
|
|
3049
|
+
if (row.status === SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriod += n;
|
|
3050
|
+
byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
|
|
3051
|
+
byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
|
|
3052
|
+
}
|
|
3053
|
+
return { active, gracePeriod, byProduct, byPlatform };
|
|
3054
|
+
}
|
|
3055
|
+
/** Cohort-start counts: subscriptions whose `purchased_at` is in the window. */
|
|
3056
|
+
async aggregateStarted(query) {
|
|
3057
|
+
const pool = await this.getPool();
|
|
3058
|
+
return aggregateViaSql(pool, "onesub_subscriptions", "purchased_at", query);
|
|
3059
|
+
}
|
|
3060
|
+
/**
|
|
3061
|
+
* Subscriptions that have ended AND whose `expires_at` is in the window. A
|
|
3062
|
+
* still-active record does not count even if its expiry lands inside it — see
|
|
3063
|
+
* `isEndedSubscription`, which this mirrors.
|
|
3064
|
+
*/
|
|
3065
|
+
async aggregateExpired(query) {
|
|
3066
|
+
const pool = await this.getPool();
|
|
3067
|
+
return aggregateViaSql(pool, "onesub_subscriptions", "expires_at", query, {
|
|
3068
|
+
column: "status",
|
|
3069
|
+
values: [SUBSCRIPTION_STATUS.EXPIRED, SUBSCRIPTION_STATUS.CANCELED]
|
|
3070
|
+
});
|
|
3071
|
+
}
|
|
2989
3072
|
/** Gracefully close the underlying connection pool. */
|
|
2990
3073
|
async close() {
|
|
2991
3074
|
if (this.poolPromise) {
|
|
@@ -3126,6 +3209,35 @@ var PostgresPurchaseStore = class {
|
|
|
3126
3209
|
const result = await pool.query(`SELECT * FROM onesub_purchases`);
|
|
3127
3210
|
return result.rows.map(rowToPurchaseInfo);
|
|
3128
3211
|
}
|
|
3212
|
+
/** Counts of non-consumable purchases. Consumables are excluded. */
|
|
3213
|
+
async aggregateNonConsumable() {
|
|
3214
|
+
const pool = await this.getPool();
|
|
3215
|
+
const result = await pool.query(
|
|
3216
|
+
`SELECT product_id, platform, COUNT(*)::text AS n
|
|
3217
|
+
FROM onesub_purchases
|
|
3218
|
+
WHERE type = $1
|
|
3219
|
+
GROUP BY product_id, platform`,
|
|
3220
|
+
[PURCHASE_TYPE.NON_CONSUMABLE]
|
|
3221
|
+
);
|
|
3222
|
+
const byProduct = {};
|
|
3223
|
+
const byPlatform = {};
|
|
3224
|
+
let total = 0;
|
|
3225
|
+
for (const row of result.rows) {
|
|
3226
|
+
const n = parseInt(row.n, 10);
|
|
3227
|
+
total += n;
|
|
3228
|
+
byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
|
|
3229
|
+
byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
|
|
3230
|
+
}
|
|
3231
|
+
return { total, byProduct, byPlatform };
|
|
3232
|
+
}
|
|
3233
|
+
/** Non-consumable purchases whose `purchased_at` is in the window. */
|
|
3234
|
+
async aggregateStarted(query) {
|
|
3235
|
+
const pool = await this.getPool();
|
|
3236
|
+
return aggregateViaSql(pool, "onesub_purchases", "purchased_at", query, {
|
|
3237
|
+
column: "type",
|
|
3238
|
+
values: [PURCHASE_TYPE.NON_CONSUMABLE]
|
|
3239
|
+
});
|
|
3240
|
+
}
|
|
3129
3241
|
/** Gracefully close the underlying connection pool. */
|
|
3130
3242
|
async close() {
|
|
3131
3243
|
if (this.poolPromise) {
|
|
@@ -3135,6 +3247,42 @@ var PostgresPurchaseStore = class {
|
|
|
3135
3247
|
}
|
|
3136
3248
|
}
|
|
3137
3249
|
};
|
|
3250
|
+
async function aggregateViaSql(pool, table, anchorColumn, query, filter) {
|
|
3251
|
+
const params = [query.from, query.to];
|
|
3252
|
+
let filterClause = "";
|
|
3253
|
+
if (filter) {
|
|
3254
|
+
const placeholders = filter.values.map((_, i) => `$${params.length + i + 1}`).join(", ");
|
|
3255
|
+
filterClause = `AND ${filter.column} IN (${placeholders})`;
|
|
3256
|
+
params.push(...filter.values);
|
|
3257
|
+
}
|
|
3258
|
+
const bucketing = query.groupBy === "day";
|
|
3259
|
+
const dayExpr = `to_char(date_trunc('day', ${anchorColumn} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`;
|
|
3260
|
+
const result = await pool.query(
|
|
3261
|
+
`SELECT product_id, platform,${bucketing ? ` ${dayExpr} AS day,` : ""}
|
|
3262
|
+
COUNT(*)::text AS n
|
|
3263
|
+
FROM ${table}
|
|
3264
|
+
WHERE ${anchorColumn} >= $1 AND ${anchorColumn} <= $2 ${filterClause}
|
|
3265
|
+
GROUP BY product_id, platform${bucketing ? ", day" : ""}`,
|
|
3266
|
+
params
|
|
3267
|
+
);
|
|
3268
|
+
const byProduct = {};
|
|
3269
|
+
const byPlatform = {};
|
|
3270
|
+
const perDay = /* @__PURE__ */ new Map();
|
|
3271
|
+
let total = 0;
|
|
3272
|
+
for (const row of result.rows) {
|
|
3273
|
+
const n = parseInt(row.n, 10);
|
|
3274
|
+
total += n;
|
|
3275
|
+
byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
|
|
3276
|
+
byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
|
|
3277
|
+
if (bucketing && row.day) perDay.set(row.day, (perDay.get(row.day) ?? 0) + n);
|
|
3278
|
+
}
|
|
3279
|
+
if (!bucketing) return { total, byProduct, byPlatform };
|
|
3280
|
+
const buckets = emptyDailyBuckets(query.from.getTime(), query.to.getTime()).map((b) => ({
|
|
3281
|
+
date: b.date,
|
|
3282
|
+
count: perDay.get(b.date) ?? 0
|
|
3283
|
+
}));
|
|
3284
|
+
return { total, byProduct, byPlatform, buckets };
|
|
3285
|
+
}
|
|
3138
3286
|
function rowToSubscriptionInfo(row) {
|
|
3139
3287
|
return {
|
|
3140
3288
|
originalTransactionId: row.original_transaction_id,
|