@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/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 (googleApps.length > 0 && !googleApps.some((g) => g.pushAudience)) {
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. The route is mounted whether or not Google is configured; set google.packageName, or block the path at your proxy if this deployment does not serve Google Play."
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
- router.post(
1978
- ROUTES.WEBHOOK_GOOGLE,
1979
- (req, res) => handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue)
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 aggregateActive(subs, purchases, nowMs) {
2545
+ function aggregateActiveSubscriptions(subs, nowMs) {
2540
2546
  const byProduct = {};
2541
- const byProductPurchases = {};
2542
2547
  const byPlatform = {};
2543
- let activeSubscriptions = 0;
2544
- let gracePeriodSubscriptions = 0;
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
- activeSubscriptions++;
2549
- if (sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriodSubscriptions++;
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
- nonConsumablePurchases++;
2556
- bump(byProductPurchases, purchase.productId);
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: activeSubscriptions + nonConsumablePurchases,
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 [subs, purchases] = await Promise.all([store.listAll(), purchaseStore.listAll()]);
2629
- return aggregateActive(subs, purchases, Date.now());
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 () => aggregateRange(await load(), {
2678
- fromMs: range.fromMs,
2679
- toMs: range.toMs,
2680
- groupBy: range.groupBy,
2681
- anchor,
2682
- ...include ? { include } : {}
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("started", () => store.listAll(), (sub) => sub.purchasedAt)
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,