@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
@@ -6,7 +6,7 @@
6
6
  * - subscriptionState string enum → onesub SubscriptionStatus mapping
7
7
  * - lineItems productId match (success / mismatch)
8
8
  * - autoRenewingPlan.autoRenewEnabled → willRenew
9
- * - latestOrderId → originalTransactionId
9
+ * - purchaseToken → originalTransactionId (webhooks look records up by token)
10
10
  * - PENDING / UNSPECIFIED states rejected (return null)
11
11
  */
12
12
  export {};
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=tracing.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tracing.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/tracing.test.ts"],"names":[],"mappings":""}
package/dist/cache.d.ts CHANGED
@@ -21,11 +21,16 @@ export interface CacheAdapter {
21
21
  */
22
22
  export declare class InMemoryCacheAdapter implements CacheAdapter {
23
23
  private readonly entries;
24
+ private setsSinceSweep;
24
25
  get<T>(key: string): Promise<T | null>;
25
26
  set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
26
27
  del(key: string): Promise<void>;
28
+ /** Evict every entry whose TTL has elapsed. */
29
+ private sweepExpired;
27
30
  /** Test helper — clear everything. Not part of the public CacheAdapter contract. */
28
31
  clear(): void;
32
+ /** Test helper — number of entries currently held (including not-yet-swept expired ones). */
33
+ get size(): number;
29
34
  }
30
35
  export declare function getDefaultCache(): CacheAdapter;
31
36
  export declare function setDefaultCache(adapter: CacheAdapter): void;
@@ -1 +1 @@
1
- {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,YAAY;IAC3B,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACjD,wDAAwD;IACxD,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5E,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjC;AAQD;;GAEG;AACH,qBAAa,oBAAqB,YAAW,YAAY;IACvD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4B;IAE9C,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAUtC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKjE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrC,oFAAoF;IACpF,KAAK,IAAI,IAAI;CAGd;AAQD,wBAAgB,eAAe,IAAI,YAAY,CAE9C;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAE3D"}
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,YAAY;IAC3B,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACjD,wDAAwD;IACxD,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5E,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjC;AAWD;;GAEG;AACH,qBAAa,oBAAqB,YAAW,YAAY;IACvD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4B;IACpD,OAAO,CAAC,cAAc,CAAK;IAErB,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAUtC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYjE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrC,+CAA+C;IAC/C,OAAO,CAAC,YAAY;IAQpB,oFAAoF;IACpF,KAAK,IAAI,IAAI;IAKb,6FAA6F;IAC7F,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAQD,wBAAgB,eAAe,IAAI,YAAY,CAE9C;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAE3D"}
package/dist/index.cjs CHANGED
@@ -5,13 +5,19 @@ var shared = require('@onesub/shared');
5
5
  var zod = require('zod');
6
6
  var jose = require('jose');
7
7
  var crypto = require('crypto');
8
+ var module$1 = require('module');
8
9
 
9
10
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
10
11
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
11
12
 
12
13
  var express__default = /*#__PURE__*/_interopDefault(express);
13
14
 
14
- // src/index.ts
15
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
16
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
17
+ }) : x)(function(x) {
18
+ if (typeof require !== "undefined") return require.apply(this, arguments);
19
+ throw Error('Dynamic require of "' + x + '" is not supported');
20
+ });
15
21
 
16
22
  // src/store.ts
