@onesub/server 0.13.1 → 0.15.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 (141) hide show
  1. package/dist/__tests__/google-subscriptions-v2.test.d.ts +1 -1
  2. package/dist/__tests__/tracing.test.d.ts +2 -0
  3. package/dist/__tests__/tracing.test.d.ts.map +1 -0
  4. package/dist/cache.d.ts +5 -0
  5. package/dist/cache.d.ts.map +1 -1
  6. package/dist/index.cjs +197 -52
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.js +197 -52
  9. package/dist/index.js.map +1 -1
  10. package/dist/providers/apple.d.ts +8 -0
  11. package/dist/providers/apple.d.ts.map +1 -1
  12. package/dist/providers/google.d.ts +8 -0
  13. package/dist/providers/google.d.ts.map +1 -1
  14. package/dist/providers/mock.d.ts +2 -0
  15. package/dist/providers/mock.d.ts.map +1 -1
  16. package/dist/routes/admin.d.ts.map +1 -1
  17. package/dist/routes/purchase.d.ts.map +1 -1
  18. package/dist/routes/validate.d.ts.map +1 -1
  19. package/dist/routes/webhook-apple.d.ts.map +1 -1
  20. package/dist/routes/webhook-google.d.ts +1 -1
  21. package/dist/routes/webhook-google.d.ts.map +1 -1
  22. package/dist/store.d.ts.map +1 -1
  23. package/dist/stores/postgres.d.ts.map +1 -1
  24. package/dist/stores/redis.d.ts +1 -0
  25. package/dist/stores/redis.d.ts.map +1 -1
  26. package/dist/tracing.d.ts.map +1 -1
  27. package/dist/webhook-events.d.ts +10 -0
  28. package/dist/webhook-events.d.ts.map +1 -1
  29. package/package.json +2 -2
  30. package/dist/__tests__/admin-customer-detail.test.js +0 -146
  31. package/dist/__tests__/admin-customer-detail.test.js.map +0 -1
  32. package/dist/__tests__/admin-list-subscriptions.test.js +0 -192
  33. package/dist/__tests__/admin-list-subscriptions.test.js.map +0 -1
  34. package/dist/__tests__/admin-subscription-detail.test.js +0 -112
  35. package/dist/__tests__/admin-subscription-detail.test.js.map +0 -1
  36. package/dist/__tests__/apple-root-ca.test.js +0 -41
  37. package/dist/__tests__/apple-root-ca.test.js.map +0 -1
  38. package/dist/__tests__/apple-status-api.test.js +0 -345
  39. package/dist/__tests__/apple-status-api.test.js.map +0 -1
  40. package/dist/__tests__/cache.test.js +0 -34
  41. package/dist/__tests__/cache.test.js.map +0 -1
  42. package/dist/__tests__/entitlements.test.js +0 -268
  43. package/dist/__tests__/entitlements.test.js.map +0 -1
  44. package/dist/__tests__/error-codes.test.js +0 -173
  45. package/dist/__tests__/error-codes.test.js.map +0 -1
  46. package/dist/__tests__/fetch-hardening.test.js +0 -192
  47. package/dist/__tests__/fetch-hardening.test.js.map +0 -1
  48. package/dist/__tests__/google-paused.test.js +0 -126
  49. package/dist/__tests__/google-paused.test.js.map +0 -1
  50. package/dist/__tests__/google-price-change.test.js +0 -186
  51. package/dist/__tests__/google-price-change.test.js.map +0 -1
  52. package/dist/__tests__/google-subscriptions-v2.test.js +0 -206
  53. package/dist/__tests__/google-subscriptions-v2.test.js.map +0 -1
  54. package/dist/__tests__/lifecycle-scenarios.test.js +0 -579
  55. package/dist/__tests__/lifecycle-scenarios.test.js.map +0 -1
  56. package/dist/__tests__/lifecycle-states.test.js +0 -423
  57. package/dist/__tests__/lifecycle-states.test.js.map +0 -1
  58. package/dist/__tests__/linked-purchase-token.test.js +0 -227
  59. package/dist/__tests__/linked-purchase-token.test.js.map +0 -1
  60. package/dist/__tests__/metrics.test.js +0 -340
  61. package/dist/__tests__/metrics.test.js.map +0 -1
  62. package/dist/__tests__/mock-provider.test.js +0 -188
  63. package/dist/__tests__/mock-provider.test.js.map +0 -1
  64. package/dist/__tests__/openapi.test.js +0 -32
  65. package/dist/__tests__/openapi.test.js.map +0 -1
  66. package/dist/__tests__/paused-auto-resume.test.js +0 -162
  67. package/dist/__tests__/paused-auto-resume.test.js.map +0 -1
  68. package/dist/__tests__/providers.test.js +0 -321
  69. package/dist/__tests__/providers.test.js.map +0 -1
  70. package/dist/__tests__/redis-store.test.js +0 -166
  71. package/dist/__tests__/redis-store.test.js.map +0 -1
  72. package/dist/__tests__/refund-policy.test.js +0 -317
  73. package/dist/__tests__/refund-policy.test.js.map +0 -1
  74. package/dist/__tests__/schema.test.js +0 -46
  75. package/dist/__tests__/schema.test.js.map +0 -1
  76. package/dist/__tests__/store.test.js +0 -172
  77. package/dist/__tests__/store.test.js.map +0 -1
  78. package/dist/__tests__/test-utils.js +0 -30
  79. package/dist/__tests__/test-utils.js.map +0 -1
  80. package/dist/__tests__/webhook-apple.test.js +0 -247
  81. package/dist/__tests__/webhook-apple.test.js.map +0 -1
  82. package/dist/__tests__/webhook-events.test.js +0 -36
  83. package/dist/__tests__/webhook-events.test.js.map +0 -1
  84. package/dist/__tests__/webhook-google.test.js +0 -255
  85. package/dist/__tests__/webhook-google.test.js.map +0 -1
  86. package/dist/__tests__/webhook-queue.test.js +0 -26
  87. package/dist/__tests__/webhook-queue.test.js.map +0 -1
  88. package/dist/__tests__/webhook-refund.test.js +0 -313
  89. package/dist/__tests__/webhook-refund.test.js.map +0 -1
  90. package/dist/cache.js +0 -39
  91. package/dist/cache.js.map +0 -1
  92. package/dist/errors.js +0 -19
  93. package/dist/errors.js.map +0 -1
  94. package/dist/http.js +0 -36
  95. package/dist/http.js.map +0 -1
  96. package/dist/logger.js +0 -20
  97. package/dist/logger.js.map +0 -1
  98. package/dist/openapi.js +0 -196
  99. package/dist/openapi.js.map +0 -1
  100. package/dist/providers/apple-root-ca.js +0 -38
  101. package/dist/providers/apple-root-ca.js.map +0 -1
  102. package/dist/providers/apple.js +0 -630
  103. package/dist/providers/apple.js.map +0 -1
  104. package/dist/providers/google.js +0 -564
  105. package/dist/providers/google.js.map +0 -1
  106. package/dist/providers/mock.js +0 -93
  107. package/dist/providers/mock.js.map +0 -1
  108. package/dist/routes/admin.js +0 -327
  109. package/dist/routes/admin.js.map +0 -1
  110. package/dist/routes/apple-offer.js +0 -72
  111. package/dist/routes/apple-offer.js.map +0 -1
  112. package/dist/routes/entitlements.js +0 -130
  113. package/dist/routes/entitlements.js.map +0 -1
  114. package/dist/routes/metrics.js +0 -281
  115. package/dist/routes/metrics.js.map +0 -1
  116. package/dist/routes/purchase.js +0 -204
  117. package/dist/routes/purchase.js.map +0 -1
  118. package/dist/routes/status.js +0 -55
  119. package/dist/routes/status.js.map +0 -1
  120. package/dist/routes/validate.js +0 -70
  121. package/dist/routes/validate.js.map +0 -1
  122. package/dist/routes/webhook-apple.js +0 -145
  123. package/dist/routes/webhook-apple.js.map +0 -1
  124. package/dist/routes/webhook-google.js +0 -204
  125. package/dist/routes/webhook-google.js.map +0 -1
  126. package/dist/routes/webhook.js +0 -19
  127. package/dist/routes/webhook.js.map +0 -1
  128. package/dist/store.js +0 -145
  129. package/dist/store.js.map +0 -1
  130. package/dist/stores/postgres.js +0 -329
  131. package/dist/stores/postgres.js.map +0 -1
  132. package/dist/stores/redis.js +0 -297
  133. package/dist/stores/redis.js.map +0 -1
  134. package/dist/stores/schema.js +0 -67
  135. package/dist/stores/schema.js.map +0 -1
  136. package/dist/tracing.js +0 -74
  137. package/dist/tracing.js.map +0 -1
  138. package/dist/webhook-events.js +0 -58
  139. package/dist/webhook-events.js.map +0 -1
  140. package/dist/webhook-queue.js +0 -108
  141. 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,17 +274,20 @@ 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) {
248
282
  const outcome = classifyMockReceipt(receipt);
249
283
  if (!outcomePasses(outcome, "apple")) return null;
250
284
  const productId = expectedProductId ?? "mock_product";
285
+ const tokenMatch = receipt.match(/#token=([^#]+)/);
251
286
  return {
252
287
  transactionId: deterministicTransactionId(`mock_apple_${productId}`, receipt),
253
288
  productId,
254
- purchasedAt: (/* @__PURE__ */ new Date()).toISOString()
289
+ purchasedAt: (/* @__PURE__ */ new Date()).toISOString(),
290
+ ...tokenMatch ? { appAccountToken: tokenMatch[1] } : {}
255
291
  };
256
292
  }
257
293
  function mockValidateGoogleSubscription(receipt, productId) {
@@ -259,23 +295,29 @@ function mockValidateGoogleSubscription(receipt, productId) {
259
295
  if (!outcomePasses(outcome, "google")) return null;
260
296
  const now = Date.now();
261
297
  const expiresAt = outcome.kind === "sandbox" ? now + 1 * HOURS : now + 30 * DAYS;
298
+ const tokenMatch = receipt.match(/#token=([^#]+)/);
262
299
  return {
263
300
  userId: "",
264
301
  productId,
265
302
  platform: "google",
266
303
  status: SUBSCRIPTION_STATUS.ACTIVE,
267
304
  expiresAt: new Date(expiresAt).toISOString(),
268
- 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,
269
308
  purchasedAt: new Date(now).toISOString(),
270
- willRenew: true
309
+ willRenew: true,
310
+ ...tokenMatch ? { boundAccountId: tokenMatch[1] } : {}
271
311
  };
272
312
  }
273
313
  function mockValidateGoogleProduct(receipt, productId) {
274
314
  const outcome = classifyMockReceipt(receipt);
275
315
  if (!outcomePasses(outcome, "google")) return null;
316
+ const tokenMatch = receipt.match(/#token=([^#]+)/);
276
317
  return {
277
318
  transactionId: deterministicTransactionId(`mock_google_${productId}`, receipt),
278
- purchasedAt: (/* @__PURE__ */ new Date()).toISOString()
319
+ purchasedAt: (/* @__PURE__ */ new Date()).toISOString(),
320
+ ...tokenMatch ? { obfuscatedExternalAccountId: tokenMatch[1] } : {}
279
321
  };
280
322
  }
281
323
 
@@ -383,8 +425,9 @@ async function validateAppleReceipt(receipt, config) {
383
425
  expiresAt: new Date(tx.expiresDate).toISOString(),
384
426
  originalTransactionId: tx.originalTransactionId,
385
427
  purchasedAt: new Date(purchasedAt).toISOString(),
386
- willRenew: status === SUBSCRIPTION_STATUS.ACTIVE
428
+ willRenew: status === SUBSCRIPTION_STATUS.ACTIVE,
387
429
  // refined by renewal info in webhook
430
+ ...tx.appAccountToken ? { boundAccountId: tx.appAccountToken } : {}
388
431
  };
389
432
  }
390
433
  async function validateAppleConsumableReceipt(signedTransaction, config, expectedProductId) {
@@ -439,7 +482,8 @@ async function validateAppleConsumableReceipt(signedTransaction, config, expecte
439
482
  return {
440
483
  transactionId,
441
484
  productId: tx.productId,
442
- purchasedAt: tx.purchaseDate ? new Date(tx.purchaseDate).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
485
+ purchasedAt: tx.purchaseDate ? new Date(tx.purchaseDate).toISOString() : (/* @__PURE__ */ new Date()).toISOString(),
486
+ appAccountToken: tx.appAccountToken ?? void 0
443
487
  };
444
488
  }
445
489
  async function decodeAppleNotification(payload, skipJwsVerification = false) {
@@ -943,6 +987,7 @@ async function validateGoogleReceipt(receipt, productId, config) {
943
987
  const purchasedAt = purchase.startTime ?? (/* @__PURE__ */ new Date()).toISOString();
944
988
  const willRenew = lineItem.autoRenewingPlan?.autoRenewEnabled ?? false;
945
989
  const autoResumeTime = status === SUBSCRIPTION_STATUS.PAUSED ? purchase.pausedStateContext?.autoResumeTime : void 0;
990
+ const boundAccountId = purchase.externalAccountIdentifiers?.obfuscatedExternalAccountId;
946
991
  return {
947
992
  userId: "",
948
993
  // caller fills this in
@@ -950,13 +995,18 @@ async function validateGoogleReceipt(receipt, productId, config) {
950
995
  platform: "google",
951
996
  status,
952
997
  expiresAt,
953
- originalTransactionId: purchase.latestOrderId ?? receipt.slice(0, 64),
998
+ // Keyed by the purchaseToken, NOT latestOrderId: RTDN webhooks and
999
+ // linkedPurchaseToken chains only carry the token, so records keyed by
1000
+ // order id are invisible to the entire webhook lifecycle (refund/cancel/
1001
+ // renewal would never find them).
1002
+ originalTransactionId: receipt,
954
1003
  purchasedAt,
955
1004
  willRenew,
956
1005
  // Surface the linked token so host apps (and our webhook userId-continuity
957
1006
  // logic) can follow upgrade/downgrade chains.
958
1007
  linkedPurchaseToken: purchase.linkedPurchaseToken,
959
- autoResumeTime
1008
+ autoResumeTime,
1009
+ ...boundAccountId ? { boundAccountId } : {}
960
1010
  };
961
1011
  }
962
1012
  async function validateGoogleProductReceipt(purchaseToken, productId, config, type = "non_consumable") {
@@ -998,7 +1048,8 @@ async function validateGoogleProductReceipt(purchaseToken, productId, config, ty
998
1048
  }
999
1049
  return {
1000
1050
  transactionId: purchase.orderId,
1001
- purchasedAt: purchase.purchaseTimeMillis ? new Date(parseInt(purchase.purchaseTimeMillis, 10)).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
1051
+ purchasedAt: purchase.purchaseTimeMillis ? new Date(parseInt(purchase.purchaseTimeMillis, 10)).toISOString() : (/* @__PURE__ */ new Date()).toISOString(),
1052
+ obfuscatedExternalAccountId: purchase.obfuscatedExternalAccountId
1002
1053
  };
1003
1054
  }
1004
1055
  function decodeGoogleNotification(payload) {
@@ -1131,6 +1182,22 @@ function createValidateRouter(config, store) {
1131
1182
  sendError(res, 422, ONESUB_ERROR_CODE.RECEIPT_VALIDATION_FAILED, "Receipt validation failed", NO_SUB);
1132
1183
  return;
1133
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
+ }
1134
1201
  sub.userId = userId;
1135
1202
  await store.save(sub);
1136
1203
  if (platform === "google" && config.google) {
@@ -1178,7 +1245,7 @@ function createStatusRouter(store) {
1178
1245
  return router;
1179
1246
  }
1180
1247
  var APPLE_ACTIVE_TYPES = /* @__PURE__ */ new Set(["SUBSCRIBED", "DID_RENEW", "DID_RECOVER", "OFFER_REDEEMED"]);
1181
- var APPLE_CANCELED_TYPES = /* @__PURE__ */ new Set(["REVOKE", "REFUND", "CONSUMPTION_REQUEST"]);
1248
+ var APPLE_CANCELED_TYPES = /* @__PURE__ */ new Set(["REVOKE", "REFUND"]);
1182
1249
  var APPLE_EXPIRED_TYPES = /* @__PURE__ */ new Set(["EXPIRED"]);
1183
1250
  function mapAppleNotificationStatus(notificationType, subtype) {
1184
1251
  if (notificationType === "DID_FAIL_TO_RENEW") {
@@ -1235,6 +1302,11 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1235
1302
  } = decoded;
1236
1303
  const notificationType = payload.notificationType;
1237
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
+ }
1238
1310
  const mapped = mapAppleNotificationStatus(notificationType, subtype);
1239
1311
  const finalStatus = mapped ?? status;
1240
1312
  if (notificationType === "CONSUMPTION_REQUEST" && config.apple?.consumptionInfoProvider && transactionId && productId) {
@@ -1261,6 +1333,10 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1261
1333
  }
1262
1334
  const isOneTimePurchase = type === "Consumable" || type === "Non-Consumable";
1263
1335
  const isRefundOrRevoke = APPLE_CANCELED_TYPES.has(notificationType);
1336
+ if (isOneTimePurchase && notificationType === "CONSUMPTION_REQUEST") {
1337
+ res.status(200).json({ received: true });
1338
+ return;
1339
+ }
1264
1340
  try {
1265
1341
  if (isOneTimePurchase && isRefundOrRevoke) {
1266
1342
  const lookupId = transactionId ?? originalTransactionId;
@@ -1273,7 +1349,7 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1273
1349
  }
1274
1350
  const existing = await store.getByTransactionId(originalTransactionId);
1275
1351
  if (existing) {
1276
- const isSubscriptionRefund = isRefundOrRevoke && !isOneTimePurchase && notificationType !== "CONSUMPTION_REQUEST";
1352
+ const isSubscriptionRefund = isRefundOrRevoke && !isOneTimePurchase;
1277
1353
  const keepEntitlement = isSubscriptionRefund && config.refundPolicy === "until_expiry";
1278
1354
  const correctedUserId = appAccountToken && existing.userId === originalTransactionId ? appAccountToken : existing.userId;
1279
1355
  if (correctedUserId !== existing.userId) {
@@ -1313,6 +1389,12 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1313
1389
  res.status(200).json({ received: true });
1314
1390
  } catch (err) {
1315
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
+ }
1316
1398
  sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to update subscription");
1317
1399
  }
1318
1400
  }
@@ -1322,13 +1404,23 @@ function getGoogleJwks() {
1322
1404
  if (!googleJwks) googleJwks = createRemoteJWKSet(new URL(GOOGLE_JWKS_URL));
1323
1405
  return googleJwks;
1324
1406
  }
1325
- async function verifyGooglePushToken(req, expectedAudience) {
1407
+ async function verifyGooglePushToken(req, expectedAudience, expectedServiceAccountEmail) {
1326
1408
  const authHeader = req.headers["authorization"];
1327
1409
  if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) return false;
1328
1410
  try {
1329
- await jwtVerify(authHeader.slice("Bearer ".length).trim(), getGoogleJwks(), {
1330
- 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"]
1331
1418
  });
1419
+ if (expectedServiceAccountEmail) {
1420
+ if (payload["email"] !== expectedServiceAccountEmail || payload["email_verified"] !== true) {
1421
+ return false;
1422
+ }
1423
+ }
1332
1424
  return true;
1333
1425
  } catch {
1334
1426
  return false;
@@ -1336,7 +1428,11 @@ async function verifyGooglePushToken(req, expectedAudience) {
1336
1428
  }
1337
1429
  async function handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore) {
1338
1430
  if (config.google?.pushAudience) {
1339
- const authenticated = await verifyGooglePushToken(req, config.google.pushAudience);
1431
+ const authenticated = await verifyGooglePushToken(
1432
+ req,
1433
+ config.google.pushAudience,
1434
+ config.google.pushServiceAccountEmail
1435
+ );
1340
1436
  if (!authenticated) {
1341
1437
  sendError(res, 401, ONESUB_ERROR_CODE.UNAUTHORIZED, "Unauthorized");
1342
1438
  return;
@@ -1347,6 +1443,14 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1347
1443
  sendError(res, 400, ONESUB_ERROR_CODE.MISSING_MESSAGE_DATA, "Missing message.data");
1348
1444
  return;
1349
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
+ };
1350
1454
  if (webhookEventStore && typeof body.message.messageId === "string") {
1351
1455
  const fresh = await webhookEventStore.markIfNew("google", body.message.messageId);
1352
1456
  if (!fresh) {
@@ -1380,6 +1484,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1380
1484
  res.status(200).json({ received: true });
1381
1485
  } catch (err) {
1382
1486
  log.error("[onesub/webhook/google] voided notification error:", err);
1487
+ await unmarkEvent();
1383
1488
  sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process voided notification");
1384
1489
  }
1385
1490
  return;
@@ -1406,6 +1511,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1406
1511
  res.status(200).json({ received: true });
1407
1512
  } catch (err) {
1408
1513
  log.error("[onesub/webhook/google] oneTimeProduct error:", err);
1514
+ await unmarkEvent();
1409
1515
  sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process oneTimeProduct notification");
1410
1516
  }
1411
1517
  return;
@@ -1439,7 +1545,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1439
1545
  } else if (isGoogleExpiredNotification(notificationType)) {
1440
1546
  finalStatus = SUBSCRIPTION_STATUS.EXPIRED;
1441
1547
  } else {
1442
- finalStatus = SUBSCRIPTION_STATUS.ACTIVE;
1548
+ finalStatus = existing?.status ?? SUBSCRIPTION_STATUS.ACTIVE;
1443
1549
  }
1444
1550
  if (isGooglePriceChangeConfirmedNotification(notificationType) && config.google?.onPriceChangeConfirmed) {
1445
1551
  const hook = config.google.onPriceChangeConfirmed;
@@ -1450,7 +1556,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1450
1556
  if (config.google?.serviceAccountKey) {
1451
1557
  const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1452
1558
  if (fresh) {
1453
- const preserveNotificationStatus = finalStatus === SUBSCRIPTION_STATUS.GRACE_PERIOD || finalStatus === SUBSCRIPTION_STATUS.ON_HOLD;
1559
+ const preserveNotificationStatus = isGoogleGracePeriodNotification(notificationType) || isGoogleOnHoldNotification(notificationType);
1454
1560
  updated = {
1455
1561
  ...existing,
1456
1562
  status: preserveNotificationStatus ? finalStatus : fresh.status,
@@ -1466,14 +1572,16 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1466
1572
  if (config.google?.serviceAccountKey) {
1467
1573
  const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1468
1574
  if (fresh) {
1575
+ const boundAccountId = fresh.boundAccountId;
1576
+ delete fresh.boundAccountId;
1469
1577
  if (fresh.linkedPurchaseToken) {
1470
1578
  const previous = await store.getByTransactionId(fresh.linkedPurchaseToken);
1471
- fresh.userId = previous ? previous.userId : purchaseToken;
1579
+ fresh.userId = previous ? previous.userId : boundAccountId ?? purchaseToken;
1472
1580
  if (previous) {
1473
1581
  log.info(`[onesub/webhook/google] inherited userId ${previous.userId} from linkedPurchaseToken ${fresh.linkedPurchaseToken} \u2192 new token ${purchaseToken}`);
1474
1582
  }
1475
1583
  } else {
1476
- fresh.userId = purchaseToken;
1584
+ fresh.userId = boundAccountId ?? purchaseToken;
1477
1585
  }
1478
1586
  await store.save(fresh);
1479
1587
  }
@@ -1484,6 +1592,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1484
1592
  res.status(200).json({ received: true });
1485
1593
  } catch (err) {
1486
1594
  log.error("[onesub/webhook/google] Error handling notification:", err);
1595
+ await unmarkEvent();
1487
1596
  sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process notification");
1488
1597
  }
1489
1598
  }
@@ -1544,6 +1653,7 @@ function createPurchaseRouter(config, purchaseStore) {
1544
1653
  }
1545
1654
  let transactionId = null;
1546
1655
  let purchasedAt = (/* @__PURE__ */ new Date()).toISOString();
1656
+ let boundAccountId = null;
1547
1657
  if (platform === "apple") {
1548
1658
  if (!config.apple) {
1549
1659
  sendError(res, 500, ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "Apple configuration not provided", NO_PURCHASE);
@@ -1553,6 +1663,7 @@ function createPurchaseRouter(config, purchaseStore) {
1553
1663
  if (result) {
1554
1664
  transactionId = result.transactionId;
1555
1665
  purchasedAt = result.purchasedAt;
1666
+ boundAccountId = result.appAccountToken ?? null;
1556
1667
  }
1557
1668
  } else {
1558
1669
  if (!config.google) {
@@ -1568,12 +1679,27 @@ function createPurchaseRouter(config, purchaseStore) {
1568
1679
  if (result) {
1569
1680
  transactionId = result.transactionId;
1570
1681
  purchasedAt = result.purchasedAt;
1682
+ boundAccountId = result.obfuscatedExternalAccountId ?? null;
1571
1683
  }
1572
1684
  }
1573
1685
  if (!transactionId) {
1574
1686
  sendError(res, 422, ONESUB_ERROR_CODE.RECEIPT_VALIDATION_FAILED, "Receipt validation failed", NO_PURCHASE);
1575
1687
  return;
1576
1688
  }
1689
+ const bindingMismatch = platform === "apple" ? boundAccountId && boundAccountId.toLowerCase() !== userId.toLowerCase() : boundAccountId && boundAccountId !== userId;
1690
+ if (bindingMismatch) {
1691
+ log.warn(
1692
+ `[onesub/purchase] account binding mismatch for transaction ${transactionId}: token does not match userId ${userId}`
1693
+ );
1694
+ sendError(
1695
+ res,
1696
+ 409,
1697
+ ONESUB_ERROR_CODE.TRANSACTION_BELONGS_TO_OTHER_USER,
1698
+ "TRANSACTION_BELONGS_TO_OTHER_USER",
1699
+ NO_PURCHASE
1700
+ );
1701
+ return;
1702
+ }
1577
1703
  const existing = await purchaseStore.getPurchaseByTransactionId(transactionId);
1578
1704
  let action = "new";
1579
1705
  if (existing) {
@@ -1795,9 +1921,12 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
1795
1921
  sendError(res, 404, ONESUB_ERROR_CODE.TRANSACTION_NOT_FOUND, "TRANSACTION_NOT_FOUND");
1796
1922
  return;
1797
1923
  }
1798
- 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
+ }
1799
1929
  const migrated = { ...existing, userId: body.newUserId };
1800
- await purchaseStore.savePurchase(migrated);
1801
1930
  res.json({ ok: true, purchase: migrated });
1802
1931
  });
1803
1932
  const grantSchema = z.object({
@@ -2329,7 +2458,11 @@ var PostgresSubscriptionStore = class {
2329
2458
  );
2330
2459
  });
2331
2460
  const Pool = pg.default?.Pool ?? pg.Pool;
2332
- 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;
2333
2466
  })();
2334
2467
  }
2335
2468
  return this.poolPromise;
@@ -2501,7 +2634,11 @@ var PostgresPurchaseStore = class {
2501
2634
  );
2502
2635
  });
2503
2636
  const Pool = pg.default?.Pool ?? pg.Pool;
2504
- 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;
2505
2642
  })();
2506
2643
  }
2507
2644
  return this.poolPromise;
@@ -2516,23 +2653,12 @@ var PostgresPurchaseStore = class {
2516
2653
  }
2517
2654
  async savePurchase(purchase) {
2518
2655
  const pool = await this.getPool();
2519
- const existing = await pool.query(
2520
- `SELECT user_id FROM onesub_purchases WHERE transaction_id = $1`,
2521
- [purchase.transactionId]
2522
- );
2523
- if (existing.rows.length > 0) {
2524
- const owner = existing.rows[0].user_id;
2525
- if (owner !== purchase.userId) {
2526
- const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2527
- err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2528
- throw err;
2529
- }
2530
- return;
2531
- }
2532
- await pool.query(
2656
+ const inserted = await pool.query(
2533
2657
  `INSERT INTO onesub_purchases
2534
2658
  (transaction_id, user_id, product_id, platform, type, quantity, purchased_at)
2535
- 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`,
2536
2662
  [
2537
2663
  purchase.transactionId,
2538
2664
  purchase.userId,
@@ -2543,6 +2669,17 @@ var PostgresPurchaseStore = class {
2543
2669
  purchase.purchasedAt
2544
2670
  ]
2545
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
+ }
2546
2683
  }
2547
2684
  async getPurchasesByUserId(userId) {
2548
2685
  const pool = await this.getPool();
@@ -2643,7 +2780,6 @@ function rowToPurchaseInfo(row) {
2643
2780
  // src/stores/redis.ts
2644
2781
  var SUB_TX_PREFIX = "onesub:sub:tx:";
2645
2782
  var SUB_USER_PREFIX = "onesub:sub:user:";
2646
- var SUB_ALL = "onesub:sub:all";
2647
2783
  var SUB_ALL_SORTED = "onesub:sub:all:sorted";
2648
2784
  var PUR_TX_PREFIX = "onesub:purchase:tx:";
2649
2785
  var PUR_USER_PREFIX = "onesub:purchase:user:";
@@ -2658,10 +2794,14 @@ var RedisSubscriptionStore = class {
2658
2794
  const score = Date.now();
2659
2795
  const txKey = SUB_TX_PREFIX + sub.originalTransactionId;
2660
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;
2661
2799
  const pipeline = this.redis.multi();
2662
2800
  pipeline.set(txKey, JSON.stringify(sub));
2801
+ if (prevUserId !== null && prevUserId !== sub.userId) {
2802
+ pipeline.zrem(SUB_USER_PREFIX + prevUserId, sub.originalTransactionId);
2803
+ }
2663
2804
  pipeline.zadd(userKey, score, sub.originalTransactionId);
2664
- pipeline.sadd(SUB_ALL, sub.originalTransactionId);
2665
2805
  pipeline.zadd(SUB_ALL_SORTED, score, sub.originalTransactionId);
2666
2806
  await pipeline.exec();
2667
2807
  }
@@ -2733,19 +2873,18 @@ var RedisPurchaseStore = class {
2733
2873
  redis;
2734
2874
  async savePurchase(purchase) {
2735
2875
  const txKey = PUR_TX_PREFIX + purchase.transactionId;
2736
- const existing = await this.redis.get(txKey);
2737
- if (existing) {
2738
- const owner = JSON.parse(existing).userId;
2739
- 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) {
2740
2881
  const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2741
2882
  err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2742
2883
  throw err;
2743
2884
  }
2744
- return;
2745
2885
  }
2746
2886
  const score = Date.parse(purchase.purchasedAt) || Date.now();
2747
2887
  const pipeline = this.redis.multi();
2748
- pipeline.set(txKey, JSON.stringify(purchase));
2749
2888
  pipeline.zadd(PUR_USER_PREFIX + purchase.userId, score, purchase.transactionId);
2750
2889
  pipeline.sadd(PUR_USER_PRODUCT_PREFIX + purchase.userId + ":" + purchase.productId, purchase.transactionId);
2751
2890
  pipeline.sadd(PUR_ALL, purchase.transactionId);
@@ -2856,6 +2995,9 @@ var RedisWebhookEventStore = class {
2856
2995
  const result = await this.redis.set(key, "1", "EX", this.ttlSeconds, "NX");
2857
2996
  return result === "OK";
2858
2997
  }
2998
+ async unmark(provider, eventId) {
2999
+ await this.redis.del(WEBHOOK_EVENT_PREFIX + provider + ":" + eventId);
3000
+ }
2859
3001
  };
2860
3002
 
2861
3003
  // src/webhook-events.ts
@@ -2873,6 +3015,9 @@ var InMemoryWebhookEventStore = class {
2873
3015
  this.seen.set(key, Date.now() + this.ttlSeconds * 1e3);
2874
3016
  return true;
2875
3017
  }
3018
+ async unmark(provider, eventId) {
3019
+ this.seen.delete(`${provider}:${eventId}`);
3020
+ }
2876
3021
  evictExpired() {
2877
3022
  const now = Date.now();
2878
3023
  for (const [key, expiresAt] of this.seen) {
@@ -2894,6 +3039,9 @@ var CacheWebhookEventStore = class {
2894
3039
  await this.cache.set(key, "1", this.ttlSeconds);
2895
3040
  return true;
2896
3041
  }
3042
+ async unmark(provider, eventId) {
3043
+ await this.cache.del(`webhook:event:${provider}:${eventId}`);
3044
+ }
2897
3045
  };
2898
3046
 
2899
3047
  // src/webhook-queue.ts
@@ -3171,8 +3319,6 @@ function openapiHandler() {
3171
3319
  res.json(ONESUB_OPENAPI);
3172
3320
  };
3173
3321
  }
3174
-
3175
- // src/tracing.ts
3176
3322
  var NOOP_SPAN = {
3177
3323
  setAttribute: () => {
3178
3324
  },
@@ -3189,8 +3335,7 @@ function getTracer() {
3189
3335
  if (resolved) return cachedTracer;
3190
3336
  resolved = true;
3191
3337
  try {
3192
- const req = globalThis.require;
3193
- if (!req) return null;
3338
+ const req = typeof __require === "function" ? __require : createRequire(import.meta.url);
3194
3339
  const otel = req("@opentelemetry/api");
3195
3340
  cachedTracer = otel.trace.getTracer("@onesub/server", "1.0.0");
3196
3341
  return cachedTracer;