@onesub/server 0.14.0 → 0.16.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.
Files changed (140) hide show
  1. package/dist/__tests__/google-subscriptions-v2.test.d.ts +1 -1
  2. package/dist/__tests__/receipt-age.test.d.ts +7 -0
  3. package/dist/__tests__/receipt-age.test.d.ts.map +1 -0
  4. package/dist/__tests__/tracing.test.d.ts +2 -0
  5. package/dist/__tests__/tracing.test.d.ts.map +1 -0
  6. package/dist/cache.d.ts +5 -0
  7. package/dist/cache.d.ts.map +1 -1
  8. package/dist/index.cjs +178 -55
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.js +178 -55
  11. package/dist/index.js.map +1 -1
  12. package/dist/providers/apple.d.ts.map +1 -1
  13. package/dist/providers/google.d.ts.map +1 -1
  14. package/dist/providers/mock.d.ts.map +1 -1
  15. package/dist/routes/admin.d.ts.map +1 -1
  16. package/dist/routes/purchase.d.ts.map +1 -1
  17. package/dist/routes/validate.d.ts.map +1 -1
  18. package/dist/routes/webhook-apple.d.ts.map +1 -1
  19. package/dist/routes/webhook-google.d.ts +1 -1
  20. package/dist/routes/webhook-google.d.ts.map +1 -1
  21. package/dist/store.d.ts.map +1 -1
  22. package/dist/stores/postgres.d.ts.map +1 -1
  23. package/dist/stores/redis.d.ts +1 -0
  24. package/dist/stores/redis.d.ts.map +1 -1
  25. package/dist/tracing.d.ts.map +1 -1
  26. package/dist/webhook-events.d.ts +10 -0
  27. package/dist/webhook-events.d.ts.map +1 -1
  28. package/package.json +5 -3
  29. package/dist/__tests__/admin-customer-detail.test.js +0 -146
  30. package/dist/__tests__/admin-customer-detail.test.js.map +0 -1
  31. package/dist/__tests__/admin-list-subscriptions.test.js +0 -192
  32. package/dist/__tests__/admin-list-subscriptions.test.js.map +0 -1
  33. package/dist/__tests__/admin-subscription-detail.test.js +0 -112
  34. package/dist/__tests__/admin-subscription-detail.test.js.map +0 -1
  35. package/dist/__tests__/apple-root-ca.test.js +0 -41
  36. package/dist/__tests__/apple-root-ca.test.js.map +0 -1
  37. package/dist/__tests__/apple-status-api.test.js +0 -345
  38. package/dist/__tests__/apple-status-api.test.js.map +0 -1
  39. package/dist/__tests__/cache.test.js +0 -34
  40. package/dist/__tests__/cache.test.js.map +0 -1
  41. package/dist/__tests__/entitlements.test.js +0 -268
  42. package/dist/__tests__/entitlements.test.js.map +0 -1
  43. package/dist/__tests__/error-codes.test.js +0 -173
  44. package/dist/__tests__/error-codes.test.js.map +0 -1
  45. package/dist/__tests__/fetch-hardening.test.js +0 -192
  46. package/dist/__tests__/fetch-hardening.test.js.map +0 -1
  47. package/dist/__tests__/google-paused.test.js +0 -126
  48. package/dist/__tests__/google-paused.test.js.map +0 -1
  49. package/dist/__tests__/google-price-change.test.js +0 -186
  50. package/dist/__tests__/google-price-change.test.js.map +0 -1
  51. package/dist/__tests__/google-subscriptions-v2.test.js +0 -206
  52. package/dist/__tests__/google-subscriptions-v2.test.js.map +0 -1
  53. package/dist/__tests__/lifecycle-scenarios.test.js +0 -579
  54. package/dist/__tests__/lifecycle-scenarios.test.js.map +0 -1
  55. package/dist/__tests__/lifecycle-states.test.js +0 -423
  56. package/dist/__tests__/lifecycle-states.test.js.map +0 -1
  57. package/dist/__tests__/linked-purchase-token.test.js +0 -227
  58. package/dist/__tests__/linked-purchase-token.test.js.map +0 -1
  59. package/dist/__tests__/metrics.test.js +0 -340
  60. package/dist/__tests__/metrics.test.js.map +0 -1
  61. package/dist/__tests__/mock-provider.test.js +0 -188
  62. package/dist/__tests__/mock-provider.test.js.map +0 -1
  63. package/dist/__tests__/openapi.test.js +0 -32
  64. package/dist/__tests__/openapi.test.js.map +0 -1
  65. package/dist/__tests__/paused-auto-resume.test.js +0 -162
  66. package/dist/__tests__/paused-auto-resume.test.js.map +0 -1
  67. package/dist/__tests__/providers.test.js +0 -321
  68. package/dist/__tests__/providers.test.js.map +0 -1
  69. package/dist/__tests__/redis-store.test.js +0 -166
  70. package/dist/__tests__/redis-store.test.js.map +0 -1
  71. package/dist/__tests__/refund-policy.test.js +0 -317
  72. package/dist/__tests__/refund-policy.test.js.map +0 -1
  73. package/dist/__tests__/schema.test.js +0 -46
  74. package/dist/__tests__/schema.test.js.map +0 -1
  75. package/dist/__tests__/store.test.js +0 -172
  76. package/dist/__tests__/store.test.js.map +0 -1
  77. package/dist/__tests__/test-utils.js +0 -30
  78. package/dist/__tests__/test-utils.js.map +0 -1
  79. package/dist/__tests__/webhook-apple.test.js +0 -247
  80. package/dist/__tests__/webhook-apple.test.js.map +0 -1
  81. package/dist/__tests__/webhook-events.test.js +0 -36
  82. package/dist/__tests__/webhook-events.test.js.map +0 -1
  83. package/dist/__tests__/webhook-google.test.js +0 -255
  84. package/dist/__tests__/webhook-google.test.js.map +0 -1
  85. package/dist/__tests__/webhook-queue.test.js +0 -26
  86. package/dist/__tests__/webhook-queue.test.js.map +0 -1
  87. package/dist/__tests__/webhook-refund.test.js +0 -313
  88. package/dist/__tests__/webhook-refund.test.js.map +0 -1
  89. package/dist/cache.js +0 -39
  90. package/dist/cache.js.map +0 -1
  91. package/dist/errors.js +0 -19
  92. package/dist/errors.js.map +0 -1
  93. package/dist/http.js +0 -36
  94. package/dist/http.js.map +0 -1
  95. package/dist/logger.js +0 -20
  96. package/dist/logger.js.map +0 -1
  97. package/dist/openapi.js +0 -196
  98. package/dist/openapi.js.map +0 -1
  99. package/dist/providers/apple-root-ca.js +0 -38
  100. package/dist/providers/apple-root-ca.js.map +0 -1
  101. package/dist/providers/apple.js +0 -630
  102. package/dist/providers/apple.js.map +0 -1
  103. package/dist/providers/google.js +0 -564
  104. package/dist/providers/google.js.map +0 -1
  105. package/dist/providers/mock.js +0 -93
  106. package/dist/providers/mock.js.map +0 -1
  107. package/dist/routes/admin.js +0 -327
  108. package/dist/routes/admin.js.map +0 -1
  109. package/dist/routes/apple-offer.js +0 -72
  110. package/dist/routes/apple-offer.js.map +0 -1
  111. package/dist/routes/entitlements.js +0 -130
  112. package/dist/routes/entitlements.js.map +0 -1
  113. package/dist/routes/metrics.js +0 -281
  114. package/dist/routes/metrics.js.map +0 -1
  115. package/dist/routes/purchase.js +0 -204
  116. package/dist/routes/purchase.js.map +0 -1
  117. package/dist/routes/status.js +0 -55
  118. package/dist/routes/status.js.map +0 -1
  119. package/dist/routes/validate.js +0 -70
  120. package/dist/routes/validate.js.map +0 -1
  121. package/dist/routes/webhook-apple.js +0 -145
  122. package/dist/routes/webhook-apple.js.map +0 -1
  123. package/dist/routes/webhook-google.js +0 -204
  124. package/dist/routes/webhook-google.js.map +0 -1
  125. package/dist/routes/webhook.js +0 -19
  126. package/dist/routes/webhook.js.map +0 -1
  127. package/dist/store.js +0 -145
  128. package/dist/store.js.map +0 -1
  129. package/dist/stores/postgres.js +0 -329
  130. package/dist/stores/postgres.js.map +0 -1
  131. package/dist/stores/redis.js +0 -297
  132. package/dist/stores/redis.js.map +0 -1
  133. package/dist/stores/schema.js +0 -67
  134. package/dist/stores/schema.js.map +0 -1
  135. package/dist/tracing.js +0 -74
  136. package/dist/tracing.js.map +0 -1
  137. package/dist/webhook-events.js +0 -58
  138. package/dist/webhook-events.js.map +0 -1
  139. package/dist/webhook-queue.js +0 -108
  140. package/dist/webhook-queue.js.map +0 -1
