@onesub/server 0.14.0 → 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 (138) 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 +172 -49
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.js +172 -49
  9. package/dist/index.js.map +1 -1
  10. package/dist/providers/apple.d.ts.map +1 -1
  11. package/dist/providers/google.d.ts.map +1 -1
  12. package/dist/providers/mock.d.ts.map +1 -1
  13. package/dist/routes/admin.d.ts.map +1 -1
  14. package/dist/routes/purchase.d.ts.map +1 -1
  15. package/dist/routes/validate.d.ts.map +1 -1
  16. package/dist/routes/webhook-apple.d.ts.map +1 -1
  17. package/dist/routes/webhook-google.d.ts +1 -1
  18. package/dist/routes/webhook-google.d.ts.map +1 -1
  19. package/dist/store.d.ts.map +1 -1
  20. package/dist/stores/postgres.d.ts.map +1 -1
  21. package/dist/stores/redis.d.ts +1 -0
  22. package/dist/stores/redis.d.ts.map +1 -1
  23. package/dist/tracing.d.ts.map +1 -1
  24. package/dist/webhook-events.d.ts +10 -0
  25. package/dist/webhook-events.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/dist/__tests__/admin-customer-detail.test.js +0 -146
  28. package/dist/__tests__/admin-customer-detail.test.js.map +0 -1
  29. package/dist/__tests__/admin-list-subscriptions.test.js +0 -192
  30. package/dist/__tests__/admin-list-subscriptions.test.js.map +0 -1
  31. package/dist/__tests__/admin-subscription-detail.test.js +0 -112
  32. package/dist/__tests__/admin-subscription-detail.test.js.map +0 -1
  33. package/dist/__tests__/apple-root-ca.test.js +0 -41
  34. package/dist/__tests__/apple-root-ca.test.js.map +0 -1
  35. package/dist/__tests__/apple-status-api.test.js +0 -345
  36. package/dist/__tests__/apple-status-api.test.js.map +0 -1
  37. package/dist/__tests__/cache.test.js +0 -34
  38. package/dist/__tests__/cache.test.js.map +0 -1
  39. package/dist/__tests__/entitlements.test.js +0 -268
  40. package/dist/__tests__/entitlements.test.js.map +0 -1
  41. package/dist/__tests__/error-codes.test.js +0 -173
  42. package/dist/__tests__/error-codes.test.js.map +0 -1
  43. package/dist/__tests__/fetch-hardening.test.js +0 -192
  44. package/dist/__tests__/fetch-hardening.test.js.map +0 -1
  45. package/dist/__tests__/google-paused.test.js +0 -126
  46. package/dist/__tests__/google-paused.test.js.map +0 -1
  47. package/dist/__tests__/google-price-change.test.js +0 -186
  48. package/dist/__tests__/google-price-change.test.js.map +0 -1
  49. package/dist/__tests__/google-subscriptions-v2.test.js +0 -206
  50. package/dist/__tests__/google-subscriptions-v2.test.js.map +0 -1
  51. package/dist/__tests__/lifecycle-scenarios.test.js +0 -579
  52. package/dist/__tests__/lifecycle-scenarios.test.js.map +0 -1
  53. package/dist/__tests__/lifecycle-states.test.js +0 -423
  54. package/dist/__tests__/lifecycle-states.test.js.map +0 -1
  55. package/dist/__tests__/linked-purchase-token.test.js +0 -227
  56. package/dist/__tests__/linked-purchase-token.test.js.map +0 -1
  57. package/dist/__tests__/metrics.test.js +0 -340
  58. package/dist/__tests__/metrics.test.js.map +0 -1
  59. package/dist/__tests__/mock-provider.test.js +0 -188
  60. package/dist/__tests__/mock-provider.test.js.map +0 -1
  61. package/dist/__tests__/openapi.test.js +0 -32
  62. package/dist/__tests__/openapi.test.js.map +0 -1
  63. package/dist/__tests__/paused-auto-resume.test.js +0 -162
  64. package/dist/__tests__/paused-auto-resume.test.js.map +0 -1
  65. package/dist/__tests__/providers.test.js +0 -321
  66. package/dist/__tests__/providers.test.js.map +0 -1
  67. package/dist/__tests__/redis-store.test.js +0 -166
  68. package/dist/__tests__/redis-store.test.js.map +0 -1
  69. package/dist/__tests__/refund-policy.test.js +0 -317
  70. package/dist/__tests__/refund-policy.test.js.map +0 -1
  71. package/dist/__tests__/schema.test.js +0 -46
  72. package/dist/__tests__/schema.test.js.map +0 -1
  73. package/dist/__tests__/store.test.js +0 -172
  74. package/dist/__tests__/store.test.js.map +0 -1
  75. package/dist/__tests__/test-utils.js +0 -30
  76. package/dist/__tests__/test-utils.js.map +0 -1
  77. package/dist/__tests__/webhook-apple.test.js +0 -247
  78. package/dist/__tests__/webhook-apple.test.js.map +0 -1
  79. package/dist/__tests__/webhook-events.test.js +0 -36
  80. package/dist/__tests__/webhook-events.test.js.map +0 -1
  81. package/dist/__tests__/webhook-google.test.js +0 -255
  82. package/dist/__tests__/webhook-google.test.js.map +0 -1
  83. package/dist/__tests__/webhook-queue.test.js +0 -26
  84. package/dist/__tests__/webhook-queue.test.js.map +0 -1
  85. package/dist/__tests__/webhook-refund.test.js +0 -313
  86. package/dist/__tests__/webhook-refund.test.js.map +0 -1
  87. package/dist/cache.js +0 -39
  88. package/dist/cache.js.map +0 -1
  89. package/dist/errors.js +0 -19
  90. package/dist/errors.js.map +0 -1
  91. package/dist/http.js +0 -36
  92. package/dist/http.js.map +0 -1
  93. package/dist/logger.js +0 -20
  94. package/dist/logger.js.map +0 -1
  95. package/dist/openapi.js +0 -196
  96. package/dist/openapi.js.map +0 -1
  97. package/dist/providers/apple-root-ca.js +0 -38
  98. package/dist/providers/apple-root-ca.js.map +0 -1
  99. package/dist/providers/apple.js +0 -630
  100. package/dist/providers/apple.js.map +0 -1
  101. package/dist/providers/google.js +0 -564
  102. package/dist/providers/google.js.map +0 -1
  103. package/dist/providers/mock.js +0 -93
  104. package/dist/providers/mock.js.map +0 -1
  105. package/dist/routes/admin.js +0 -327
  106. package/dist/routes/admin.js.map +0 -1
  107. package/dist/routes/apple-offer.js +0 -72
  108. package/dist/routes/apple-offer.js.map +0 -1
  109. package/dist/routes/entitlements.js +0 -130
  110. package/dist/routes/entitlements.js.map +0 -1
  111. package/dist/routes/metrics.js +0 -281
  112. package/dist/routes/metrics.js.map +0 -1
  113. package/dist/routes/purchase.js +0 -204
  114. package/dist/routes/purchase.js.map +0 -1
  115. package/dist/routes/status.js +0 -55
  116. package/dist/routes/status.js.map +0 -1
  117. package/dist/routes/validate.js +0 -70
  118. package/dist/routes/validate.js.map +0 -1
  119. package/dist/routes/webhook-apple.js +0 -145
  120. package/dist/routes/webhook-apple.js.map +0 -1
  121. package/dist/routes/webhook-google.js +0 -204
  122. package/dist/routes/webhook-google.js.map +0 -1
  123. package/dist/routes/webhook.js +0 -19
  124. package/dist/routes/webhook.js.map +0 -1
  125. package/dist/store.js +0 -145
  126. package/dist/store.js.map +0 -1
  127. package/dist/stores/postgres.js +0 -329
  128. package/dist/stores/postgres.js.map +0 -1
  129. package/dist/stores/redis.js +0 -297
  130. package/dist/stores/redis.js.map +0 -1
  131. package/dist/stores/schema.js +0 -67
  132. package/dist/stores/schema.js.map +0 -1
  133. package/dist/tracing.js +0 -74
  134. package/dist/tracing.js.map +0 -1
  135. package/dist/webhook-events.js +0 -58
  136. package/dist/webhook-events.js.map +0 -1
  137. package/dist/webhook-queue.js +0 -108
  138. 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) {
@@ -387,8 +425,9 @@ async function validateAppleReceipt(receipt, config) {
387
425
  expiresAt: new Date(tx.expiresDate).toISOString(),
388
426
  originalTransactionId: tx.originalTransactionId,
389
427
  purchasedAt: new Date(purchasedAt).toISOString(),
390
- willRenew: status === SUBSCRIPTION_STATUS.ACTIVE
428
+ willRenew: status === SUBSCRIPTION_STATUS.ACTIVE,
391
429
  // refined by renewal info in webhook
430
+ ...tx.appAccountToken ? { boundAccountId: tx.appAccountToken } : {}
392
431
  };
393
432
  }