17
23
  var InMemorySubscriptionStore = class {
@@ -21,6 +27,14 @@ var InMemorySubscriptionStore = class {
21
27
  byUserId = /* @__PURE__ */ new Map();
22
28
  byTransactionId = /* @__PURE__ */ new Map();
23
29
  async save(sub) {
30
+ const prev = this.byTransactionId.get(sub.originalTransactionId);
31
+ if (prev && prev.userId !== sub.userId) {
32
+ const oldList = (this.byUserId.get(prev.userId) ?? []).filter(
33
+ (s) => s.originalTransactionId !== sub.originalTransactionId
34
+ );
35
+ if (oldList.length) this.byUserId.set(prev.userId, oldList);
36
+ else this.byUserId.delete(prev.userId);
37
+ }
24
38
  this.byTransactionId.set(sub.originalTransactionId, sub);
25
39
  const existing = this.byUserId.get(sub.userId) ?? [];
26
40
  const filtered = existing.filter((s) => s.originalTransactionId !== sub.originalTransactionId);
@@ -181,8 +195,10 @@ async function fetchWithTimeout(input, init, timeoutMs = DEFAULT_FETCH_TIMEOUT_M
181
195
  }
182
196
 
183
197
  // src/cache.ts
198
+ var SWEEP_EVERY_N_SETS = 256;
184
199
  var InMemoryCacheAdapter = class {
185
200
  entries = /* @__PURE__ */ new Map();
201
+ setsSinceSweep = 0;
186
202
  async get(key) {
187
203
  const entry = this.entries.get(key);
188
204
  if (!entry) return null;
@@ -195,13 +211,29 @@ var InMemoryCacheAdapter = class {
195
211
  async set(key, value, ttlSeconds) {
196
212
  const expiresAt = ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1e3 : 0;
197
213
  this.entries.set(key, { value, expiresAt });
214
+ if (++this.setsSinceSweep >= SWEEP_EVERY_N_SETS) {
215
+ this.sweepExpired();
216
+ }
198
217
  }
199
218
  async del(key) {
200
219
  this.entries.delete(key);
201
220
  }
221
+ /** Evict every entry whose TTL has elapsed. */
222
+ sweepExpired() {
223
+ this.setsSinceSweep = 0;
224
+ const now = Date.now();
225
+ for (const [key, entry] of this.entries) {
226
+ if (entry.expiresAt !== 0 && entry.expiresAt < now) this.entries.delete(key);
227
+ }
228
+ }
202
229
  /** Test helper — clear everything. Not part of the public CacheAdapter contract. */
203
230
  clear() {
204
231
  this.entries.clear();
232
+ this.setsSinceSweep = 0;
233
+ }
234
+ /** Test helper — number of entries currently held (including not-yet-swept expired ones). */
235
+ get size() {
236
+ return this.entries.size;
205
237
  }
206
238
  };
207
239
  var defaultAdapter = new InMemoryCacheAdapter();
@@ -240,6 +272,7 @@ function mockValidateAppleSubscription(receipt) {
240
272
  if (!outcomePasses(outcome, "apple")) return null;
241
273
  const now = Date.now();
242
274
  const expiresAt = outcome.kind === "sandbox" ? now + 1 * HOURS : now + 30 * DAYS;
275
+ const tokenMatch = receipt.match(/#token=([^#]+)/);
243
276
  return {
244
277
  userId: "",
245
278
  productId: "mock_subscription",
@@ -248,7 +281,8 @@ function mockValidateAppleSubscription(receipt) {
248
281
  expiresAt: new Date(expiresAt).toISOString(),
249
282
  originalTransactionId: deterministicTransactionId("mock_apple_orig", receipt),
250
283
  purchasedAt: new Date(now).toISOString(),
251
- willRenew: true
284
+ willRenew: true,
285
+ ...tokenMatch ? { boundAccountId: tokenMatch[1] } : {}
252
286
  };
253
287
  }
254
288
  function mockValidateAppleProduct(receipt, expectedProductId) {
@@ -268,15 +302,19 @@ function mockValidateGoogleSubscription(receipt, productId) {
268
302
  if (!outcomePasses(outcome, "google")) return null;
269
303
  const now = Date.now();
270
304
  const expiresAt = outcome.kind === "sandbox" ? now + 1 * HOURS : now + 30 * DAYS;
305
+ const tokenMatch = receipt.match(/#token=([^#]+)/);
271
306
  return {
272
307
  userId: "",
273
308
  productId,
274
309
  platform: "google",
275
310
  status: shared.SUBSCRIPTION_STATUS.ACTIVE,
276
311
  expiresAt: new Date(expiresAt).toISOString(),
277
- originalTransactionId: deterministicTransactionId("mock_google_orig", receipt),
312
+ // Same keying as production: Google records are keyed by the purchaseToken
313
+ // (the receipt itself) so mock webhooks can find them by token.
314
+ originalTransactionId: receipt,
278
315
  purchasedAt: new Date(now).toISOString(),
279
- willRenew: true
316
+ willRenew: true,
317
+ ...tokenMatch ? { boundAccountId: tokenMatch[1] } : {}
280
318
  };
281
319
  }
282
320
  function mockValidateGoogleProduct(receipt, productId) {
@@ -394,8 +432,9 @@ async function validateAppleReceipt(receipt, config) {
394
432
  expiresAt: new Date(tx.expiresDate).toISOString(),
395
433
  originalTransactionId: tx.originalTransactionId,
396
434
  purchasedAt: new Date(purchasedAt).toISOString(),
397
- willRenew: status === shared.SUBSCRIPTION_STATUS.ACTIVE
435
+ willRenew: status === shared.SUBSCRIPTION_STATUS.ACTIVE,
398
436
  // refined by renewal info in webhook
437
+ ...tx.appAccountToken ? { boundAccountId: tx.appAccountToken } : {}
399
438
  };
400
439
  }
401
440
  async function validateAppleConsumableReceipt(signedTransaction, config, expectedProductId) {
@@ -955,6 +994,7 @@ async function validateGoogleReceipt(receipt, productId, config) {
955
994
  const purchasedAt = purchase.startTime ?? (/* @__PURE__ */ new Date()).toISOString();
956
995
  const willRenew = lineItem.autoRenewingPlan?.autoRenewEnabled ?? false;
957
996
  const autoResumeTime = status === shared.SUBSCRIPTION_STATUS.PAUSED ? purchase.pausedStateContext?.autoResumeTime : void 0;
997
+ const boundAccountId = purchase.externalAccountIdentifiers?.obfuscatedExternalAccountId;
958
998
  return {
959
999
  userId: "",
960
1000
  // caller fills this in
@@ -962,13 +1002,18 @@ async function validateGoogleReceipt(receipt, productId, config) {
962
1002
  platform: "google",
963
1003
  status,
964
1004
  expiresAt,
965
- originalTransactionId: purchase.latestOrderId ?? receipt.slice(0, 64),
1005
+ // Keyed by the purchaseToken, NOT latestOrderId: RTDN webhooks and
1006
+ // linkedPurchaseToken chains only carry the token, so records keyed by
1007
+ // order id are invisible to the entire webhook lifecycle (refund/cancel/
1008
+ // renewal would never find them).
1009
+ originalTransactionId: receipt,
966
1010
  purchasedAt,
967
1011
  willRenew,
968
1012
  // Surface the linked token so host apps (and our webhook userId-continuity
969
1013
  // logic) can follow upgrade/downgrade chains.
970
1014
  linkedPurchaseToken: purchase.linkedPurchaseToken,
971
- autoResumeTime
1015
+ autoResumeTime,
1016
+ ...boundAccountId ? { boundAccountId } : {}
972
1017
  };
973
1018
  }
974
1019
  async function validateGoogleProductReceipt(purchaseToken, productId, config, type = "non_consumable") {
@@ -1144,6 +1189,22 @@ function createValidateRouter(config, store) {
1144
1189
  sendError(res, 422, shared.ONESUB_ERROR_CODE.RECEIPT_VALIDATION_FAILED, "Receipt validation failed", NO_SUB);
1145
1190
  return;
1146
1191
  }
1192
+ const boundAccountId = sub.boundAccountId;
1193
+ delete sub.boundAccountId;
1194
+ const bindingMismatch = platform === "apple" ? boundAccountId && boundAccountId.toLowerCase() !== userId.toLowerCase() : boundAccountId && boundAccountId !== userId;
1195
+ if (bindingMismatch) {
1196
+ log.warn(
1197
+ `[onesub/validate] account binding mismatch for ${sub.originalTransactionId}: receipt token does not match userId ${userId}`
1198
+ );
1199
+ sendError(
1200
+ res,
1201
+ 409,
1202
+ shared.ONESUB_ERROR_CODE.TRANSACTION_BELONGS_TO_OTHER_USER,
1203
+ "TRANSACTION_BELONGS_TO_OTHER_USER",
1204
+ NO_SUB
1205
+ );
1206
+ return;
1207
+ }
1147
1208
  sub.userId = userId;
1148
1209
  await store.save(sub);
1149
1210
  if (platform === "google" && config.google) {
@@ -1191,7 +1252,7 @@ function createStatusRouter(store) {
1191
1252
  return router;
1192
1253
  }
1193
1254
  var APPLE_ACTIVE_TYPES = /* @__PURE__ */ new Set(["SUBSCRIBED", "DID_RENEW", "DID_RECOVER", "OFFER_REDEEMED"]);
1194
- var APPLE_CANCELED_TYPES = /* @__PURE__ */ new Set(["REVOKE", "REFUND", "CONSUMPTION_REQUEST"]);
1255
+ var APPLE_CANCELED_TYPES = /* @__PURE__ */ new Set(["REVOKE", "REFUND"]);
1195
1256
  var APPLE_EXPIRED_TYPES = /* @__PURE__ */ new Set(["EXPIRED"]);
1196
1257
  function mapAppleNotificationStatus(notificationType, subtype) {
1197
1258
  if (notificationType === "DID_FAIL_TO_RENEW") {
@@ -1248,6 +1309,11 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1248
1309
  } = decoded;
1249
1310
  const notificationType = payload.notificationType;
1250
1311
  const subtype = payload.subtype;
1312
+ if (config.apple?.bundleId && bundleId && bundleId !== config.apple.bundleId) {
1313
+ log.warn("[onesub/webhook/apple] Bundle ID mismatch:", bundleId, "!==", config.apple.bundleId);
1314
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.BUNDLE_ID_MISMATCH, "Bundle ID mismatch");
1315
+ return;
1316
+ }
1251
1317
  const mapped = mapAppleNotificationStatus(notificationType, subtype);
1252
1318
  const finalStatus = mapped ?? status;
1253
1319
  if (notificationType === "CONSUMPTION_REQUEST" && config.apple?.consumptionInfoProvider && transactionId && productId) {
@@ -1274,6 +1340,10 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1274
1340
  }
1275
1341
  const isOneTimePurchase = type === "Consumable" || type === "Non-Consumable";
1276
1342
  const isRefundOrRevoke = APPLE_CANCELED_TYPES.has(notificationType);
1343
+ if (isOneTimePurchase && notificationType === "CONSUMPTION_REQUEST") {
1344
+ res.status(200).json({ received: true });
1345
+ return;
1346
+ }
1277
1347
  try {
1278
1348
  if (isOneTimePurchase && isRefundOrRevoke) {
1279
1349
  const lookupId = transactionId ?? originalTransactionId;
@@ -1286,7 +1356,7 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1286
1356
  }
1287
1357
  const existing = await store.getByTransactionId(originalTransactionId);
1288
1358
  if (existing) {
1289
- const isSubscriptionRefund = isRefundOrRevoke && !isOneTimePurchase && notificationType !== "CONSUMPTION_REQUEST";
1359
+ const isSubscriptionRefund = isRefundOrRevoke && !isOneTimePurchase;
1290
1360
  const keepEntitlement = isSubscriptionRefund && config.refundPolicy === "until_expiry";
1291
1361
  const correctedUserId = appAccountToken && existing.userId === originalTransactionId ? appAccountToken : existing.userId;
1292
1362
  if (correctedUserId !== existing.userId) {
@@ -1326,6 +1396,12 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1326
1396
  res.status(200).json({ received: true });
1327
1397
  } catch (err) {
1328
1398
  log.error("[onesub/webhook/apple] Store update error:", err);
1399
+ if (webhookEventStore?.unmark && typeof payload.notificationUUID === "string") {
1400
+ try {
1401
+ await webhookEventStore.unmark("apple", payload.notificationUUID);
1402
+ } catch {
1403
+ }
1404
+ }
1329
1405
  sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to update subscription");
1330
1406
  }
1331
1407
  }
@@ -1335,13 +1411,23 @@ function getGoogleJwks() {
1335
1411
  if (!googleJwks) googleJwks = jose.createRemoteJWKSet(new URL(GOOGLE_JWKS_URL));
1336
1412
  return googleJwks;
1337
1413
  }
1338
- async function verifyGooglePushToken(req, expectedAudience) {
1414
+ async function verifyGooglePushToken(req, expectedAudience, expectedServiceAccountEmail) {
1339
1415
  const authHeader = req.headers["authorization"];
1340
1416
  if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) return false;
1341
1417
  try {
1342
- await jose.jwtVerify(authHeader.slice("Bearer ".length).trim(), getGoogleJwks(), {
1343
- audience: expectedAudience
1418
+ const { payload } = await jose.jwtVerify(authHeader.slice("Bearer ".length).trim(), getGoogleJwks(), {
1419
+ audience: expectedAudience,
1420
+ // Any Google Cloud principal can mint an OIDC token with an arbitrary
1421
+ // audience — issuer (and, when configured, the push service-account
1422
+ // email) narrows acceptance to real Pub/Sub push auth tokens. Google
1423
+ // documents both issuer forms, so accept either.
1424
+ issuer: ["https://accounts.google.com", "accounts.google.com"]
1344
1425
  });
1426
+ if (expectedServiceAccountEmail) {
1427
+ if (payload["email"] !== expectedServiceAccountEmail || payload["email_verified"] !== true) {
1428
+ return false;
1429
+ }
1430
+ }
1345
1431
  return true;
1346
1432
  } catch {
1347
1433
  return false;
@@ -1349,7 +1435,11 @@ async function verifyGooglePushToken(req, expectedAudience) {
1349
1435
  }
1350
1436
  async function handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore) {
1351
1437
  if (config.google?.pushAudience) {
1352
- const authenticated = await verifyGooglePushToken(req, config.google.pushAudience);
1438
+ const authenticated = await verifyGooglePushToken(
1439
+ req,
1440
+ config.google.pushAudience,
1441
+ config.google.pushServiceAccountEmail
1442
+ );
1353
1443
  if (!authenticated) {
1354
1444
  sendError(res, 401, shared.ONESUB_ERROR_CODE.UNAUTHORIZED, "Unauthorized");
1355
1445
  return;
@@ -1360,6 +1450,14 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1360
1450
  sendError(res, 400, shared.ONESUB_ERROR_CODE.MISSING_MESSAGE_DATA, "Missing message.data");
1361
1451
  return;
1362
1452
  }
1453
+ const unmarkEvent = async () => {
1454
+ if (webhookEventStore?.unmark && typeof body.message?.messageId === "string") {
1455
+ try {
1456
+ await webhookEventStore.unmark("google", body.message.messageId);
1457
+ } catch {
1458
+ }
1459
+ }
1460
+ };
1363
1461
  if (webhookEventStore && typeof body.message.messageId === "string") {
1364
1462
  const fresh = await webhookEventStore.markIfNew("google", body.message.messageId);
1365
1463
  if (!fresh) {
@@ -1393,6 +1491,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1393
1491
  res.status(200).json({ received: true });
1394
1492
  } catch (err) {
1395
1493
  log.error("[onesub/webhook/google] voided notification error:", err);
1494
+ await unmarkEvent();
1396
1495
  sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process voided notification");
1397
1496
  }
1398
1497
  return;
@@ -1419,6 +1518,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1419
1518
  res.status(200).json({ received: true });
1420
1519
  } catch (err) {
1421
1520
  log.error("[onesub/webhook/google] oneTimeProduct error:", err);
1521
+ await unmarkEvent();
1422
1522
  sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process oneTimeProduct notification");
1423
1523
  }
1424
1524
  return;
@@ -1452,7 +1552,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1452
1552
  } else if (isGoogleExpiredNotification(notificationType)) {
1453
1553
  finalStatus = shared.SUBSCRIPTION_STATUS.EXPIRED;
1454
1554
  } else {
1455
- finalStatus = shared.SUBSCRIPTION_STATUS.ACTIVE;
1555
+ finalStatus = existing?.status ?? shared.SUBSCRIPTION_STATUS.ACTIVE;
1456
1556
  }
1457
1557
  if (isGooglePriceChangeConfirmedNotification(notificationType) && config.google?.onPriceChangeConfirmed) {
1458
1558
  const hook = config.google.onPriceChangeConfirmed;
@@ -1463,7 +1563,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1463
1563
  if (config.google?.serviceAccountKey) {
1464
1564
  const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1465
1565
  if (fresh) {
1466
- const preserveNotificationStatus = finalStatus === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD || finalStatus === shared.SUBSCRIPTION_STATUS.ON_HOLD;
1566
+ const preserveNotificationStatus = isGoogleGracePeriodNotification(notificationType) || isGoogleOnHoldNotification(notificationType);
1467
1567
  updated = {
1468
1568
  ...existing,
1469
1569
  status: preserveNotificationStatus ? finalStatus : fresh.status,
@@ -1479,14 +1579,16 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1479
1579
  if (config.google?.serviceAccountKey) {
1480
1580
  const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1481
1581
  if (fresh) {
1582
+ const boundAccountId = fresh.boundAccountId;
1583
+ delete fresh.boundAccountId;
1482
1584
  if (fresh.linkedPurchaseToken) {
1483
1585
  const previous = await store.getByTransactionId(fresh.linkedPurchaseToken);
1484
- fresh.userId = previous ? previous.userId : purchaseToken;
1586
+ fresh.userId = previous ? previous.userId : boundAccountId ?? purchaseToken;
1485
1587
  if (previous) {
1486
1588
  log.info(`[onesub/webhook/google] inherited userId ${previous.userId} from linkedPurchaseToken ${fresh.linkedPurchaseToken} \u2192 new token ${purchaseToken}`);
1487
1589
  }
1488
1590
  } else {
1489
- fresh.userId = purchaseToken;
1591
+ fresh.userId = boundAccountId ?? purchaseToken;
1490
1592
  }
1491
1593
  await store.save(fresh);
1492
1594
  }
@@ -1497,6 +1599,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1497
1599
  res.status(200).json({ received: true });
1498
1600
  } catch (err) {
1499
1601
  log.error("[onesub/webhook/google] Error handling notification:", err);
1602
+ await unmarkEvent();
1500
1603
  sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process notification");
1501
1604
  }
1502
1605
  }
@@ -1590,7 +1693,8 @@ function createPurchaseRouter(config, purchaseStore) {
1590
1693
  sendError(res, 422, shared.ONESUB_ERROR_CODE.RECEIPT_VALIDATION_FAILED, "Receipt validation failed", NO_PURCHASE);
1591
1694
  return;
1592
1695
  }
1593
- if (boundAccountId && boundAccountId.toLowerCase() !== userId.toLowerCase()) {
1696
+ const bindingMismatch = platform === "apple" ? boundAccountId && boundAccountId.toLowerCase() !== userId.toLowerCase() : boundAccountId && boundAccountId !== userId;
1697
+ if (bindingMismatch) {
1594
1698
  log.warn(
1595
1699
  `[onesub/purchase] account binding mismatch for transaction ${transactionId}: token does not match userId ${userId}`
1596
1700
  );
@@ -1824,9 +1928,12 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
1824
1928
  sendError(res, 404, shared.ONESUB_ERROR_CODE.TRANSACTION_NOT_FOUND, "TRANSACTION_NOT_FOUND");
1825
1929
  return;
1826
1930
  }
1827
- await purchaseStore.deletePurchases(existing.userId, existing.productId);
1931
+ const moved = await purchaseStore.reassignPurchase(body.transactionId, body.newUserId);
1932
+ if (!moved) {
1933
+ sendError(res, 404, shared.ONESUB_ERROR_CODE.TRANSACTION_NOT_FOUND, "TRANSACTION_NOT_FOUND");
1934
+ return;
1935
+ }
1828
1936
  const migrated = { ...existing, userId: body.newUserId };
1829
- await purchaseStore.savePurchase(migrated);
1830
1937
  res.json({ ok: true, purchase: migrated });
1831
1938
  });
1832
1939
  const grantSchema = zod.z.object({
@@ -2358,7 +2465,11 @@ var PostgresSubscriptionStore = class {
2358
2465
  );
2359
2466
  });
2360
2467
  const Pool = pg.default?.Pool ?? pg.Pool;
2361
- return new Pool({ connectionString: this.connectionString, max: 10 });
2468
+ const pool = new Pool({ connectionString: this.connectionString, max: 10 });
2469
+ pool.on("error", (err) => {
2470
+ log.error("[onesub] PostgresSubscriptionStore pool error (idle client):", err);
2471
+ });
2472
+ return pool;
2362
2473
  })();
2363
2474
  }
2364
2475
  return this.poolPromise;
@@ -2530,7 +2641,11 @@ var PostgresPurchaseStore = class {
2530
2641
  );
2531
2642
  });
2532
2643
  const Pool = pg.default?.Pool ?? pg.Pool;
2533
- return new Pool({ connectionString: this.connectionString, max: 10 });
2644
+ const pool = new Pool({ connectionString: this.connectionString, max: 10 });
2645
+ pool.on("error", (err) => {
2646
+ log.error("[onesub] PostgresPurchaseStore pool error (idle client):", err);
2647
+ });
2648
+ return pool;
2534
2649
  })();
2535
2650
  }
2536
2651
  return this.poolPromise;
@@ -2545,23 +2660,12 @@ var PostgresPurchaseStore = class {
2545
2660
  }
2546
2661
  async savePurchase(purchase) {
2547
2662
  const pool = await this.getPool();
2548
- const existing = await pool.query(
2549
- `SELECT user_id FROM onesub_purchases WHERE transaction_id = $1`,
2550
- [purchase.transactionId]
2551
- );
2552
- if (existing.rows.length > 0) {
2553
- const owner = existing.rows[0].user_id;
2554
- if (owner !== purchase.userId) {
2555
- const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2556
- err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2557
- throw err;
2558
- }
2559
- return;
2560
- }
2561
- await pool.query(
2663
+ const inserted = await pool.query(
2562
2664
  `INSERT INTO onesub_purchases
2563
2665
  (transaction_id, user_id, product_id, platform, type, quantity, purchased_at)
2564
- VALUES ($1, $2, $3, $4, $5, $6, $7)`,
2666
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
2667
+ ON CONFLICT (transaction_id) DO NOTHING
2668
+ RETURNING transaction_id`,
2565
2669
  [
2566
2670
  purchase.transactionId,
2567
2671
  purchase.userId,
@@ -2572,6 +2676,17 @@ var PostgresPurchaseStore = class {
2572
2676
  purchase.purchasedAt
2573
2677
  ]
2574
2678
  );
2679
+ if (inserted.rows.length > 0) return;
2680
+ const existing = await pool.query(
2681
+ `SELECT user_id FROM onesub_purchases WHERE transaction_id = $1`,
2682
+ [purchase.transactionId]
2683
+ );
2684
+ const owner = existing.rows[0]?.user_id;
2685
+ if (owner !== void 0 && owner !== purchase.userId) {
2686
+ const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2687
+ err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2688
+ throw err;
2689
+ }
2575
2690
  }
2576
2691
  async getPurchasesByUserId(userId) {
2577
2692
  const pool = await this.getPool();
@@ -2672,7 +2787,6 @@ function rowToPurchaseInfo(row) {
2672
2787
  // src/stores/redis.ts
2673
2788
  var SUB_TX_PREFIX = "onesub:sub:tx:";
2674
2789
  var SUB_USER_PREFIX = "onesub:sub:user:";
2675
- var SUB_ALL = "onesub:sub:all";
2676
2790
  var SUB_ALL_SORTED = "onesub:sub:all:sorted";
2677
2791
  var PUR_TX_PREFIX = "onesub:purchase:tx:";
2678
2792
  var PUR_USER_PREFIX = "onesub:purchase:user:";
@@ -2687,10 +2801,14 @@ var RedisSubscriptionStore = class {
2687
2801
  const score = Date.now();
2688
2802
  const txKey = SUB_TX_PREFIX + sub.originalTransactionId;
2689
2803
  const userKey = SUB_USER_PREFIX + sub.userId;
2804
+ const prevRaw = await this.redis.get(txKey);
2805
+ const prevUserId = prevRaw ? JSON.parse(prevRaw).userId : null;
2690
2806
  const pipeline = this.redis.multi();
2691
2807
  pipeline.set(txKey, JSON.stringify(sub));
2808
+ if (prevUserId !== null && prevUserId !== sub.userId) {
2809
+ pipeline.zrem(SUB_USER_PREFIX + prevUserId, sub.originalTransactionId);
2810
+ }
2692
2811
  pipeline.zadd(userKey, score, sub.originalTransactionId);
2693
- pipeline.sadd(SUB_ALL, sub.originalTransactionId);
2694
2812
  pipeline.zadd(SUB_ALL_SORTED, score, sub.originalTransactionId);
2695
2813
  await pipeline.exec();
2696
2814
  }
@@ -2762,19 +2880,18 @@ var RedisPurchaseStore = class {
2762
2880
  redis;
2763
2881
  async savePurchase(purchase) {
2764
2882
  const txKey = PUR_TX_PREFIX + purchase.transactionId;
2765
- const existing = await this.redis.get(txKey);
2766
- if (existing) {
2767
- const owner = JSON.parse(existing).userId;
2768
- if (owner !== purchase.userId) {
2883
+ const claimed = await this.redis.set(txKey, JSON.stringify(purchase), "NX");
2884
+ if (claimed !== "OK") {
2885
+ const existing = await this.redis.get(txKey);
2886
+ const owner = existing ? JSON.parse(existing).userId : null;
2887
+ if (owner !== null && owner !== purchase.userId) {
2769
2888
  const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2770
2889
  err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2771
2890
  throw err;
2772
2891
  }
2773
- return;
2774
2892
  }
2775
2893
  const score = Date.parse(purchase.purchasedAt) || Date.now();
2776
2894
  const pipeline = this.redis.multi();
2777
- pipeline.set(txKey, JSON.stringify(purchase));
2778
2895
  pipeline.zadd(PUR_USER_PREFIX + purchase.userId, score, purchase.transactionId);
2779
2896
  pipeline.sadd(PUR_USER_PRODUCT_PREFIX + purchase.userId + ":" + purchase.productId, purchase.transactionId);
2780
2897
  pipeline.sadd(PUR_ALL, purchase.transactionId);
@@ -2885,6 +3002,9 @@ var RedisWebhookEventStore = class {
2885
3002
  const result = await this.redis.set(key, "1", "EX", this.ttlSeconds, "NX");
2886
3003
  return result === "OK";
2887
3004
  }
3005
+ async unmark(provider, eventId) {
3006
+ await this.redis.del(WEBHOOK_EVENT_PREFIX + provider + ":" + eventId);
3007
+ }
2888
3008
  };
2889
3009
 
2890
3010
  // src/webhook-events.ts
@@ -2902,6 +3022,9 @@ var InMemoryWebhookEventStore = class {
2902
3022
  this.seen.set(key, Date.now() + this.ttlSeconds * 1e3);
2903
3023
  return true;
2904
3024
  }
3025
+ async unmark(provider, eventId) {
3026
+ this.seen.delete(`${provider}:${eventId}`);
3027
+ }
2905
3028
  evictExpired() {
2906
3029
  const now = Date.now();
2907
3030
  for (const [key, expiresAt] of this.seen) {
@@ -2923,6 +3046,9 @@ var CacheWebhookEventStore = class {
2923
3046
  await this.cache.set(key, "1", this.ttlSeconds);
2924
3047
  return true;
2925
3048
  }
3049
+ async unmark(provider, eventId) {
3050
+ await this.cache.del(`webhook:event:${provider}:${eventId}`);
3051
+ }
2926
3052
  };
2927
3053
 
2928
3054
  // src/webhook-queue.ts
@@ -3200,8 +3326,6 @@ function openapiHandler() {
3200
3326
  res.json(ONESUB_OPENAPI);
3201
3327
  };
3202
3328
  }
3203
-
3204
- // src/tracing.ts
3205
3329
  var NOOP_SPAN = {
3206
3330
  setAttribute: () => {
3207
3331
  },
@@ -3218,8 +3342,7 @@ function getTracer() {
3218
3342
  if (resolved) return cachedTracer;
3219
3343
  resolved = true;
3220
3344
  try {
3221
- const req = globalThis.require;
3222
- if (!req) return null;
3345
+ const req = typeof __require === "function" ? __require : module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
3223
3346
  const otel = req("@opentelemetry/api");
3224
3347
  cachedTracer = otel.trace.getTracer("@onesub/server", "1.0.0");
3225
3348
  return cachedTracer;