package/dist/index.js CHANGED
@@ -3,8 +3,14 @@ import { PURCHASE_TYPE, ROUTES, DEFAULT_PORT, ONESUB_ERROR_CODE, SUBSCRIPTION_ST
3
3
  import { z } from 'zod';
4
4
  import { decodeJwt, decodeProtectedHeader, importX509, jwtVerify, importPKCS8, SignJWT, createRemoteJWKSet } from 'jose';
5
5
  import { X509Certificate, randomUUID, createSign, createHash } from 'crypto';
6
+ import { createRequire } from 'module';
6
7
 
7
- // src/index.ts
8
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
9
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
10
+ }) : x)(function(x) {
11
+ if (typeof require !== "undefined") return require.apply(this, arguments);
12
+ throw Error('Dynamic require of "' + x + '" is not supported');
13
+ });
8
14
 
9
15
  // src/store.ts
10
16
  var InMemorySubscriptionStore = class {
@@ -14,6 +20,14 @@ var InMemorySubscriptionStore = class {
14
20
  byUserId = /* @__PURE__ */ new Map();
15
21
  byTransactionId = /* @__PURE__ */ new Map();
16
22
  async save(sub) {
23
+ const prev = this.byTransactionId.get(sub.originalTransactionId);
24
+ if (prev && prev.userId !== sub.userId) {
25
+ const oldList = (this.byUserId.get(prev.userId) ?? []).filter(
26
+ (s) => s.originalTransactionId !== sub.originalTransactionId
27
+ );
28
+ if (oldList.length) this.byUserId.set(prev.userId, oldList);
29
+ else this.byUserId.delete(prev.userId);
30
+ }
17
31
  this.byTransactionId.set(sub.originalTransactionId, sub);
18
32
  const existing = this.byUserId.get(sub.userId) ?? [];
19
33
  const filtered = existing.filter((s) => s.originalTransactionId !== sub.originalTransactionId);
@@ -174,8 +188,10 @@ async function fetchWithTimeout(input, init, timeoutMs = DEFAULT_FETCH_TIMEOUT_M
174
188
  }
175
189
 
176
190
  // src/cache.ts
191
+ var SWEEP_EVERY_N_SETS = 256;
177
192
  var InMemoryCacheAdapter = class {
178
193
  entries = /* @__PURE__ */ new Map();
194
+ setsSinceSweep = 0;
179
195
  async get(key) {
180
196
  const entry = this.entries.get(key);
181
197
  if (!entry) return null;
@@ -188,13 +204,29 @@ var InMemoryCacheAdapter = class {
188
204
  async set(key, value, ttlSeconds) {
189
205
  const expiresAt = ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1e3 : 0;
190
206
  this.entries.set(key, { value, expiresAt });
207
+ if (++this.setsSinceSweep >= SWEEP_EVERY_N_SETS) {
208
+ this.sweepExpired();
209
+ }
191
210
  }
192
211
  async del(key) {
193
212
  this.entries.delete(key);
194
213
  }
214
+ /** Evict every entry whose TTL has elapsed. */
215
+ sweepExpired() {
216
+ this.setsSinceSweep = 0;
217
+ const now = Date.now();
218
+ for (const [key, entry] of this.entries) {
219
+ if (entry.expiresAt !== 0 && entry.expiresAt < now) this.entries.delete(key);
220
+ }
221
+ }
195
222
  /** Test helper — clear everything. Not part of the public CacheAdapter contract. */
196
223
  clear() {
197
224
  this.entries.clear();
225
+ this.setsSinceSweep = 0;
226
+ }
227
+ /** Test helper — number of entries currently held (including not-yet-swept expired ones). */
228
+ get size() {
229
+ return this.entries.size;
198
230
  }
199
231
  };
200
232
  var defaultAdapter = new InMemoryCacheAdapter();
@@ -233,6 +265,7 @@ function mockValidateAppleSubscription(receipt) {
233
265
  if (!outcomePasses(outcome, "apple")) return null;
234
266
  const now = Date.now();
235
267
  const expiresAt = outcome.kind === "sandbox" ? now + 1 * HOURS : now + 30 * DAYS;
268
+ const tokenMatch = receipt.match(/#token=([^#]+)/);
236
269
  return {
237
270
  userId: "",
238
271
  productId: "mock_subscription",
@@ -241,7 +274,8 @@ function mockValidateAppleSubscription(receipt) {
241
274
  expiresAt: new Date(expiresAt).toISOString(),
242
275
  originalTransactionId: deterministicTransactionId("mock_apple_orig", receipt),
243
276
  purchasedAt: new Date(now).toISOString(),
244
- willRenew: true
277
+ willRenew: true,
278
+ ...tokenMatch ? { boundAccountId: tokenMatch[1] } : {}
245
279
  };
246
280
  }
247
281
  function mockValidateAppleProduct(receipt, expectedProductId) {
@@ -261,15 +295,19 @@ function mockValidateGoogleSubscription(receipt, productId) {
261
295
  if (!outcomePasses(outcome, "google")) return null;
262
296
  const now = Date.now();
263
297
  const expiresAt = outcome.kind === "sandbox" ? now + 1 * HOURS : now + 30 * DAYS;
298
+ const tokenMatch = receipt.match(/#token=([^#]+)/);
264
299
  return {
265
300
  userId: "",
266
301
  productId,
267
302
  platform: "google",
268
303
  status: SUBSCRIPTION_STATUS.ACTIVE,
269
304
  expiresAt: new Date(expiresAt).toISOString(),
270
- originalTransactionId: deterministicTransactionId("mock_google_orig", receipt),
305
+ // Same keying as production: Google records are keyed by the purchaseToken
306
+ // (the receipt itself) so mock webhooks can find them by token.
307
+ originalTransactionId: receipt,
271
308
  purchasedAt: new Date(now).toISOString(),
272
- willRenew: true
309
+ willRenew: true,
310
+ ...tokenMatch ? { boundAccountId: tokenMatch[1] } : {}
273
311
  };
274
312
  }
275
313
  function mockValidateGoogleProduct(receipt, productId) {
@@ -284,7 +322,6 @@ function mockValidateGoogleProduct(receipt, productId) {
284
322
  }
285
323
 
286
324
  // src/providers/apple.ts
287
- var MAX_CONSUMABLE_RECEIPT_AGE_MS = 72 * 60 * 60 * 1e3;
288
325
  function derBase64ToPem(der) {
289
326
  return "-----BEGIN CERTIFICATE-----\n" + (der.match(/.{1,64}/g) ?? []).join("\n") + "\n-----END CERTIFICATE-----";
290
327
  }
@@ -387,8 +424,9 @@ async function validateAppleReceipt(receipt, config) {
387
424
  expiresAt: new Date(tx.expiresDate).toISOString(),
388
425
  originalTransactionId: tx.originalTransactionId,
389
426
  purchasedAt: new Date(purchasedAt).toISOString(),
390
- willRenew: status === SUBSCRIPTION_STATUS.ACTIVE
427
+ willRenew: status === SUBSCRIPTION_STATUS.ACTIVE,
391
428
  // refined by renewal info in webhook
429
+ ...tx.appAccountToken ? { boundAccountId: tx.appAccountToken } : {}
392
430
  };
393
431
  }
394
432
  async function validateAppleConsumableReceipt(signedTransaction, config, expectedProductId) {
@@ -431,8 +469,9 @@ async function validateAppleConsumableReceipt(signedTransaction, config, expecte
431
469
  log.warn("[onesub/apple] Purchase was revoked/refunded");
432
470
  return null;
433
471
  }
434
- if (tx.purchaseDate && Date.now() - tx.purchaseDate > MAX_CONSUMABLE_RECEIPT_AGE_MS) {
435
- log.warn("[onesub/apple] Consumable receipt too old (>72h)");
472
+ const maxAgeMs = (config.productReceiptMaxAgeHours ?? 72) * 60 * 60 * 1e3;
473
+ if (tx.purchaseDate && Date.now() - tx.purchaseDate > maxAgeMs) {
474
+ log.warn(`[onesub/apple] Consumable receipt too old (>${config.productReceiptMaxAgeHours ?? 72}h)`);
436
475
  return null;
437
476
  }
438
477
  const transactionId = tx.transactionId ?? tx.originalTransactionId;
@@ -721,7 +760,6 @@ var GOOGLE_NOTIFICATION_TYPE = {
721
760
  SUBSCRIPTION_REVOKED: 12,
722
761
  SUBSCRIPTION_EXPIRED: 13
723
762
  };
724
- var MAX_PRODUCT_RECEIPT_AGE_MS = 72 * 60 * 60 * 1e3;
725
763
  var refreshPromises = /* @__PURE__ */ new Map();
726
764
  function googleTokenCacheKey(serviceAccountKey) {
727
765
  let hash = 0;
@@ -948,6 +986,7 @@ async function validateGoogleReceipt(receipt, productId, config) {
948
986
  const purchasedAt = purchase.startTime ?? (/* @__PURE__ */ new Date()).toISOString();
949
987
  const willRenew = lineItem.autoRenewingPlan?.autoRenewEnabled ?? false;
950
988
  const autoResumeTime = status === SUBSCRIPTION_STATUS.PAUSED ? purchase.pausedStateContext?.autoResumeTime : void 0;
989
+ const boundAccountId = purchase.externalAccountIdentifiers?.obfuscatedExternalAccountId;
951
990
  return {
952
991
  userId: "",
953
992
  // caller fills this in
@@ -955,13 +994,18 @@ async function validateGoogleReceipt(receipt, productId, config) {
955
994
  platform: "google",
956
995
  status,
957
996
  expiresAt,
958
- originalTransactionId: purchase.latestOrderId ?? receipt.slice(0, 64),
997
+ // Keyed by the purchaseToken, NOT latestOrderId: RTDN webhooks and
998
+ // linkedPurchaseToken chains only carry the token, so records keyed by
999
+ // order id are invisible to the entire webhook lifecycle (refund/cancel/
1000
+ // renewal would never find them).
1001
+ originalTransactionId: receipt,
959
1002
  purchasedAt,
960
1003
  willRenew,
961
1004
  // Surface the linked token so host apps (and our webhook userId-continuity
962
1005
  // logic) can follow upgrade/downgrade chains.
963
1006
  linkedPurchaseToken: purchase.linkedPurchaseToken,
964
- autoResumeTime
1007
+ autoResumeTime,
1008
+ ...boundAccountId ? { boundAccountId } : {}
965
1009
  };
966
1010
  }
967
1011
  async function validateGoogleProductReceipt(purchaseToken, productId, config, type = "non_consumable") {
@@ -992,8 +1036,9 @@ async function validateGoogleProductReceipt(purchaseToken, productId, config, ty
992
1036
  }
993
1037
  if (purchase.purchaseTimeMillis) {
994
1038
  const purchaseTime = parseInt(purchase.purchaseTimeMillis, 10);
995
- if (Date.now() - purchaseTime > MAX_PRODUCT_RECEIPT_AGE_MS) {
996
- log.warn("[onesub/google] Product receipt too old (>72h)");
1039
+ const maxAgeMs = (config.productReceiptMaxAgeHours ?? 72) * 60 * 60 * 1e3;
1040
+ if (Date.now() - purchaseTime > maxAgeMs) {
1041
+ log.warn(`[onesub/google] Product receipt too old (>${config.productReceiptMaxAgeHours ?? 72}h)`);
997
1042
  return null;
998
1043
  }
999
1044
  }
@@ -1137,6 +1182,22 @@ function createValidateRouter(config, store) {
1137
1182
  sendError(res, 422, ONESUB_ERROR_CODE.RECEIPT_VALIDATION_FAILED, "Receipt validation failed", NO_SUB);
1138
1183
  return;
1139
1184
  }
1185
+ const boundAccountId = sub.boundAccountId;
1186
+ delete sub.boundAccountId;
1187
+ const bindingMismatch = platform === "apple" ? boundAccountId && boundAccountId.toLowerCase() !== userId.toLowerCase() : boundAccountId && boundAccountId !== userId;
1188
+ if (bindingMismatch) {
1189
+ log.warn(
1190
+ `[onesub/validate] account binding mismatch for ${sub.originalTransactionId}: receipt token does not match userId ${userId}`
1191
+ );
1192
+ sendError(
1193
+ res,
1194
+ 409,
1195
+ ONESUB_ERROR_CODE.TRANSACTION_BELONGS_TO_OTHER_USER,
1196
+ "TRANSACTION_BELONGS_TO_OTHER_USER",
1197
+ NO_SUB
1198
+ );
1199
+ return;
1200
+ }
1140
1201
  sub.userId = userId;
1141
1202
  await store.save(sub);
1142
1203
  if (platform === "google" && config.google) {
@@ -1184,7 +1245,7 @@ function createStatusRouter(store) {
1184
1245
  return router;
1185
1246
  }
1186
1247
  var APPLE_ACTIVE_TYPES = /* @__PURE__ */ new Set(["SUBSCRIBED", "DID_RENEW", "DID_RECOVER", "OFFER_REDEEMED"]);
1187
- var APPLE_CANCELED_TYPES = /* @__PURE__ */ new Set(["REVOKE", "REFUND", "CONSUMPTION_REQUEST"]);
1248
+ var APPLE_CANCELED_TYPES = /* @__PURE__ */ new Set(["REVOKE", "REFUND"]);
1188
1249
  var APPLE_EXPIRED_TYPES = /* @__PURE__ */ new Set(["EXPIRED"]);
1189
1250
  function mapAppleNotificationStatus(notificationType, subtype) {
1190
1251
  if (notificationType === "DID_FAIL_TO_RENEW") {
@@ -1241,6 +1302,11 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1241
1302
  } = decoded;
1242
1303
  const notificationType = payload.notificationType;
1243
1304
  const subtype = payload.subtype;
1305
+ if (config.apple?.bundleId && bundleId && bundleId !== config.apple.bundleId) {
1306
+ log.warn("[onesub/webhook/apple] Bundle ID mismatch:", bundleId, "!==", config.apple.bundleId);
1307
+ sendError(res, 400, ONESUB_ERROR_CODE.BUNDLE_ID_MISMATCH, "Bundle ID mismatch");
1308
+ return;
1309
+ }
1244
1310
  const mapped = mapAppleNotificationStatus(notificationType, subtype);
1245
1311
  const finalStatus = mapped ?? status;
1246
1312
  if (notificationType === "CONSUMPTION_REQUEST" && config.apple?.consumptionInfoProvider && transactionId && productId) {
@@ -1267,6 +1333,10 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1267
1333
  }
1268
1334
  const isOneTimePurchase = type === "Consumable" || type === "Non-Consumable";
1269
1335
  const isRefundOrRevoke = APPLE_CANCELED_TYPES.has(notificationType);
1336
+ if (isOneTimePurchase && notificationType === "CONSUMPTION_REQUEST") {
1337
+ res.status(200).json({ received: true });
1338
+ return;
1339
+ }
1270
1340
  try {
1271
1341
  if (isOneTimePurchase && isRefundOrRevoke) {
1272
1342
  const lookupId = transactionId ?? originalTransactionId;
@@ -1279,7 +1349,7 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1279
1349
  }
1280
1350
  const existing = await store.getByTransactionId(originalTransactionId);
1281
1351
  if (existing) {
1282
- const isSubscriptionRefund = isRefundOrRevoke && !isOneTimePurchase && notificationType !== "CONSUMPTION_REQUEST";
1352
+ const isSubscriptionRefund = isRefundOrRevoke && !isOneTimePurchase;
1283
1353
  const keepEntitlement = isSubscriptionRefund && config.refundPolicy === "until_expiry";
1284
1354
  const correctedUserId = appAccountToken && existing.userId === originalTransactionId ? appAccountToken : existing.userId;
1285
1355
  if (correctedUserId !== existing.userId) {
@@ -1319,6 +1389,12 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1319
1389
  res.status(200).json({ received: true });
1320
1390
  } catch (err) {
1321
1391
  log.error("[onesub/webhook/apple] Store update error:", err);
1392
+ if (webhookEventStore?.unmark && typeof payload.notificationUUID === "string") {
1393
+ try {
1394
+ await webhookEventStore.unmark("apple", payload.notificationUUID);
1395
+ } catch {
1396
+ }
1397
+ }
1322
1398
  sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to update subscription");
1323
1399
  }
1324
1400
  }
@@ -1328,13 +1404,23 @@ function getGoogleJwks() {
1328
1404
  if (!googleJwks) googleJwks = createRemoteJWKSet(new URL(GOOGLE_JWKS_URL));
1329
1405
  return googleJwks;
1330
1406
  }
1331
- async function verifyGooglePushToken(req, expectedAudience) {
1407
+ async function verifyGooglePushToken(req, expectedAudience, expectedServiceAccountEmail) {
1332
1408
  const authHeader = req.headers["authorization"];
1333
1409
  if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) return false;
1334
1410
  try {
1335
- await jwtVerify(authHeader.slice("Bearer ".length).trim(), getGoogleJwks(), {
1336
- audience: expectedAudience
1411
+ const { payload } = await jwtVerify(authHeader.slice("Bearer ".length).trim(), getGoogleJwks(), {
1412
+ audience: expectedAudience,
1413
+ // Any Google Cloud principal can mint an OIDC token with an arbitrary
1414
+ // audience — issuer (and, when configured, the push service-account
1415
+ // email) narrows acceptance to real Pub/Sub push auth tokens. Google
1416
+ // documents both issuer forms, so accept either.
1417
+ issuer: ["https://accounts.google.com", "accounts.google.com"]
1337
1418
  });
1419
+ if (expectedServiceAccountEmail) {
1420
+ if (payload["email"] !== expectedServiceAccountEmail || payload["email_verified"] !== true) {
1421
+ return false;
1422
+ }
1423
+ }
1338
1424
  return true;
1339
1425
  } catch {
1340
1426
  return false;
@@ -1342,7 +1428,11 @@ async function verifyGooglePushToken(req, expectedAudience) {
1342
1428
  }
1343
1429
  async function handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore) {
1344
1430
  if (config.google?.pushAudience) {
1345
- const authenticated = await verifyGooglePushToken(req, config.google.pushAudience);
1431
+ const authenticated = await verifyGooglePushToken(
1432
+ req,
1433
+ config.google.pushAudience,
1434
+ config.google.pushServiceAccountEmail
1435
+ );
1346
1436
  if (!authenticated) {
1347
1437
  sendError(res, 401, ONESUB_ERROR_CODE.UNAUTHORIZED, "Unauthorized");
1348
1438
  return;
@@ -1353,6 +1443,14 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1353
1443
  sendError(res, 400, ONESUB_ERROR_CODE.MISSING_MESSAGE_DATA, "Missing message.data");
1354
1444
  return;
1355
1445
  }
1446
+ const unmarkEvent = async () => {
1447
+ if (webhookEventStore?.unmark && typeof body.message?.messageId === "string") {
1448
+ try {
1449
+ await webhookEventStore.unmark("google", body.message.messageId);
1450
+ } catch {
1451
+ }
1452
+ }
1453
+ };
1356
1454
  if (webhookEventStore && typeof body.message.messageId === "string") {
1357
1455
  const fresh = await webhookEventStore.markIfNew("google", body.message.messageId);
1358
1456
  if (!fresh) {
@@ -1386,6 +1484,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1386
1484
  res.status(200).json({ received: true });
1387
1485
  } catch (err) {
1388
1486
  log.error("[onesub/webhook/google] voided notification error:", err);
1487
+ await unmarkEvent();
1389
1488
  sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process voided notification");
1390
1489
  }
1391
1490
  return;
@@ -1412,6 +1511,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1412
1511
  res.status(200).json({ received: true });
1413
1512
  } catch (err) {
1414
1513
  log.error("[onesub/webhook/google] oneTimeProduct error:", err);
1514
+ await unmarkEvent();
1415
1515
  sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process oneTimeProduct notification");
1416
1516
  }
1417
1517
  return;
@@ -1445,7 +1545,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1445
1545
  } else if (isGoogleExpiredNotification(notificationType)) {
1446
1546
  finalStatus = SUBSCRIPTION_STATUS.EXPIRED;
1447
1547
  } else {
1448
- finalStatus = SUBSCRIPTION_STATUS.ACTIVE;
1548
+ finalStatus = existing?.status ?? SUBSCRIPTION_STATUS.ACTIVE;
1449
1549
  }
1450
1550
  if (isGooglePriceChangeConfirmedNotification(notificationType) && config.google?.onPriceChangeConfirmed) {
1451
1551
  const hook = config.google.onPriceChangeConfirmed;
@@ -1456,7 +1556,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1456
1556
  if (config.google?.serviceAccountKey) {
1457
1557
  const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1458
1558
  if (fresh) {
1459
- const preserveNotificationStatus = finalStatus === SUBSCRIPTION_STATUS.GRACE_PERIOD || finalStatus === SUBSCRIPTION_STATUS.ON_HOLD;
1559
+ const preserveNotificationStatus = isGoogleGracePeriodNotification(notificationType) || isGoogleOnHoldNotification(notificationType);
1460
1560
  updated = {
1461
1561
  ...existing,
1462
1562
  status: preserveNotificationStatus ? finalStatus : fresh.status,
@@ -1472,14 +1572,16 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1472
1572
  if (config.google?.serviceAccountKey) {
1473
1573
  const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1474
1574
  if (fresh) {
1575
+ const boundAccountId = fresh.boundAccountId;
1576
+ delete fresh.boundAccountId;
1475
1577
  if (fresh.linkedPurchaseToken) {
1476
1578
  const previous = await store.getByTransactionId(fresh.linkedPurchaseToken);
1477
- fresh.userId = previous ? previous.userId : purchaseToken;
1579
+ fresh.userId = previous ? previous.userId : boundAccountId ?? purchaseToken;
1478
1580
  if (previous) {
1479
1581
  log.info(`[onesub/webhook/google] inherited userId ${previous.userId} from linkedPurchaseToken ${fresh.linkedPurchaseToken} \u2192 new token ${purchaseToken}`);
1480
1582
  }
1481
1583
  } else {
1482
- fresh.userId = purchaseToken;
1584
+ fresh.userId = boundAccountId ?? purchaseToken;
1483
1585
  }
1484
1586
  await store.save(fresh);
1485
1587
  }
@@ -1490,6 +1592,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1490
1592
  res.status(200).json({ received: true });
1491
1593
  } catch (err) {
1492
1594
  log.error("[onesub/webhook/google] Error handling notification:", err);
1595
+ await unmarkEvent();
1493
1596
  sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process notification");
1494
1597
  }
1495
1598
  }
@@ -1583,7 +1686,8 @@ function createPurchaseRouter(config, purchaseStore) {
1583
1686
  sendError(res, 422, ONESUB_ERROR_CODE.RECEIPT_VALIDATION_FAILED, "Receipt validation failed", NO_PURCHASE);
1584
1687
  return;
1585
1688
  }
1586
- if (boundAccountId && boundAccountId.toLowerCase() !== userId.toLowerCase()) {
1689
+ const bindingMismatch = platform === "apple" ? boundAccountId && boundAccountId.toLowerCase() !== userId.toLowerCase() : boundAccountId && boundAccountId !== userId;
1690
+ if (bindingMismatch) {
1587
1691
  log.warn(
1588
1692
  `[onesub/purchase] account binding mismatch for transaction ${transactionId}: token does not match userId ${userId}`
1589
1693
  );
@@ -1817,9 +1921,12 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
1817
1921
  sendError(res, 404, ONESUB_ERROR_CODE.TRANSACTION_NOT_FOUND, "TRANSACTION_NOT_FOUND");
1818
1922
  return;
1819
1923
  }
1820
- await purchaseStore.deletePurchases(existing.userId, existing.productId);
1924
+ const moved = await purchaseStore.reassignPurchase(body.transactionId, body.newUserId);
1925
+ if (!moved) {
1926
+ sendError(res, 404, ONESUB_ERROR_CODE.TRANSACTION_NOT_FOUND, "TRANSACTION_NOT_FOUND");
1927
+ return;
1928
+ }
1821
1929
  const migrated = { ...existing, userId: body.newUserId };
1822
- await purchaseStore.savePurchase(migrated);
1823
1930
  res.json({ ok: true, purchase: migrated });
1824
1931
  });
1825
1932
  const grantSchema = z.object({
@@ -2351,7 +2458,11 @@ var PostgresSubscriptionStore = class {
2351
2458
  );
2352
2459
  });
2353
2460
  const Pool = pg.default?.Pool ?? pg.Pool;
2354
- return new Pool({ connectionString: this.connectionString, max: 10 });
2461
+ const pool = new Pool({ connectionString: this.connectionString, max: 10 });
2462
+ pool.on("error", (err) => {
2463
+ log.error("[onesub] PostgresSubscriptionStore pool error (idle client):", err);
2464
+ });
2465
+ return pool;
2355
2466
  })();
2356
2467
  }
2357
2468
  return this.poolPromise;
@@ -2523,7 +2634,11 @@ var PostgresPurchaseStore = class {
2523
2634
  );
2524
2635
  });
2525
2636
  const Pool = pg.default?.Pool ?? pg.Pool;
2526
- return new Pool({ connectionString: this.connectionString, max: 10 });
2637
+ const pool = new Pool({ connectionString: this.connectionString, max: 10 });
2638
+ pool.on("error", (err) => {
2639
+ log.error("[onesub] PostgresPurchaseStore pool error (idle client):", err);
2640
+ });
2641
+ return pool;
2527
2642
  })();
2528
2643
  }
2529
2644
  return this.poolPromise;
@@ -2538,23 +2653,12 @@ var PostgresPurchaseStore = class {
2538
2653
  }
2539
2654
  async savePurchase(purchase) {
2540
2655
  const pool = await this.getPool();
2541
- const existing = await pool.query(
2542
- `SELECT user_id FROM onesub_purchases WHERE transaction_id = $1`,
2543
- [purchase.transactionId]
2544
- );
2545
- if (existing.rows.length > 0) {
2546
- const owner = existing.rows[0].user_id;
2547
- if (owner !== purchase.userId) {
2548
- const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2549
- err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2550
- throw err;
2551
- }
2552
- return;
2553
- }
2554
- await pool.query(
2656
+ const inserted = await pool.query(
2555
2657
  `INSERT INTO onesub_purchases
2556
2658
  (transaction_id, user_id, product_id, platform, type, quantity, purchased_at)
2557
- VALUES ($1, $2, $3, $4, $5, $6, $7)`,
2659
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
2660
+ ON CONFLICT (transaction_id) DO NOTHING
2661
+ RETURNING transaction_id`,
2558
2662
  [
2559
2663
  purchase.transactionId,
2560
2664
  purchase.userId,
@@ -2565,6 +2669,17 @@ var PostgresPurchaseStore = class {
2565
2669
  purchase.purchasedAt
2566
2670
  ]
2567
2671
  );
2672
+ if (inserted.rows.length > 0) return;
2673
+ const existing = await pool.query(
2674
+ `SELECT user_id FROM onesub_purchases WHERE transaction_id = $1`,
2675
+ [purchase.transactionId]
2676
+ );
2677
+ const owner = existing.rows[0]?.user_id;
2678
+ if (owner !== void 0 && owner !== purchase.userId) {
2679
+ const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2680
+ err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2681
+ throw err;
2682
+ }
2568
2683
  }
2569
2684
  async getPurchasesByUserId(userId) {
2570
2685
  const pool = await this.getPool();
@@ -2665,7 +2780,6 @@ function rowToPurchaseInfo(row) {
2665
2780
  // src/stores/redis.ts
2666
2781
  var SUB_TX_PREFIX = "onesub:sub:tx:";
2667
2782
  var SUB_USER_PREFIX = "onesub:sub:user:";
2668
- var SUB_ALL = "onesub:sub:all";
2669
2783
  var SUB_ALL_SORTED = "onesub:sub:all:sorted";
2670
2784
  var PUR_TX_PREFIX = "onesub:purchase:tx:";
2671
2785
  var PUR_USER_PREFIX = "onesub:purchase:user:";
@@ -2680,10 +2794,14 @@ var RedisSubscriptionStore = class {
2680
2794
  const score = Date.now();
2681
2795
  const txKey = SUB_TX_PREFIX + sub.originalTransactionId;
2682
2796
  const userKey = SUB_USER_PREFIX + sub.userId;
2797
+ const prevRaw = await this.redis.get(txKey);
2798
+ const prevUserId = prevRaw ? JSON.parse(prevRaw).userId : null;
2683
2799
  const pipeline = this.redis.multi();
2684
2800
  pipeline.set(txKey, JSON.stringify(sub));
2801
+ if (prevUserId !== null && prevUserId !== sub.userId) {
2802
+ pipeline.zrem(SUB_USER_PREFIX + prevUserId, sub.originalTransactionId);
2803
+ }
2685
2804
  pipeline.zadd(userKey, score, sub.originalTransactionId);
2686
- pipeline.sadd(SUB_ALL, sub.originalTransactionId);
2687
2805
  pipeline.zadd(SUB_ALL_SORTED, score, sub.originalTransactionId);
2688
2806
  await pipeline.exec();
2689
2807
  }
@@ -2755,19 +2873,18 @@ var RedisPurchaseStore = class {
2755
2873
  redis;
2756
2874
  async savePurchase(purchase) {
2757
2875
  const txKey = PUR_TX_PREFIX + purchase.transactionId;
2758
- const existing = await this.redis.get(txKey);
2759
- if (existing) {
2760
- const owner = JSON.parse(existing).userId;
2761
- if (owner !== purchase.userId) {
2876
+ const claimed = await this.redis.set(txKey, JSON.stringify(purchase), "NX");
2877
+ if (claimed !== "OK") {
2878
+ const existing = await this.redis.get(txKey);
2879
+ const owner = existing ? JSON.parse(existing).userId : null;
2880
+ if (owner !== null && owner !== purchase.userId) {
2762
2881
  const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2763
2882
  err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2764
2883
  throw err;
2765
2884
  }
2766
- return;
2767
2885
  }
2768
2886
  const score = Date.parse(purchase.purchasedAt) || Date.now();
2769
2887
  const pipeline = this.redis.multi();
2770
- pipeline.set(txKey, JSON.stringify(purchase));
2771
2888
  pipeline.zadd(PUR_USER_PREFIX + purchase.userId, score, purchase.transactionId);
2772
2889
  pipeline.sadd(PUR_USER_PRODUCT_PREFIX + purchase.userId + ":" + purchase.productId, purchase.transactionId);
2773
2890
  pipeline.sadd(PUR_ALL, purchase.transactionId);
@@ -2878,6 +2995,9 @@ var RedisWebhookEventStore = class {
2878
2995
  const result = await this.redis.set(key, "1", "EX", this.ttlSeconds, "NX");
2879
2996
  return result === "OK";
2880
2997
  }
2998
+ async unmark(provider, eventId) {
2999
+ await this.redis.del(WEBHOOK_EVENT_PREFIX + provider + ":" + eventId);
3000
+ }
2881
3001
  };
2882
3002
 
2883
3003
  // src/webhook-events.ts
@@ -2895,6 +3015,9 @@ var InMemoryWebhookEventStore = class {
2895
3015
  this.seen.set(key, Date.now() + this.ttlSeconds * 1e3);
2896
3016
  return true;
2897
3017
  }
3018
+ async unmark(provider, eventId) {
3019
+ this.seen.delete(`${provider}:${eventId}`);
3020
+ }
2898
3021
  evictExpired() {
2899
3022
  const now = Date.now();
2900
3023
  for (const [key, expiresAt] of this.seen) {
@@ -2916,6 +3039,9 @@ var CacheWebhookEventStore = class {
2916
3039
  await this.cache.set(key, "1", this.ttlSeconds);
2917
3040
  return true;
2918
3041
  }
3042
+ async unmark(provider, eventId) {
3043
+ await this.cache.del(`webhook:event:${provider}:${eventId}`);
3044
+ }
2919
3045
  };
2920
3046
 
2921
3047
  // src/webhook-queue.ts
@@ -3193,8 +3319,6 @@ function openapiHandler() {
3193
3319
  res.json(ONESUB_OPENAPI);
3194
3320
  };
3195
3321
  }
3196
-
3197
- // src/tracing.ts
3198
3322
  var NOOP_SPAN = {
3199
3323
  setAttribute: () => {
3200
3324
  },
@@ -3211,8 +3335,7 @@ function getTracer() {
3211
3335
  if (resolved) return cachedTracer;
3212
3336
  resolved = true;
3213
3337
  try {
3214
- const req = globalThis.require;
3215
- if (!req) return null;
3338
+ const req = typeof __require === "function" ? __require : createRequire(import.meta.url);
3216
3339
  const otel = req("@opentelemetry/api");
3217
3340
  cachedTracer = otel.trace.getTracer("@onesub/server", "1.0.0");
3218
3341
  return cachedTracer;