@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
|
@@ -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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /onesub/webhook/google` is mounted only for deployments that serve
|
|
3
|
+
* Google Play.
|
|
4
|
+
*
|
|
5
|
+
* Why it is conditional at all: unlike Apple's route, it does not authenticate
|
|
6
|
+
* unconditionally — the Pub/Sub OIDC token is verified only when an app declares
|
|
7
|
+
* a `pushAudience` — and its voided-purchase branch needs no Google credentials
|
|
8
|
+
* to run. It cancels a subscription by `purchaseToken`, or deletes a purchase row
|
|
9
|
+
* by `orderId`, straight from the payload. So an Apple-only deployment used to
|
|
10
|
+
* expose an unauthenticated endpoint that could revoke entitlement, with no
|
|
11
|
+
* Google purchases for it to be about.
|
|
12
|
+
*
|
|
13
|
+
* Apple's route stays unconditional, and this file asserts that too: it verifies
|
|
14
|
+
* the `signedPayload` JWS against the bundled Apple roots on every request
|
|
15
|
+
* regardless of config, so it is not open in the same way.
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=webhook-google-mount.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-google-mount.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/webhook-google-mount.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG"}
|
package/dist/index.cjs
CHANGED
|
@@ -1751,18 +1751,22 @@ var GOOGLE_FAILURE_MESSAGES = {
|
|
|
1751
1751
|
message: "Failed to process notification"
|
|
1752
1752
|
}
|
|
1753
1753
|
};
|
|
1754
|
+
function servesGoogle(config) {
|
|
1755
|
+
return getAppRegistry(config).apps.some((app) => !!app.google);
|
|
1756
|
+
}
|
|
1754
1757
|
function warnIfGoogleWebhookOpen(config) {
|
|
1755
1758
|
if (process.env["NODE_ENV"] !== "production") return;
|
|
1759
|
+
if (!servesGoogle(config)) return;
|
|
1756
1760
|
const apps = getAppRegistry(config).apps;
|
|
1757
1761
|
const googleApps = apps.map((a) => a.google).filter((g) => !!g);
|
|
1758
|
-
if (
|
|
1762
|
+
if (!googleApps.some((g) => g.pushAudience)) {
|
|
1759
1763
|
log.warn(
|
|
1760
1764
|
"[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)."
|
|
1761
1765
|
);
|
|
1762
1766
|
}
|
|
1763
1767
|
if (!apps.some((a) => a.google?.packageName)) {
|
|
1764
1768
|
log.warn(
|
|
1765
|
-
"[onesub] POST /onesub/webhook/google is in open mode \u2014 no configured app declares google.packageName, so notifications for ANY package are accepted.
|
|
1769
|
+
"[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."
|
|
1766
1770
|
);
|
|
1767
1771
|
}
|
|
1768
1772
|
}
|
|
@@ -1981,10 +1985,12 @@ function createWebhookRouter(config, store, purchaseStore, webhookEventStore, we
|
|
|
1981
1985
|
shared.ROUTES.WEBHOOK_APPLE,
|
|
1982
1986
|
(req, res) => handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue)
|
|
1983
1987
|
);
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
+
if (servesGoogle(config)) {
|
|
1989
|
+
router.post(
|
|
1990
|
+
shared.ROUTES.WEBHOOK_GOOGLE,
|
|
1991
|
+
(req, res) => handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue)
|
|
1992
|
+
);
|
|
1993
|
+
}
|
|
1988
1994
|
return router;
|
|
1989
1995
|
}
|
|
1990
1996
|
var NO_PURCHASE = { valid: false, purchase: null };
|
|
@@ -2543,33 +2549,44 @@ function isEndedSubscription(sub) {
|
|
|
2543
2549
|
function isNonConsumable(purchase) {
|
|
2544
2550
|
return purchase.type === shared.PURCHASE_TYPE.NON_CONSUMABLE;
|
|
2545
2551
|
}
|
|
2546
|
-
function
|
|
2552
|
+
function aggregateActiveSubscriptions(subs, nowMs) {
|
|
2547
2553
|
const byProduct = {};
|
|
2548
|
-
const byProductPurchases = {};
|
|
2549
2554
|
const byPlatform = {};
|
|
2550
|
-
let
|
|
2551
|
-
let
|
|
2552
|
-
let nonConsumablePurchases = 0;
|
|
2555
|
+
let active = 0;
|
|
2556
|
+
let gracePeriod = 0;
|
|
2553
2557
|
for (const sub of subs) {
|
|
2554
2558
|
if (!isActiveSubscription(sub, nowMs)) continue;
|
|
2555
|
-
|
|
2556
|
-
if (sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD)
|
|
2559
|
+
active++;
|
|
2560
|
+
if (sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriod++;
|
|
2557
2561
|
bump(byProduct, sub.productId);
|
|
2558
2562
|
bump(byPlatform, sub.platform);
|
|
2559
2563
|
}
|
|
2564
|
+
return { active, gracePeriod, byProduct, byPlatform };
|
|
2565
|
+
}
|
|
2566
|
+
function aggregateNonConsumablePurchases(purchases) {
|
|
2567
|
+
const byProduct = {};
|
|
2568
|
+
const byPlatform = {};
|
|
2569
|
+
let total = 0;
|
|
2560
2570
|
for (const purchase of purchases) {
|
|
2561
2571
|
if (!isNonConsumable(purchase)) continue;
|
|
2562
|
-
|
|
2563
|
-
bump(
|
|
2572
|
+
total++;
|
|
2573
|
+
bump(byProduct, purchase.productId);
|
|
2564
2574
|
bump(byPlatform, purchase.platform);
|
|
2565
2575
|
}
|
|
2576
|
+
return { total, byProduct, byPlatform };
|
|
2577
|
+
}
|
|
2578
|
+
function composeActiveResponse(subs, purchases) {
|
|
2579
|
+
const byPlatform = { ...subs.byPlatform };
|
|
2580
|
+
for (const [platform, count] of Object.entries(purchases.byPlatform)) {
|
|
2581
|
+
byPlatform[platform] = (byPlatform[platform] ?? 0) + count;
|
|
2582
|
+
}
|
|
2566
2583
|
return {
|
|
2567
|
-
total:
|
|
2568
|
-
activeSubscriptions,
|
|
2569
|
-
gracePeriodSubscriptions,
|
|
2570
|
-
nonConsumablePurchases,
|
|
2571
|
-
byProduct,
|
|
2572
|
-
byProductPurchases,
|
|
2584
|
+
total: subs.active + purchases.total,
|
|
2585
|
+
activeSubscriptions: subs.active,
|
|
2586
|
+
gracePeriodSubscriptions: subs.gracePeriod,
|
|
2587
|
+
nonConsumablePurchases: purchases.total,
|
|
2588
|
+
byProduct: subs.byProduct,
|
|
2589
|
+
byProductPurchases: purchases.byProduct,
|
|
2573
2590
|
byPlatform
|
|
2574
2591
|
};
|
|
2575
2592
|
}
|
|
@@ -2632,8 +2649,12 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2632
2649
|
const response = await metricsCache.resolve(
|
|
2633
2650
|
["active", metricsCache.quantizeToWindow(Date.now())],
|
|
2634
2651
|
async () => {
|
|
2635
|
-
const
|
|
2636
|
-
|
|
2652
|
+
const now = /* @__PURE__ */ new Date();
|
|
2653
|
+
const [subsAgg, purchasesAgg] = await Promise.all([
|
|
2654
|
+
store.aggregateActive ? store.aggregateActive(now) : store.listAll().then((subs) => aggregateActiveSubscriptions(subs, now.getTime())),
|
|
2655
|
+
purchaseStore.aggregateNonConsumable ? purchaseStore.aggregateNonConsumable() : purchaseStore.listAll().then(aggregateNonConsumablePurchases)
|
|
2656
|
+
]);
|
|
2657
|
+
return composeActiveResponse(subsAgg, purchasesAgg);
|
|
2637
2658
|
}
|
|
2638
2659
|
);
|
|
2639
2660
|
res.status(200).json(response);
|
|
@@ -2666,7 +2687,7 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2666
2687
|
}
|
|
2667
2688
|
return { fromMs, toMs, groupBy };
|
|
2668
2689
|
}
|
|
2669
|
-
function windowed(name, load, anchor, include) {
|
|
2690
|
+
function windowed(name, sqlAggregate, load, anchor, include) {
|
|
2670
2691
|
return async (req, res) => {
|
|
2671
2692
|
const range = parseRange(req);
|
|
2672
2693
|
if ("error" in range) {
|
|
@@ -2681,13 +2702,22 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2681
2702
|
metricsCache.quantizeToWindow(range.toMs),
|
|
2682
2703
|
range.groupBy
|
|
2683
2704
|
],
|
|
2684
|
-
async () =>
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2705
|
+
async () => {
|
|
2706
|
+
if (sqlAggregate) {
|
|
2707
|
+
return sqlAggregate({
|
|
2708
|
+
from: new Date(range.fromMs),
|
|
2709
|
+
to: new Date(range.toMs),
|
|
2710
|
+
groupBy: range.groupBy
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
return aggregateRange(await load(), {
|
|
2714
|
+
fromMs: range.fromMs,
|
|
2715
|
+
toMs: range.toMs,
|
|
2716
|
+
groupBy: range.groupBy,
|
|
2717
|
+
anchor,
|
|
2718
|
+
...include ? { include } : {}
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2691
2721
|
);
|
|
2692
2722
|
const response = {
|
|
2693
2723
|
from: req.query["from"],
|
|
@@ -2703,12 +2733,18 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2703
2733
|
}
|
|
2704
2734
|
router.get(
|
|
2705
2735
|
shared.ROUTES.METRICS_STARTED,
|
|
2706
|
-
windowed(
|
|
2736
|
+
windowed(
|
|
2737
|
+
"started",
|
|
2738
|
+
store.aggregateStarted?.bind(store),
|
|
2739
|
+
() => store.listAll(),
|
|
2740
|
+
(sub) => sub.purchasedAt
|
|
2741
|
+
)
|
|
2707
2742
|
);
|
|
2708
2743
|
router.get(
|
|
2709
2744
|
shared.ROUTES.METRICS_EXPIRED,
|
|
2710
2745
|
windowed(
|
|
2711
2746
|
"expired",
|
|
2747
|
+
store.aggregateExpired?.bind(store),
|
|
2712
2748
|
() => store.listAll(),
|
|
2713
2749
|
(sub) => sub.expiresAt,
|
|
2714
2750
|
isEndedSubscription
|
|
@@ -2718,6 +2754,7 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2718
2754
|
shared.ROUTES.METRICS_PURCHASES_STARTED,
|
|
2719
2755
|
windowed(
|
|
2720
2756
|
"purchases-started",
|
|
2757
|
+
purchaseStore.aggregateStarted?.bind(purchaseStore),
|
|
2721
2758
|
() => purchaseStore.listAll(),
|
|
2722
2759
|
(purchase) => purchase.purchasedAt,
|
|
2723
2760
|
isNonConsumable
|
|
@@ -2993,6 +3030,52 @@ var PostgresSubscriptionStore = class {
|
|
|
2993
3030
|
offset
|
|
2994
3031
|
};
|
|
2995
3032
|
}
|
|
3033
|
+
/**
|
|
3034
|
+
* Counts of subscriptions currently granting entitlement.
|
|
3035
|
+
*
|
|
3036
|
+
* One `GROUP BY` instead of reading every row into the process. Grouping by
|
|
3037
|
+
* status as well as product/platform is what lets the grace-period subset come
|
|
3038
|
+
* out of the same query.
|
|
3039
|
+
*/
|
|
3040
|
+
async aggregateActive(now) {
|
|
3041
|
+
const pool = await this.getPool();
|
|
3042
|
+
const result = await pool.query(
|
|
3043
|
+
`SELECT product_id, platform, status, COUNT(*)::text AS n
|
|
3044
|
+
FROM onesub_subscriptions
|
|
3045
|
+
WHERE status IN ($1, $2) AND expires_at > $3
|
|
3046
|
+
GROUP BY product_id, platform, status`,
|
|
3047
|
+
[shared.SUBSCRIPTION_STATUS.ACTIVE, shared.SUBSCRIPTION_STATUS.GRACE_PERIOD, now]
|
|
3048
|
+
);
|
|
3049
|
+
const byProduct = {};
|
|
3050
|
+
const byPlatform = {};
|
|
3051
|
+
let active = 0;
|
|
3052
|
+
let gracePeriod = 0;
|
|
3053
|
+
for (const row of result.rows) {
|
|
3054
|
+
const n = parseInt(row.n, 10);
|
|
3055
|
+
active += n;
|
|
3056
|
+
if (row.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriod += n;
|
|
3057
|
+
byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
|
|
3058
|
+
byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
|
|
3059
|
+
}
|
|
3060
|
+
return { active, gracePeriod, byProduct, byPlatform };
|
|
3061
|
+
}
|
|
3062
|
+
/** Cohort-start counts: subscriptions whose `purchased_at` is in the window. */
|
|
3063
|
+
async aggregateStarted(query) {
|
|
3064
|
+
const pool = await this.getPool();
|
|
3065
|
+
return aggregateViaSql(pool, "onesub_subscriptions", "purchased_at", query);
|
|
3066
|
+
}
|
|
3067
|
+
/**
|
|
3068
|
+
* Subscriptions that have ended AND whose `expires_at` is in the window. A
|
|
3069
|
+
* still-active record does not count even if its expiry lands inside it — see
|
|
3070
|
+
* `isEndedSubscription`, which this mirrors.
|
|
3071
|
+
*/
|
|
3072
|
+
async aggregateExpired(query) {
|
|
3073
|
+
const pool = await this.getPool();
|
|
3074
|
+
return aggregateViaSql(pool, "onesub_subscriptions", "expires_at", query, {
|
|
3075
|
+
column: "status",
|
|
3076
|
+
values: [shared.SUBSCRIPTION_STATUS.EXPIRED, shared.SUBSCRIPTION_STATUS.CANCELED]
|
|
3077
|
+
});
|
|
3078
|
+
}
|
|
2996
3079
|
/** Gracefully close the underlying connection pool. */
|
|
2997
3080
|
async close() {
|
|
2998
3081
|
if (this.poolPromise) {
|
|
@@ -3133,6 +3216,35 @@ var PostgresPurchaseStore = class {
|
|
|
3133
3216
|
const result = await pool.query(`SELECT * FROM onesub_purchases`);
|
|
3134
3217
|
return result.rows.map(rowToPurchaseInfo);
|
|
3135
3218
|
}
|
|
3219
|
+
/** Counts of non-consumable purchases. Consumables are excluded. */
|
|
3220
|
+
async aggregateNonConsumable() {
|
|
3221
|
+
const pool = await this.getPool();
|
|
3222
|
+
const result = await pool.query(
|
|
3223
|
+
`SELECT product_id, platform, COUNT(*)::text AS n
|
|
3224
|
+
FROM onesub_purchases
|
|
3225
|
+
WHERE type = $1
|
|
3226
|
+
GROUP BY product_id, platform`,
|
|
3227
|
+
[shared.PURCHASE_TYPE.NON_CONSUMABLE]
|
|
3228
|
+
);
|
|
3229
|
+
const byProduct = {};
|
|
3230
|
+
const byPlatform = {};
|
|
3231
|
+
let total = 0;
|
|
3232
|
+
for (const row of result.rows) {
|
|
3233
|
+
const n = parseInt(row.n, 10);
|
|
3234
|
+
total += n;
|
|
3235
|
+
byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
|
|
3236
|
+
byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
|
|
3237
|
+
}
|
|
3238
|
+
return { total, byProduct, byPlatform };
|
|
3239
|
+
}
|
|
3240
|
+
/** Non-consumable purchases whose `purchased_at` is in the window. */
|
|
3241
|
+
async aggregateStarted(query) {
|
|
3242
|
+
const pool = await this.getPool();
|
|
3243
|
+
return aggregateViaSql(pool, "onesub_purchases", "purchased_at", query, {
|
|
3244
|
+
column: "type",
|
|
3245
|
+
values: [shared.PURCHASE_TYPE.NON_CONSUMABLE]
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3136
3248
|
/** Gracefully close the underlying connection pool. */
|
|
3137
3249
|
async close() {
|
|
3138
3250
|
if (this.poolPromise) {
|
|
@@ -3142,6 +3254,42 @@ var PostgresPurchaseStore = class {
|
|
|
3142
3254
|
}
|
|
3143
3255
|
}
|
|
3144
3256
|
};
|
|
3257
|
+
async function aggregateViaSql(pool, table, anchorColumn, query, filter) {
|
|
3258
|
+
const params = [query.from, query.to];
|
|
3259
|
+
let filterClause = "";
|
|
3260
|
+
if (filter) {
|
|
3261
|
+
const placeholders = filter.values.map((_, i) => `$${params.length + i + 1}`).join(", ");
|
|
3262
|
+
filterClause = `AND ${filter.column} IN (${placeholders})`;
|
|
3263
|
+
params.push(...filter.values);
|
|
3264
|
+
}
|
|
3265
|
+
const bucketing = query.groupBy === "day";
|
|
3266
|
+
const dayExpr = `to_char(date_trunc('day', ${anchorColumn} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`;
|
|
3267
|
+
const result = await pool.query(
|
|
3268
|
+
`SELECT product_id, platform,${bucketing ? ` ${dayExpr} AS day,` : ""}
|
|
3269
|
+
COUNT(*)::text AS n
|
|
3270
|
+
FROM ${table}
|
|
3271
|
+
WHERE ${anchorColumn} >= $1 AND ${anchorColumn} <= $2 ${filterClause}
|
|
3272
|
+
GROUP BY product_id, platform${bucketing ? ", day" : ""}`,
|
|
3273
|
+
params
|
|
3274
|
+
);
|
|
3275
|
+
const byProduct = {};
|
|
3276
|
+
const byPlatform = {};
|
|
3277
|
+
const perDay = /* @__PURE__ */ new Map();
|
|
3278
|
+
let total = 0;
|
|
3279
|
+
for (const row of result.rows) {
|
|
3280
|
+
const n = parseInt(row.n, 10);
|
|
3281
|
+
total += n;
|
|
3282
|
+
byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
|
|
3283
|
+
byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
|
|
3284
|
+
if (bucketing && row.day) perDay.set(row.day, (perDay.get(row.day) ?? 0) + n);
|
|
3285
|
+
}
|
|
3286
|
+
if (!bucketing) return { total, byProduct, byPlatform };
|
|
3287
|
+
const buckets = emptyDailyBuckets(query.from.getTime(), query.to.getTime()).map((b) => ({
|
|
3288
|
+
date: b.date,
|
|
3289
|
+
count: perDay.get(b.date) ?? 0
|
|
3290
|
+
}));
|
|
3291
|
+
return { total, byProduct, byPlatform, buckets };
|
|
3292
|
+
}
|
|
3145
3293
|
function rowToSubscriptionInfo(row) {
|
|
3146
3294
|
return {
|
|
3147
3295
|
originalTransactionId: row.original_transaction_id,
|