394
433
  async function validateAppleConsumableReceipt(signedTransaction, config, expectedProductId) {
@@ -948,6 +987,7 @@ async function validateGoogleReceipt(receipt, productId, config) {
948
987
  const purchasedAt = purchase.startTime ?? (/* @__PURE__ */ new Date()).toISOString();
949
988
  const willRenew = lineItem.autoRenewingPlan?.autoRenewEnabled ?? false;
950
989
  const autoResumeTime = status === SUBSCRIPTION_STATUS.PAUSED ? purchase.pausedStateContext?.autoResumeTime : void 0;
990
+ const boundAccountId = purchase.externalAccountIdentifiers?.obfuscatedExternalAccountId;
951
991
  return {
952
992
  userId: "",
953
993
  // caller fills this in
@@ -955,13 +995,18 @@ async function validateGoogleReceipt(receipt, productId, config) {
955
995
  platform: "google",
956
996
  status,
957
997
  expiresAt,
958
- 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,
959
1003
  purchasedAt,
960
1004
  willRenew,
961
1005
  // Surface the linked token so host apps (and our webhook userId-continuity
962
1006
  // logic) can follow upgrade/downgrade chains.
963
1007
  linkedPurchaseToken: purchase.linkedPurchaseToken,
964
- autoResumeTime
1008
+ autoResumeTime,
1009
+ ...boundAccountId ? { boundAccountId } : {}
965
1010
  };
966
1011
  }
967
1012
  async function validateGoogleProductReceipt(purchaseToken, productId, config, type = "non_consumable") {
@@ -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;