@onesub/server 0.16.0 → 0.17.1

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 (37) hide show
  1. package/dist/__tests__/apple-cert-chain.test.d.ts +17 -0
  2. package/dist/__tests__/apple-cert-chain.test.d.ts.map +1 -0
  3. package/dist/__tests__/openapi.test.d.ts +14 -0
  4. package/dist/__tests__/openapi.test.d.ts.map +1 -1
  5. package/dist/__tests__/secret-compare.test.d.ts +2 -0
  6. package/dist/__tests__/secret-compare.test.d.ts.map +1 -0
  7. package/dist/__tests__/webhook-queue-integration.test.d.ts +20 -0
  8. package/dist/__tests__/webhook-queue-integration.test.d.ts.map +1 -0
  9. package/dist/index.cjs +916 -440
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.ts +19 -5
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +916 -443
  14. package/dist/index.js.map +1 -1
  15. package/dist/openapi.d.ts +4 -2
  16. package/dist/openapi.d.ts.map +1 -1
  17. package/dist/providers/apple.d.ts +20 -0
  18. package/dist/providers/apple.d.ts.map +1 -1
  19. package/dist/providers/mock.d.ts.map +1 -1
  20. package/dist/routes/admin.d.ts.map +1 -1
  21. package/dist/routes/apple-offer.d.ts.map +1 -1
  22. package/dist/routes/metrics.d.ts.map +1 -1
  23. package/dist/routes/secret-compare.d.ts +11 -0
  24. package/dist/routes/secret-compare.d.ts.map +1 -0
  25. package/dist/routes/webhook-apple.d.ts +26 -1
  26. package/dist/routes/webhook-apple.d.ts.map +1 -1
  27. package/dist/routes/webhook-google.d.ts +33 -1
  28. package/dist/routes/webhook-google.d.ts.map +1 -1
  29. package/dist/routes/webhook.d.ts +9 -2
  30. package/dist/routes/webhook.d.ts.map +1 -1
  31. package/dist/stores/postgres.d.ts.map +1 -1
  32. package/dist/stores/redis.d.ts.map +1 -1
  33. package/dist/webhook-events.d.ts +15 -0
  34. package/dist/webhook-events.d.ts.map +1 -1
  35. package/dist/webhook-queue.d.ts +11 -3
  36. package/dist/webhook-queue.d.ts.map +1 -1
  37. package/package.json +5 -5
package/dist/index.cjs CHANGED
@@ -83,9 +83,9 @@ var InMemoryPurchaseStore = class {
83
83
  const existing = this.byTransactionId.get(purchase.transactionId);
84
84
  if (existing) {
85
85
  if (existing.userId !== purchase.userId) {
86
- const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
87
- err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
88
- throw err;
86
+ const err2 = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
87
+ err2.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
88
+ throw err2;
89
89
  }
90
90
  return;
91
91
  }
@@ -265,6 +265,9 @@ function deterministicTransactionId(prefix, receipt) {
265
265
  const digest = crypto.createHash("sha256").update(receipt).digest("hex").slice(0, 24);
266
266
  return `${prefix}_${digest}`;
267
267
  }
268
+ function extractBoundToken(receipt) {
269
+ return receipt.match(/#token=([^#]+)/)?.[1];
270
+ }
268
271
  var HOURS = 60 * 60 * 1e3;
269
272
  var DAYS = 24 * HOURS;
270
273
  function mockValidateAppleSubscription(receipt) {
@@ -272,7 +275,7 @@ function mockValidateAppleSubscription(receipt) {
272
275
  if (!outcomePasses(outcome, "apple")) return null;
273
276
  const now = Date.now();
274
277
  const expiresAt = outcome.kind === "sandbox" ? now + 1 * HOURS : now + 30 * DAYS;
275
- const tokenMatch = receipt.match(/#token=([^#]+)/);
278
+ const boundToken = extractBoundToken(receipt);
276
279
  return {
277
280
  userId: "",
278
281
  productId: "mock_subscription",
@@ -282,19 +285,19 @@ function mockValidateAppleSubscription(receipt) {
282
285
  originalTransactionId: deterministicTransactionId("mock_apple_orig", receipt),
283
286
  purchasedAt: new Date(now).toISOString(),
284
287
  willRenew: true,
285
- ...tokenMatch ? { boundAccountId: tokenMatch[1] } : {}
288
+ ...boundToken ? { boundAccountId: boundToken } : {}
286
289
  };
287
290
  }
288
291
  function mockValidateAppleProduct(receipt, expectedProductId) {
289
292
  const outcome = classifyMockReceipt(receipt);
290
293
  if (!outcomePasses(outcome, "apple")) return null;
291
294
  const productId = expectedProductId ?? "mock_product";
292
- const tokenMatch = receipt.match(/#token=([^#]+)/);
295
+ const boundToken = extractBoundToken(receipt);
293
296
  return {
294
297
  transactionId: deterministicTransactionId(`mock_apple_${productId}`, receipt),
295
298
  productId,
296
299
  purchasedAt: (/* @__PURE__ */ new Date()).toISOString(),
297
- ...tokenMatch ? { appAccountToken: tokenMatch[1] } : {}
300
+ ...boundToken ? { appAccountToken: boundToken } : {}
298
301
  };
299
302
  }
300
303
  function mockValidateGoogleSubscription(receipt, productId) {
@@ -302,7 +305,7 @@ function mockValidateGoogleSubscription(receipt, productId) {
302
305
  if (!outcomePasses(outcome, "google")) return null;
303
306
  const now = Date.now();
304
307
  const expiresAt = outcome.kind === "sandbox" ? now + 1 * HOURS : now + 30 * DAYS;
305
- const tokenMatch = receipt.match(/#token=([^#]+)/);
308
+ const boundToken = extractBoundToken(receipt);
306
309
  return {
307
310
  userId: "",
308
311
  productId,
@@ -314,17 +317,17 @@ function mockValidateGoogleSubscription(receipt, productId) {
314
317
  originalTransactionId: receipt,
315
318
  purchasedAt: new Date(now).toISOString(),
316
319
  willRenew: true,
317
- ...tokenMatch ? { boundAccountId: tokenMatch[1] } : {}
320
+ ...boundToken ? { boundAccountId: boundToken } : {}
318
321
  };
319
322
  }
320
323
  function mockValidateGoogleProduct(receipt, productId) {
321
324
  const outcome = classifyMockReceipt(receipt);
322
325
  if (!outcomePasses(outcome, "google")) return null;
323
- const tokenMatch = receipt.match(/#token=([^#]+)/);
326
+ const boundToken = extractBoundToken(receipt);
324
327
  return {
325
328
  transactionId: deterministicTransactionId(`mock_google_${productId}`, receipt),
326
329
  purchasedAt: (/* @__PURE__ */ new Date()).toISOString(),
327
- ...tokenMatch ? { obfuscatedExternalAccountId: tokenMatch[1] } : {}
330
+ ...boundToken ? { obfuscatedExternalAccountId: boundToken } : {}
328
331
  };
329
332
  }
330
333
 
@@ -333,6 +336,13 @@ function derBase64ToPem(der) {
333
336
  return "-----BEGIN CERTIFICATE-----\n" + (der.match(/.{1,64}/g) ?? []).join("\n") + "\n-----END CERTIFICATE-----";
334
337
  }
335
338
  var APPLE_ROOT_CERTS = APPLE_ROOT_CA_PEMS.map((pem) => new crypto.X509Certificate(pem));
339
+ function assertIssuerCanSign(cert, index) {
340
+ if (!cert.ca) {
341
+ throw new Error(
342
+ `[onesub/apple] cert[${index}] is used as an issuer but is not a CA (basicConstraints CA=false)`
343
+ );
344
+ }
345
+ }
336
346
  function verifyAppleCertChain(x5c) {
337
347
  if (x5c.length === 0) {
338
348
  throw new Error("[onesub/apple] empty x5c");
@@ -344,6 +354,7 @@ function verifyAppleCertChain(x5c) {
344
354
  if (new Date(cert.validFrom) > now || new Date(cert.validTo) < now) {
345
355
  throw new Error(`[onesub/apple] cert[${i}] outside validity window`);
346
356
  }
357
+ if (i >= 1) assertIssuerCanSign(cert, i);
347
358
  if (i + 1 < chain.length) {
348
359
  if (!cert.checkIssued(chain[i + 1]) || !cert.verify(chain[i + 1].publicKey)) {
349
360
  throw new Error(`[onesub/apple] cert[${i}] not signed by cert[${i + 1}]`);
@@ -354,6 +365,7 @@ function verifyAppleCertChain(x5c) {
354
365
  const topDer = top.raw.toString("base64");
355
366
  const trustsRoot = APPLE_ROOT_CERTS.some((root) => {
356
367
  if (root.raw.toString("base64") === topDer) return true;
368
+ if (!root.ca) return false;
357
369
  if (!top.checkIssued(root)) return false;
358
370
  try {
359
371
  return top.verify(root.publicKey);
@@ -399,12 +411,12 @@ async function validateAppleReceipt(receipt, config) {
399
411
  let tx;
400
412
  try {
401
413
  tx = await decodeJws(receipt, config.skipJwsVerification);
402
- } catch (err) {
414
+ } catch (err2) {
403
415
  const preview = receipt.slice(0, 60);
404
416
  const parts = receipt.split(".").length;
405
417
  log.warn(
406
418
  "[onesub/apple] Failed to decode receipt as JWS:",
407
- err?.message ?? err,
419
+ err2?.message ?? err2,
408
420
  `| preview: "${preview}..." (len=${receipt.length}, parts=${parts})`
409
421
  );
410
422
  return null;
@@ -441,13 +453,13 @@ async function validateAppleConsumableReceipt(signedTransaction, config, expecte
441
453
  let tx;
442
454
  try {
443
455
  tx = await decodeJws(signedTransaction, config.skipJwsVerification);
444
- } catch (err) {
456
+ } catch (err2) {
445
457
  const preview = signedTransaction.slice(0, 60);
446
458
  const parts = signedTransaction.split(".").length;
447
459
  const looksLikeJws = parts === 3;
448
460
  log.warn(
449
461
  "[onesub/apple] Failed to decode consumable JWS:",
450
- err?.message ?? err,
462
+ err2?.message ?? err2,
451
463
  `| receipt preview: "${preview}..." (len=${signedTransaction.length}, parts=${parts}, looksLikeJws=${looksLikeJws})`
452
464
  );
453
465
  return null;
@@ -561,8 +573,8 @@ async function sendAppleConsumptionResponse(transactionId, body, config, options
561
573
  let jwt;
562
574
  try {
563
575
  jwt = await makeAppleApiJwt(config);
564
- } catch (err) {
565
- log.warn("[onesub/apple] Cannot send consumption response \u2014 JWT mint failed:", err);
576
+ } catch (err2) {
577
+ log.warn("[onesub/apple] Cannot send consumption response \u2014 JWT mint failed:", err2);
566
578
  return;
567
579
  }
568
580
  const host = options?.sandbox ? "api.storekit-sandbox.itunes.apple.com" : "api.storekit.itunes.apple.com";
@@ -580,8 +592,8 @@ async function sendAppleConsumptionResponse(transactionId, body, config, options
580
592
  const text = await resp.text();
581
593
  log.warn(`[onesub/apple] Consumption response API error ${resp.status}: ${text}`);
582
594
  }
583
- } catch (err) {
584
- log.warn("[onesub/apple] Consumption response network error:", err);
595
+ } catch (err2) {
596
+ log.warn("[onesub/apple] Consumption response network error:", err2);
585
597
  }
586
598
  }
587
599
  var APPLE_SUBSCRIPTION_STATUS_CODE = {
@@ -611,8 +623,8 @@ async function fetchAppleSubscriptionStatus(originalTransactionId, config, optio
611
623
  let jwt;
612
624
  try {
613
625
  jwt = await makeAppleApiJwt(config);
614
- } catch (err) {
615
- log.warn("[onesub/apple] Cannot fetch subscription status \u2014 JWT mint failed:", err);
626
+ } catch (err2) {
627
+ log.warn("[onesub/apple] Cannot fetch subscription status \u2014 JWT mint failed:", err2);
616
628
  return null;
617
629
  }
618
630
  const host = options?.sandbox ? "api.storekit-sandbox.itunes.apple.com" : "api.storekit.itunes.apple.com";
@@ -628,8 +640,8 @@ async function fetchAppleSubscriptionStatus(originalTransactionId, config, optio
628
640
  return null;
629
641
  }
630
642
  body = await resp.json();
631
- } catch (err) {
632
- log.warn("[onesub/apple] Status API network error:", err);
643
+ } catch (err2) {
644
+ log.warn("[onesub/apple] Status API network error:", err2);
633
645
  return null;
634
646
  }
635
647
  const entry = body.data?.flatMap((g) => g.lastTransactions ?? []).find((t) => t.originalTransactionId === originalTransactionId);
@@ -640,8 +652,8 @@ async function fetchAppleSubscriptionStatus(originalTransactionId, config, optio
640
652
  let tx;
641
653
  try {
642
654
  tx = await decodeJws(entry.signedTransactionInfo, config.skipJwsVerification);
643
- } catch (err) {
644
- log.warn("[onesub/apple] Failed to decode signedTransactionInfo from Status API:", err);
655
+ } catch (err2) {
656
+ log.warn("[onesub/apple] Failed to decode signedTransactionInfo from Status API:", err2);
645
657
  return null;
646
658
  }
647
659
  let renewal = null;
@@ -669,18 +681,20 @@ async function fetchAppleSubscriptionStatus(originalTransactionId, config, optio
669
681
  willRenew: renewal?.autoRenewStatus === 1
670
682
  };
671
683
  }
684
+ var MAX_HISTORY_PAGES = 50;
672
685
  async function fetchAppleTransactionHistory(originalTransactionId, config, options) {
673
686
  if (config.mockMode) return null;
674
687
  let jwt;
675
688
  try {
676
689
  jwt = await makeAppleApiJwt(config);
677
- } catch (err) {
678
- log.warn("[onesub/apple] Cannot fetch transaction history \u2014 JWT mint failed:", err);
690
+ } catch (err2) {
691
+ log.warn("[onesub/apple] Cannot fetch transaction history \u2014 JWT mint failed:", err2);
679
692
  return null;
680
693
  }
681
694
  const host = options?.sandbox ? "api.storekit-sandbox.itunes.apple.com" : "api.storekit.itunes.apple.com";
682
695
  const results = [];
683
696
  let revision;
697
+ let pageCount = 0;
684
698
  for (; ; ) {
685
699
  const url = new URL(`https://${host}/inApps/v2/history/${encodeURIComponent(originalTransactionId)}`);
686
700
  if (revision) url.searchParams.set("revision", revision);
@@ -695,8 +709,8 @@ async function fetchAppleTransactionHistory(originalTransactionId, config, optio
695
709
  return null;
696
710
  }
697
711
  page = await resp.json();
698
- } catch (err) {
699
- log.warn("[onesub/apple] Transaction History API network error:", err);
712
+ } catch (err2) {
713
+ log.warn("[onesub/apple] Transaction History API network error:", err2);
700
714
  return null;
701
715
  }
702
716
  for (const signed of page.signedTransactions ?? []) {
@@ -717,7 +731,20 @@ async function fetchAppleTransactionHistory(originalTransactionId, config, optio
717
731
  } catch {
718
732
  }
719
733
  }
734
+ pageCount++;
720
735
  if (!page.hasMore) break;
736
+ if (!page.revision || page.revision === revision) {
737
+ log.warn(
738
+ "[onesub/apple] Transaction History API returned hasMore without a new revision cursor \u2014 stopping pagination (partial history returned)"
739
+ );
740
+ break;
741
+ }
742
+ if (pageCount >= MAX_HISTORY_PAGES) {
743
+ log.warn(
744
+ `[onesub/apple] Transaction History pagination hit the ${MAX_HISTORY_PAGES}-page cap \u2014 stopping (partial history returned)`
745
+ );
746
+ break;
747
+ }
721
748
  revision = page.revision;
722
749
  }
723
750
  return results;
@@ -748,8 +775,8 @@ async function signApplePromotionalOffer(input, config) {
748
775
  sign.update(message);
749
776
  sign.end();
750
777
  signatureBuffer = sign.sign(offerPrivateKey);
751
- } catch (err) {
752
- throw new Error(`[onesub/apple] Failed to sign promotional offer \u2014 check that offerPrivateKey is a valid ES256 PEM key: ${err.message}`);
778
+ } catch (err2) {
779
+ throw new Error(`[onesub/apple] Failed to sign promotional offer \u2014 check that offerPrivateKey is a valid ES256 PEM key: ${err2.message}`);
753
780
  }
754
781
  const signature = signatureBuffer.toString("base64");
755
782
  return { keyId: offerKeyId, nonce, timestamp, signature };
@@ -864,8 +891,8 @@ async function acknowledgeGoogleSubscription(purchaseToken, productId, config) {
864
891
  let accessToken;
865
892
  try {
866
893
  accessToken = await getCachedAccessToken(config.serviceAccountKey);
867
- } catch (err) {
868
- log.warn("[onesub/google] Could not get access token for subscription ack:", err);
894
+ } catch (err2) {
895
+ log.warn("[onesub/google] Could not get access token for subscription ack:", err2);
869
896
  return;
870
897
  }
871
898
  const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(config.packageName)}/purchases/subscriptions/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:acknowledge`;
@@ -879,8 +906,8 @@ async function acknowledgeGoogleSubscription(purchaseToken, productId, config) {
879
906
  const body = await resp.text();
880
907
  log.warn(`[onesub/google] Subscription acknowledge API error ${resp.status}: ${body} \u2014 auto-refund risk`);
881
908
  }
882
- } catch (err) {
883
- log.warn("[onesub/google] Subscription acknowledge network error \u2014 auto-refund risk:", err);
909
+ } catch (err2) {
910
+ log.warn("[onesub/google] Subscription acknowledge network error \u2014 auto-refund risk:", err2);
884
911
  }
885
912
  }
886
913
  async function acknowledgeGoogleProduct(purchaseToken, productId, config) {
@@ -890,8 +917,8 @@ async function acknowledgeGoogleProduct(purchaseToken, productId, config) {
890
917
  let accessToken;
891
918
  try {
892
919
  accessToken = await getCachedAccessToken(config.serviceAccountKey);
893
- } catch (err) {
894
- log.warn("[onesub/google] Could not get access token for product ack:", err);
920
+ } catch (err2) {
921
+ log.warn("[onesub/google] Could not get access token for product ack:", err2);
895
922
  return;
896
923
  }
897
924
  const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(config.packageName)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:acknowledge`;
@@ -905,8 +932,8 @@ async function acknowledgeGoogleProduct(purchaseToken, productId, config) {
905
932
  const body = await resp.text();
906
933
  log.warn(`[onesub/google] Product acknowledge API error ${resp.status}: ${body} \u2014 auto-refund risk`);
907
934
  }
908
- } catch (err) {
909
- log.warn("[onesub/google] Product acknowledge network error \u2014 auto-refund risk:", err);
935
+ } catch (err2) {
936
+ log.warn("[onesub/google] Product acknowledge network error \u2014 auto-refund risk:", err2);
910
937
  }
911
938
  }
912
939
  async function consumeGoogleProductReceipt(purchaseToken, productId, config) {
@@ -915,8 +942,8 @@ async function consumeGoogleProductReceipt(purchaseToken, productId, config) {
915
942
  let accessToken;
916
943
  try {
917
944
  accessToken = await getCachedAccessToken(config.serviceAccountKey);
918
- } catch (err) {
919
- log.warn("[onesub/google] Could not get access token for consume:", err);
945
+ } catch (err2) {
946
+ log.warn("[onesub/google] Could not get access token for consume:", err2);
920
947
  return;
921
948
  }
922
949
  const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(config.packageName)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:consume`;
@@ -929,8 +956,8 @@ async function consumeGoogleProductReceipt(purchaseToken, productId, config) {
929
956
  const body = await resp.text();
930
957
  log.warn(`[onesub/google] Consume API error ${resp.status}: ${body} \u2014 auto-refund risk`);
931
958
  }
932
- } catch (err) {
933
- log.warn("[onesub/google] Consume network error \u2014 auto-refund risk:", err);
959
+ } catch (err2) {
960
+ log.warn("[onesub/google] Consume network error \u2014 auto-refund risk:", err2);
934
961
  }
935
962
  }
936
963
  function deriveStatusV2(state) {
@@ -967,8 +994,8 @@ async function validateGoogleReceipt(receipt, productId, config) {
967
994
  try {
968
995
  const token = await getCachedAccessToken(config.serviceAccountKey);
969
996
  purchase = await fetchSubscriptionPurchaseV2(config.packageName, receipt, token);
970
- } catch (err) {
971
- log.error("[onesub/google] Receipt validation failed:", err);
997
+ } catch (err2) {
998
+ log.error("[onesub/google] Receipt validation failed:", err2);
972
999
  return null;
973
1000
  }
974
1001
  const status = deriveStatusV2(purchase.subscriptionState);
@@ -1029,8 +1056,8 @@ async function validateGoogleProductReceipt(purchaseToken, productId, config, ty
1029
1056
  try {
1030
1057
  const token = await getCachedAccessToken(config.serviceAccountKey);
1031
1058
  purchase = await fetchProductPurchase(config.packageName, productId, purchaseToken, token);
1032
- } catch (err) {
1033
- log.error("[onesub/google] Product receipt validation failed:", err);
1059
+ } catch (err2) {
1060
+ log.error("[onesub/google] Product receipt validation failed:", err2);
1034
1061
  return null;
1035
1062
  }
1036
1063
  if (purchase.purchaseState !== 0) {
@@ -1136,12 +1163,12 @@ function isGooglePriceChangeConfirmedNotification(notificationType) {
1136
1163
  function sendError(res, status, code, error, extra = {}) {
1137
1164
  res.status(status).json({ ...extra, error, errorCode: code });
1138
1165
  }
1139
- function sendZodError(res, err, extra = {}) {
1166
+ function sendZodError(res, err2, extra = {}) {
1140
1167
  sendError(
1141
1168
  res,
1142
1169
  400,
1143
1170
  shared.ONESUB_ERROR_CODE.INVALID_INPUT,
1144
- err.issues.map((e) => e.message).join(", "),
1171
+ err2.issues.map((e) => e.message).join(", "),
1145
1172
  extra
1146
1173
  );
1147
1174
  }
@@ -1163,12 +1190,12 @@ function createValidateRouter(config, store) {
1163
1190
  let productId;
1164
1191
  try {
1165
1192
  ({ platform, receipt, userId, productId } = validateSchema.parse(req.body));
1166
- } catch (err) {
1167
- if (err instanceof zod.z.ZodError) {
1168
- sendZodError(res, err, NO_SUB);
1193
+ } catch (err2) {
1194
+ if (err2 instanceof zod.z.ZodError) {
1195
+ sendZodError(res, err2, NO_SUB);
1169
1196
  return;
1170
1197
  }
1171
- throw err;
1198
+ throw err2;
1172
1199
  }
1173
1200
  try {
1174
1201
  let sub = null;
@@ -1212,8 +1239,8 @@ function createValidateRouter(config, store) {
1212
1239
  }
1213
1240
  const response = { valid: true, subscription: sub };
1214
1241
  res.status(200).json(response);
1215
- } catch (err) {
1216
- log.error("[onesub/validate] Unexpected error:", err);
1242
+ } catch (err2) {
1243
+ log.error("[onesub/validate] Unexpected error:", err2);
1217
1244
  sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, "Internal server error during receipt validation", NO_SUB);
1218
1245
  }
1219
1246
  });
@@ -1244,13 +1271,66 @@ function createStatusRouter(store) {
1244
1271
  const active = statusAllows && notYetExpired;
1245
1272
  const response = { active, subscription: sub };
1246
1273
  res.status(200).json(response);
1247
- } catch (err) {
1248
- log.error("[onesub/status] Store error:", err);
1274
+ } catch (err2) {
1275
+ log.error("[onesub/status] Store error:", err2);
1249
1276
  sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error", NO_SUB2);
1250
1277
  }
1251
1278
  });
1252
1279
  return router;
1253
1280
  }
1281
+
1282
+ // src/webhook-events.ts
1283
+ async function unmarkWebhookEvent(store, provider, eventId) {
1284
+ if (!store?.unmark || typeof eventId !== "string") return;
1285
+ try {
1286
+ await store.unmark(provider, eventId);
1287
+ } catch {
1288
+ }
1289
+ }
1290
+ var DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60;
1291
+ var InMemoryWebhookEventStore = class {
1292
+ constructor(ttlSeconds = DEFAULT_TTL_SECONDS) {
1293
+ this.ttlSeconds = ttlSeconds;
1294
+ }
1295
+ ttlSeconds;
1296
+ seen = /* @__PURE__ */ new Map();
1297
+ async markIfNew(provider, eventId) {
1298
+ this.evictExpired();
1299
+ const key = `${provider}:${eventId}`;
1300
+ if (this.seen.has(key)) return false;
1301
+ this.seen.set(key, Date.now() + this.ttlSeconds * 1e3);
1302
+ return true;
1303
+ }
1304
+ async unmark(provider, eventId) {
1305
+ this.seen.delete(`${provider}:${eventId}`);
1306
+ }
1307
+ evictExpired() {
1308
+ const now = Date.now();
1309
+ for (const [key, expiresAt] of this.seen) {
1310
+ if (expiresAt < now) this.seen.delete(key);
1311
+ }
1312
+ }
1313
+ };
1314
+ var CacheWebhookEventStore = class {
1315
+ constructor(cache, ttlSeconds = DEFAULT_TTL_SECONDS) {
1316
+ this.cache = cache;
1317
+ this.ttlSeconds = ttlSeconds;
1318
+ }
1319
+ cache;
1320
+ ttlSeconds;
1321
+ async markIfNew(provider, eventId) {
1322
+ const key = `webhook:event:${provider}:${eventId}`;
1323
+ const existing = await this.cache.get(key);
1324
+ if (existing) return false;
1325
+ await this.cache.set(key, "1", this.ttlSeconds);
1326
+ return true;
1327
+ }
1328
+ async unmark(provider, eventId) {
1329
+ await this.cache.del(`webhook:event:${provider}:${eventId}`);
1330
+ }
1331
+ };
1332
+
1333
+ // src/routes/webhook-apple.ts
1254
1334
  var APPLE_ACTIVE_TYPES = /* @__PURE__ */ new Set(["SUBSCRIBED", "DID_RENEW", "DID_RECOVER", "OFFER_REDEEMED"]);
1255
1335
  var APPLE_CANCELED_TYPES = /* @__PURE__ */ new Set(["REVOKE", "REFUND"]);
1256
1336
  var APPLE_EXPIRED_TYPES = /* @__PURE__ */ new Set(["EXPIRED"]);
@@ -1264,36 +1344,8 @@ function mapAppleNotificationStatus(notificationType, subtype) {
1264
1344
  if (APPLE_ACTIVE_TYPES.has(notificationType)) return shared.SUBSCRIPTION_STATUS.ACTIVE;
1265
1345
  return null;
1266
1346
  }
1267
- async function handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore) {
1268
- const body = req.body;
1269
- if (!body.signedPayload) {
1270
- sendError(res, 400, shared.ONESUB_ERROR_CODE.MISSING_SIGNED_PAYLOAD, "Missing signedPayload");
1271
- return;
1272
- }
1273
- let payload;
1274
- try {
1275
- payload = await decodeJws(
1276
- body.signedPayload,
1277
- config.apple?.skipJwsVerification
1278
- );
1279
- } catch (err) {
1280
- log.error("[onesub/webhook/apple] Failed to decode signedPayload:", err);
1281
- sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_SIGNED_PAYLOAD, "Invalid signedPayload");
1282
- return;
1283
- }
1284
- if (webhookEventStore && typeof payload.notificationUUID === "string") {
1285
- const fresh = await webhookEventStore.markIfNew("apple", payload.notificationUUID);
1286
- if (!fresh) {
1287
- log.info("[onesub/webhook/apple] dedupe: already processed", payload.notificationUUID);
1288
- res.status(200).json({ received: true, deduped: true });
1289
- return;
1290
- }
1291
- }
1292
- const decoded = await decodeAppleNotification(payload, config.apple?.skipJwsVerification);
1293
- if (!decoded) {
1294
- res.status(200).json({ received: true });
1295
- return;
1296
- }
1347
+ async function processAppleNotification(work, config, store, purchaseStore) {
1348
+ const { notificationType, subtype } = work;
1297
1349
  const {
1298
1350
  originalTransactionId,
1299
1351
  transactionId,
@@ -1306,14 +1358,7 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1306
1358
  expiresAt,
1307
1359
  appAccountToken,
1308
1360
  inAppOwnershipType
1309
- } = decoded;
1310
- const notificationType = payload.notificationType;
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
- }
1361
+ } = work.decoded;
1317
1362
  const mapped = mapAppleNotificationStatus(notificationType, subtype);
1318
1363
  const finalStatus = mapped ?? status;
1319
1364
  if (notificationType === "CONSUMPTION_REQUEST" && config.apple?.consumptionInfoProvider && transactionId && productId) {
@@ -1333,75 +1378,129 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1333
1378
  sandbox: environment === "Sandbox"
1334
1379
  });
1335
1380
  }
1336
- } catch (err) {
1337
- log.warn("[onesub/webhook/apple] consumptionInfoProvider failed:", err);
1381
+ } catch (err2) {
1382
+ log.warn("[onesub/webhook/apple] consumptionInfoProvider failed:", err2);
1338
1383
  }
1339
1384
  })();
1340
1385
  }
1341
1386
  const isOneTimePurchase = type === "Consumable" || type === "Non-Consumable";
1342
1387
  const isRefundOrRevoke = APPLE_CANCELED_TYPES.has(notificationType);
1343
1388
  if (isOneTimePurchase && notificationType === "CONSUMPTION_REQUEST") {
1344
- res.status(200).json({ received: true });
1345
1389
  return;
1346
1390
  }
1347
- try {
1348
- if (isOneTimePurchase && isRefundOrRevoke) {
1349
- const lookupId = transactionId ?? originalTransactionId;
1350
- const removed = await purchaseStore.deletePurchaseByTransactionId(lookupId);
1351
- if (!removed) {
1352
- log.warn("[onesub/webhook/apple] IAP refund for unknown transaction:", lookupId);
1353
- }
1354
- res.status(200).json({ received: true });
1355
- return;
1391
+ if (isOneTimePurchase && isRefundOrRevoke) {
1392
+ const lookupId = transactionId ?? originalTransactionId;
1393
+ const removed = await purchaseStore.deletePurchaseByTransactionId(lookupId);
1394
+ if (!removed) {
1395
+ log.warn("[onesub/webhook/apple] IAP refund for unknown transaction:", lookupId);
1356
1396
  }
1357
- const existing = await store.getByTransactionId(originalTransactionId);
1358
- if (existing) {
1359
- const isSubscriptionRefund = isRefundOrRevoke && !isOneTimePurchase;
1360
- const keepEntitlement = isSubscriptionRefund && config.refundPolicy === "until_expiry";
1361
- const correctedUserId = appAccountToken && existing.userId === originalTransactionId ? appAccountToken : existing.userId;
1362
- if (correctedUserId !== existing.userId) {
1363
- log.info("[onesub/webhook/apple] correcting userId from originalTransactionId to appAccountToken:", correctedUserId);
1364
- }
1365
- const updated = keepEntitlement ? { ...existing, userId: correctedUserId, willRenew: false } : {
1366
- ...existing,
1367
- userId: correctedUserId,
1368
- status: finalStatus,
1369
- willRenew,
1370
- expiresAt: expiresAt ?? existing.expiresAt
1371
- };
1372
- await store.save(updated);
1373
- } else if (config.apple?.issuerId && config.apple?.keyId && config.apple?.privateKey) {
1374
- const fresh = await fetchAppleSubscriptionStatus(originalTransactionId, config.apple, {
1375
- sandbox: environment === "Sandbox"
1376
- });
1377
- if (fresh) {
1378
- fresh.userId = appAccountToken ?? originalTransactionId;
1379
- if (inAppOwnershipType === "FAMILY_SHARED") {
1380
- const source = appAccountToken ? "appAccountToken" : "originalTransactionId (fallback)";
1381
- log.info(`[onesub/webhook/apple] FAMILY_SHARED \u2014 userId: ${fresh.userId} (source: ${source})`);
1382
- }
1383
- await store.save(fresh);
1384
- } else {
1385
- log.warn(
1386
- "[onesub/webhook/apple] Unknown transaction and Status API returned no record:",
1387
- originalTransactionId
1388
- );
1397
+ return;
1398
+ }
1399
+ const existing = await store.getByTransactionId(originalTransactionId);
1400
+ if (existing) {
1401
+ const isSubscriptionRefund = isRefundOrRevoke && !isOneTimePurchase;
1402
+ const keepEntitlement = isSubscriptionRefund && config.refundPolicy === "until_expiry";
1403
+ const correctedUserId = appAccountToken && existing.userId === originalTransactionId ? appAccountToken : existing.userId;
1404
+ if (correctedUserId !== existing.userId) {
1405
+ log.info("[onesub/webhook/apple] correcting userId from originalTransactionId to appAccountToken:", correctedUserId);
1406
+ }
1407
+ const updated = keepEntitlement ? { ...existing, userId: correctedUserId, willRenew: false } : {
1408
+ ...existing,
1409
+ userId: correctedUserId,
1410
+ status: finalStatus,
1411
+ willRenew,
1412
+ expiresAt: expiresAt ?? existing.expiresAt
1413
+ };
1414
+ await store.save(updated);
1415
+ } else if (config.apple?.issuerId && config.apple?.keyId && config.apple?.privateKey) {
1416
+ const fresh = await fetchAppleSubscriptionStatus(originalTransactionId, config.apple, {
1417
+ sandbox: environment === "Sandbox"
1418
+ });
1419
+ if (fresh) {
1420
+ fresh.userId = appAccountToken ?? originalTransactionId;
1421
+ if (inAppOwnershipType === "FAMILY_SHARED") {
1422
+ const source = appAccountToken ? "appAccountToken" : "originalTransactionId (fallback)";
1423
+ log.info(`[onesub/webhook/apple] FAMILY_SHARED \u2014 userId: ${fresh.userId} (source: ${source})`);
1389
1424
  }
1425
+ await store.save(fresh);
1390
1426
  } else {
1391
1427
  log.warn(
1392
- "[onesub/webhook/apple] Received notification for unknown transaction (no API creds to recover):",
1428
+ "[onesub/webhook/apple] Unknown transaction and Status API returned no record:",
1393
1429
  originalTransactionId
1394
1430
  );
1395
1431
  }
1432
+ } else {
1433
+ log.warn(
1434
+ "[onesub/webhook/apple] Received notification for unknown transaction (no API creds to recover):",
1435
+ originalTransactionId
1436
+ );
1437
+ }
1438
+ }
1439
+ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue) {
1440
+ const body = req.body;
1441
+ if (!body.signedPayload) {
1442
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.MISSING_SIGNED_PAYLOAD, "Missing signedPayload");
1443
+ return;
1444
+ }
1445
+ let payload;
1446
+ try {
1447
+ payload = await decodeJws(
1448
+ body.signedPayload,
1449
+ config.apple?.skipJwsVerification
1450
+ );
1451
+ } catch (err2) {
1452
+ log.error("[onesub/webhook/apple] Failed to decode signedPayload:", err2);
1453
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_SIGNED_PAYLOAD, "Invalid signedPayload");
1454
+ return;
1455
+ }
1456
+ let markedEventId;
1457
+ if (webhookEventStore && typeof payload.notificationUUID === "string") {
1458
+ const fresh = await webhookEventStore.markIfNew("apple", payload.notificationUUID);
1459
+ if (!fresh) {
1460
+ log.info("[onesub/webhook/apple] dedupe: already processed", payload.notificationUUID);
1461
+ res.status(200).json({ received: true, deduped: true });
1462
+ return;
1463
+ }
1464
+ markedEventId = payload.notificationUUID;
1465
+ }
1466
+ const decoded = await decodeAppleNotification(payload, config.apple?.skipJwsVerification);
1467
+ if (!decoded) {
1396
1468
  res.status(200).json({ received: true });
1397
- } catch (err) {
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
- }
1469
+ return;
1470
+ }
1471
+ if (config.apple?.bundleId && decoded.bundleId && decoded.bundleId !== config.apple.bundleId) {
1472
+ log.warn("[onesub/webhook/apple] Bundle ID mismatch:", decoded.bundleId, "!==", config.apple.bundleId);
1473
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.BUNDLE_ID_MISMATCH, "Bundle ID mismatch");
1474
+ return;
1475
+ }
1476
+ const work = {
1477
+ decoded,
1478
+ notificationType: payload.notificationType,
1479
+ subtype: payload.subtype
1480
+ };
1481
+ if (webhookQueue) {
1482
+ try {
1483
+ await webhookQueue.enqueue({
1484
+ provider: "apple",
1485
+ // Stable job id feeds queue-level dedup (BullMQ jobId). Random
1486
+ // fallback when Apple omits the UUID — no dedup possible anyway.
1487
+ eventId: typeof payload.notificationUUID === "string" ? payload.notificationUUID : crypto.randomUUID(),
1488
+ payload: work
1489
+ });
1490
+ res.status(200).json({ received: true, queued: true });
1491
+ } catch (err2) {
1492
+ log.error("[onesub/webhook/apple] Failed to enqueue webhook job:", err2);
1493
+ await unmarkWebhookEvent(webhookEventStore, "apple", markedEventId);
1494
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to update subscription");
1404
1495
  }
1496
+ return;
1497
+ }
1498
+ try {
1499
+ await processAppleNotification(work, config, store, purchaseStore);
1500
+ res.status(200).json({ received: true });
1501
+ } catch (err2) {
1502
+ log.error("[onesub/webhook/apple] Store update error:", err2);
1503
+ await unmarkWebhookEvent(webhookEventStore, "apple", markedEventId);
1405
1504
  sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to update subscription");
1406
1505
  }
1407
1506
  }
@@ -1433,7 +1532,117 @@ async function verifyGooglePushToken(req, expectedAudience, expectedServiceAccou
1433
1532
  return false;
1434
1533
  }
1435
1534
  }
1436
- async function handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore) {
1535
+ var GOOGLE_FAILURE_MESSAGES = {
1536
+ voided: {
1537
+ logPrefix: "[onesub/webhook/google] voided notification error:",
1538
+ message: "Failed to process voided notification"
1539
+ },
1540
+ oneTimeProduct: {
1541
+ logPrefix: "[onesub/webhook/google] oneTimeProduct error:",
1542
+ message: "Failed to process oneTimeProduct notification"
1543
+ },
1544
+ subscription: {
1545
+ logPrefix: "[onesub/webhook/google] Error handling notification:",
1546
+ message: "Failed to process notification"
1547
+ }
1548
+ };
1549
+ async function processGoogleNotification(work, config, store, purchaseStore) {
1550
+ if (work.kind === "voided") {
1551
+ const { voided } = work;
1552
+ if (voided.productType === 1) {
1553
+ const existing2 = await store.getByTransactionId(voided.purchaseToken);
1554
+ if (existing2) {
1555
+ const updated = config.refundPolicy === "until_expiry" ? { ...existing2, willRenew: false } : { ...existing2, status: shared.SUBSCRIPTION_STATUS.CANCELED };
1556
+ await store.save(updated);
1557
+ } else {
1558
+ log.warn("[onesub/webhook/google] voided subscription for unknown purchaseToken:", voided.purchaseToken);
1559
+ }
1560
+ } else {
1561
+ const removed = await purchaseStore.deletePurchaseByTransactionId(voided.orderId);
1562
+ if (!removed) {
1563
+ log.warn("[onesub/webhook/google] voided IAP for unknown orderId:", voided.orderId);
1564
+ }
1565
+ }
1566
+ return;
1567
+ }
1568
+ if (work.kind === "oneTimeProduct") {
1569
+ const { notificationType: notificationType2, purchaseToken: purchaseToken2, sku } = work.oneTimeProduct;
1570
+ if (notificationType2 === 1) {
1571
+ log.info("[onesub/webhook/google] oneTimeProduct PURCHASED:", sku);
1572
+ if (config.google?.serviceAccountKey && config.google.packageName) {
1573
+ void acknowledgeGoogleProduct(purchaseToken2, sku, config.google).catch(
1574
+ (err2) => log.warn(`[onesub/webhook/google] oneTimeProduct ack failed for SKU ${sku} \u2014 3-day auto-refund risk:`, err2)
1575
+ );
1576
+ }
1577
+ } else {
1578
+ log.info("[onesub/webhook/google] oneTimeProduct CANCELED (pre-completion):", sku);
1579
+ }
1580
+ return;
1581
+ }
1582
+ const { notificationType, purchaseToken, subscriptionId, packageName } = work.notification;
1583
+ const existing = await store.getByTransactionId(purchaseToken);
1584
+ let finalStatus;
1585
+ if (isGoogleActiveNotification(notificationType)) {
1586
+ finalStatus = shared.SUBSCRIPTION_STATUS.ACTIVE;
1587
+ } else if (isGoogleGracePeriodNotification(notificationType)) {
1588
+ finalStatus = shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
1589
+ } else if (isGoogleOnHoldNotification(notificationType)) {
1590
+ finalStatus = shared.SUBSCRIPTION_STATUS.ON_HOLD;
1591
+ } else if (isGooglePausedNotification(notificationType)) {
1592
+ finalStatus = shared.SUBSCRIPTION_STATUS.PAUSED;
1593
+ } else if (isGooglePriceChangeConfirmedNotification(notificationType)) {
1594
+ finalStatus = shared.SUBSCRIPTION_STATUS.ACTIVE;
1595
+ } else if (isGoogleCanceledNotification(notificationType)) {
1596
+ finalStatus = shared.SUBSCRIPTION_STATUS.CANCELED;
1597
+ } else if (isGoogleExpiredNotification(notificationType)) {
1598
+ finalStatus = shared.SUBSCRIPTION_STATUS.EXPIRED;
1599
+ } else {
1600
+ finalStatus = existing?.status ?? shared.SUBSCRIPTION_STATUS.ACTIVE;
1601
+ }
1602
+ if (isGooglePriceChangeConfirmedNotification(notificationType) && config.google?.onPriceChangeConfirmed) {
1603
+ const hook = config.google.onPriceChangeConfirmed;
1604
+ void Promise.resolve().then(() => hook({ purchaseToken, subscriptionId, packageName })).catch((err2) => log.warn("[onesub/webhook/google] onPriceChangeConfirmed hook failed:", err2));
1605
+ }
1606
+ if (existing) {
1607
+ let updated = { ...existing, status: finalStatus };
1608
+ if (config.google?.serviceAccountKey) {
1609
+ const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1610
+ if (fresh) {
1611
+ const preserveNotificationStatus = isGoogleGracePeriodNotification(notificationType) || isGoogleOnHoldNotification(notificationType);
1612
+ updated = {
1613
+ ...existing,
1614
+ status: preserveNotificationStatus ? finalStatus : fresh.status,
1615
+ expiresAt: fresh.expiresAt,
1616
+ willRenew: fresh.willRenew,
1617
+ autoResumeTime: fresh.autoResumeTime,
1618
+ linkedPurchaseToken: fresh.linkedPurchaseToken ?? existing.linkedPurchaseToken
1619
+ };
1620
+ }
1621
+ }
1622
+ await store.save(updated);
1623
+ } else {
1624
+ if (config.google?.serviceAccountKey) {
1625
+ const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1626
+ if (fresh) {
1627
+ const boundAccountId = fresh.boundAccountId;
1628
+ delete fresh.boundAccountId;
1629
+ if (fresh.linkedPurchaseToken) {
1630
+ const previous = await store.getByTransactionId(fresh.linkedPurchaseToken);
1631
+ fresh.userId = previous ? previous.userId : boundAccountId ?? purchaseToken;
1632
+ if (previous) {
1633
+ log.info(`[onesub/webhook/google] inherited userId ${previous.userId} from linkedPurchaseToken ${fresh.linkedPurchaseToken} \u2192 new token ${purchaseToken}`);
1634
+ }
1635
+ } else {
1636
+ fresh.userId = boundAccountId ?? purchaseToken;
1637
+ }
1638
+ await store.save(fresh);
1639
+ }
1640
+ } else {
1641
+ log.warn("[onesub/webhook/google] Unknown purchase token and no serviceAccountKey to re-fetch:", purchaseToken);
1642
+ }
1643
+ }
1644
+ }
1645
+ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue) {
1437
1646
  if (config.google?.pushAudience) {
1438
1647
  const authenticated = await verifyGooglePushToken(
1439
1648
  req,
@@ -1450,14 +1659,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1450
1659
  sendError(res, 400, shared.ONESUB_ERROR_CODE.MISSING_MESSAGE_DATA, "Missing message.data");
1451
1660
  return;
1452
1661
  }
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
- };
1662
+ let markedEventId;
1461
1663
  if (webhookEventStore && typeof body.message.messageId === "string") {
1462
1664
  const fresh = await webhookEventStore.markIfNew("google", body.message.messageId);
1463
1665
  if (!fresh) {
@@ -1465,7 +1667,9 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1465
1667
  res.status(200).json({ received: true, deduped: true });
1466
1668
  return;
1467
1669
  }
1670
+ markedEventId = body.message.messageId;
1468
1671
  }
1672
+ let work;
1469
1673
  const voided = decodeGoogleVoidedNotification(body);
1470
1674
  if (voided) {
1471
1675
  if (config.google?.packageName && voided.packageName !== config.google.packageName) {
@@ -1473,147 +1677,77 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1473
1677
  sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1474
1678
  return;
1475
1679
  }
1476
- try {
1477
- if (voided.productType === 1) {
1478
- const existing = await store.getByTransactionId(voided.purchaseToken);
1479
- if (existing) {
1480
- const updated = config.refundPolicy === "until_expiry" ? { ...existing, willRenew: false } : { ...existing, status: shared.SUBSCRIPTION_STATUS.CANCELED };
1481
- await store.save(updated);
1482
- } else {
1483
- log.warn("[onesub/webhook/google] voided subscription for unknown purchaseToken:", voided.purchaseToken);
1484
- }
1485
- } else {
1486
- const removed = await purchaseStore.deletePurchaseByTransactionId(voided.orderId);
1487
- if (!removed) {
1488
- log.warn("[onesub/webhook/google] voided IAP for unknown orderId:", voided.orderId);
1489
- }
1680
+ work = { kind: "voided", voided };
1681
+ } else {
1682
+ const oneTimeProduct = decodeGoogleOneTimeProductNotification(body);
1683
+ if (oneTimeProduct) {
1684
+ if (config.google?.packageName && oneTimeProduct.packageName !== config.google.packageName) {
1685
+ log.warn("[onesub/webhook/google] oneTimeProduct package name mismatch:", oneTimeProduct.packageName, "!==", config.google.packageName);
1686
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1687
+ return;
1490
1688
  }
1491
- res.status(200).json({ received: true });
1492
- } catch (err) {
1493
- log.error("[onesub/webhook/google] voided notification error:", err);
1494
- await unmarkEvent();
1495
- sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process voided notification");
1689
+ work = { kind: "oneTimeProduct", oneTimeProduct };
1690
+ } else {
1691
+ const notification = decodeGoogleNotification(body);
1692
+ if (!notification) {
1693
+ res.status(200).json({ received: true });
1694
+ return;
1695
+ }
1696
+ if (config.google?.packageName && notification.packageName !== config.google.packageName) {
1697
+ log.warn("[onesub/webhook/google] Package name mismatch:", notification.packageName, "!==", config.google.packageName);
1698
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1699
+ return;
1700
+ }
1701
+ work = { kind: "subscription", notification };
1496
1702
  }
1497
- return;
1498
1703
  }
1499
- const oneTimeProduct = decodeGoogleOneTimeProductNotification(body);
1500
- if (oneTimeProduct) {
1501
- if (config.google?.packageName && oneTimeProduct.packageName !== config.google.packageName) {
1502
- log.warn("[onesub/webhook/google] oneTimeProduct package name mismatch:", oneTimeProduct.packageName, "!==", config.google.packageName);
1503
- sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1504
- return;
1505
- }
1704
+ if (webhookQueue) {
1506
1705
  try {
1507
- const { notificationType: notificationType2, purchaseToken: purchaseToken2, sku } = oneTimeProduct;
1508
- if (notificationType2 === 1) {
1509
- log.info("[onesub/webhook/google] oneTimeProduct PURCHASED:", sku);
1510
- if (config.google?.serviceAccountKey && config.google.packageName) {
1511
- void acknowledgeGoogleProduct(purchaseToken2, sku, config.google).catch(
1512
- (err) => log.warn(`[onesub/webhook/google] oneTimeProduct ack failed for SKU ${sku} \u2014 3-day auto-refund risk:`, err)
1513
- );
1514
- }
1515
- } else {
1516
- log.info("[onesub/webhook/google] oneTimeProduct CANCELED (pre-completion):", sku);
1517
- }
1518
- res.status(200).json({ received: true });
1519
- } catch (err) {
1520
- log.error("[onesub/webhook/google] oneTimeProduct error:", err);
1521
- await unmarkEvent();
1522
- sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process oneTimeProduct notification");
1706
+ await webhookQueue.enqueue({
1707
+ provider: "google",
1708
+ // Stable job id feeds queue-level dedup (BullMQ jobId). Random
1709
+ // fallback when the push lacks a messageId — no dedup possible anyway.
1710
+ eventId: typeof body.message.messageId === "string" ? body.message.messageId : crypto.randomUUID(),
1711
+ payload: work
1712
+ });
1713
+ res.status(200).json({ received: true, queued: true });
1714
+ } catch (err2) {
1715
+ log.error("[onesub/webhook/google] Failed to enqueue webhook job:", err2);
1716
+ await unmarkWebhookEvent(webhookEventStore, "google", markedEventId);
1717
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, GOOGLE_FAILURE_MESSAGES[work.kind].message);
1523
1718
  }
1524
1719
  return;
1525
1720
  }
1526
- const notification = decodeGoogleNotification(body);
1527
- if (!notification) {
1528
- res.status(200).json({ received: true });
1529
- return;
1530
- }
1531
- const { notificationType, purchaseToken, subscriptionId, packageName } = notification;
1532
- if (config.google?.packageName && packageName !== config.google.packageName) {
1533
- log.warn("[onesub/webhook/google] Package name mismatch:", packageName, "!==", config.google.packageName);
1534
- sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1535
- return;
1536
- }
1537
1721
  try {
1538
- const existing = await store.getByTransactionId(purchaseToken);
1539
- let finalStatus;
1540
- if (isGoogleActiveNotification(notificationType)) {
1541
- finalStatus = shared.SUBSCRIPTION_STATUS.ACTIVE;
1542
- } else if (isGoogleGracePeriodNotification(notificationType)) {
1543
- finalStatus = shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
1544
- } else if (isGoogleOnHoldNotification(notificationType)) {
1545
- finalStatus = shared.SUBSCRIPTION_STATUS.ON_HOLD;
1546
- } else if (isGooglePausedNotification(notificationType)) {
1547
- finalStatus = shared.SUBSCRIPTION_STATUS.PAUSED;
1548
- } else if (isGooglePriceChangeConfirmedNotification(notificationType)) {
1549
- finalStatus = shared.SUBSCRIPTION_STATUS.ACTIVE;
1550
- } else if (isGoogleCanceledNotification(notificationType)) {
1551
- finalStatus = shared.SUBSCRIPTION_STATUS.CANCELED;
1552
- } else if (isGoogleExpiredNotification(notificationType)) {
1553
- finalStatus = shared.SUBSCRIPTION_STATUS.EXPIRED;
1554
- } else {
1555
- finalStatus = existing?.status ?? shared.SUBSCRIPTION_STATUS.ACTIVE;
1556
- }
1557
- if (isGooglePriceChangeConfirmedNotification(notificationType) && config.google?.onPriceChangeConfirmed) {
1558
- const hook = config.google.onPriceChangeConfirmed;
1559
- void Promise.resolve().then(() => hook({ purchaseToken, subscriptionId, packageName })).catch((err) => log.warn("[onesub/webhook/google] onPriceChangeConfirmed hook failed:", err));
1560
- }
1561
- if (existing) {
1562
- let updated = { ...existing, status: finalStatus };
1563
- if (config.google?.serviceAccountKey) {
1564
- const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1565
- if (fresh) {
1566
- const preserveNotificationStatus = isGoogleGracePeriodNotification(notificationType) || isGoogleOnHoldNotification(notificationType);
1567
- updated = {
1568
- ...existing,
1569
- status: preserveNotificationStatus ? finalStatus : fresh.status,
1570
- expiresAt: fresh.expiresAt,
1571
- willRenew: fresh.willRenew,
1572
- autoResumeTime: fresh.autoResumeTime,
1573
- linkedPurchaseToken: fresh.linkedPurchaseToken ?? existing.linkedPurchaseToken
1574
- };
1575
- }
1576
- }
1577
- await store.save(updated);
1578
- } else {
1579
- if (config.google?.serviceAccountKey) {
1580
- const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1581
- if (fresh) {
1582
- const boundAccountId = fresh.boundAccountId;
1583
- delete fresh.boundAccountId;
1584
- if (fresh.linkedPurchaseToken) {
1585
- const previous = await store.getByTransactionId(fresh.linkedPurchaseToken);
1586
- fresh.userId = previous ? previous.userId : boundAccountId ?? purchaseToken;
1587
- if (previous) {
1588
- log.info(`[onesub/webhook/google] inherited userId ${previous.userId} from linkedPurchaseToken ${fresh.linkedPurchaseToken} \u2192 new token ${purchaseToken}`);
1589
- }
1590
- } else {
1591
- fresh.userId = boundAccountId ?? purchaseToken;
1592
- }
1593
- await store.save(fresh);
1594
- }
1595
- } else {
1596
- log.warn("[onesub/webhook/google] Unknown purchase token and no serviceAccountKey to re-fetch:", purchaseToken);
1597
- }
1598
- }
1722
+ await processGoogleNotification(work, config, store, purchaseStore);
1599
1723
  res.status(200).json({ received: true });
1600
- } catch (err) {
1601
- log.error("[onesub/webhook/google] Error handling notification:", err);
1602
- await unmarkEvent();
1603
- sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process notification");
1724
+ } catch (err2) {
1725
+ const { logPrefix, message } = GOOGLE_FAILURE_MESSAGES[work.kind];
1726
+ log.error(logPrefix, err2);
1727
+ await unmarkWebhookEvent(webhookEventStore, "google", markedEventId);
1728
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, message);
1604
1729
  }
1605
1730
  }
1606
1731
 
1607
1732
  // src/routes/webhook.ts
1608
- function createWebhookRouter(config, store, purchaseStore, webhookEventStore) {
1733
+ function createWebhookRouter(config, store, purchaseStore, webhookEventStore, webhookQueue) {
1734
+ if (webhookQueue) {
1735
+ webhookQueue.setHandler(async (job) => {
1736
+ if (job.provider === "apple") {
1737
+ await processAppleNotification(job.payload, config, store, purchaseStore);
1738
+ } else {
1739
+ await processGoogleNotification(job.payload, config, store, purchaseStore);
1740
+ }
1741
+ });
1742
+ }
1609
1743
  const router = express.Router();
1610
1744
  router.post(
1611
1745
  shared.ROUTES.WEBHOOK_APPLE,
1612
- (req, res) => handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore)
1746
+ (req, res) => handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue)
1613
1747
  );
1614
1748
  router.post(
1615
1749
  shared.ROUTES.WEBHOOK_GOOGLE,
1616
- (req, res) => handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore)
1750
+ (req, res) => handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue)
1617
1751
  );
1618
1752
  return router;
1619
1753
  }
@@ -1635,12 +1769,12 @@ function createPurchaseRouter(config, purchaseStore) {
1635
1769
  let body;
1636
1770
  try {
1637
1771
  body = validatePurchaseSchema.parse(req.body);
1638
- } catch (err) {
1639
- if (err instanceof zod.z.ZodError) {
1640
- sendZodError(res, err, NO_PURCHASE);
1772
+ } catch (err2) {
1773
+ if (err2 instanceof zod.z.ZodError) {
1774
+ sendZodError(res, err2, NO_PURCHASE);
1641
1775
  return;
1642
1776
  }
1643
- throw err;
1777
+ throw err2;
1644
1778
  }
1645
1779
  const { platform, receipt, userId, productId, type } = body;
1646
1780
  try {
@@ -1759,8 +1893,8 @@ function createPurchaseRouter(config, purchaseStore) {
1759
1893
  action
1760
1894
  };
1761
1895
  res.status(200).json(response);
1762
- } catch (err) {
1763
- log.error("[onesub/purchase/validate] Unexpected error:", err);
1896
+ } catch (err2) {
1897
+ log.error("[onesub/purchase/validate] Unexpected error:", err2);
1764
1898
  sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, "Internal server error during purchase validation", NO_PURCHASE);
1765
1899
  }
1766
1900
  });
@@ -1768,12 +1902,12 @@ function createPurchaseRouter(config, purchaseStore) {
1768
1902
  let query;
1769
1903
  try {
1770
1904
  query = purchaseStatusQuerySchema.parse(req.query);
1771
- } catch (err) {
1772
- if (err instanceof zod.z.ZodError) {
1773
- sendZodError(res, err, { purchases: [] });
1905
+ } catch (err2) {
1906
+ if (err2 instanceof zod.z.ZodError) {
1907
+ sendZodError(res, err2, { purchases: [] });
1774
1908
  return;
1775
1909
  }
1776
- throw err;
1910
+ throw err2;
1777
1911
  }
1778
1912
  const { userId, productId } = query;
1779
1913
  try {
@@ -1783,8 +1917,8 @@ function createPurchaseRouter(config, purchaseStore) {
1783
1917
  }
1784
1918
  const response = { purchases };
1785
1919
  res.status(200).json(response);
1786
- } catch (err) {
1787
- log.error("[onesub/purchase/status] Store error:", err);
1920
+ } catch (err2) {
1921
+ log.error("[onesub/purchase/status] Store error:", err2);
1788
1922
  sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error", { purchases: [] });
1789
1923
  }
1790
1924
  });
@@ -1845,8 +1979,8 @@ function createEntitlementRouter(config, store, purchaseStore) {
1845
1979
  const status = await evaluateEntitlement(userId, entitlement, store, purchaseStore);
1846
1980
  const response = { id, ...status };
1847
1981
  res.status(200).json(response);
1848
- } catch (err) {
1849
- log.error("[onesub/entitlement] evaluation error:", err);
1982
+ } catch (err2) {
1983
+ log.error("[onesub/entitlement] evaluation error:", err2);
1850
1984
  sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
1851
1985
  }
1852
1986
  });
@@ -1869,13 +2003,19 @@ function createEntitlementRouter(config, store, purchaseStore) {
1869
2003
  entitlements: Object.fromEntries(entries)
1870
2004
  };
1871
2005
  res.status(200).json(response);
1872
- } catch (err) {
1873
- log.error("[onesub/entitlements] evaluation error:", err);
2006
+ } catch (err2) {
2007
+ log.error("[onesub/entitlements] evaluation error:", err2);
1874
2008
  sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error", { entitlements: {} });
1875
2009
  }
1876
2010
  });
1877
2011
  return router;
1878
2012
  }
2013
+ function secretsEqual(provided, expected) {
2014
+ const providedBuf = Buffer.from(provided, "utf8");
2015
+ const expectedBuf = Buffer.from(expected, "utf8");
2016
+ if (providedBuf.length !== expectedBuf.length) return false;
2017
+ return crypto.timingSafeEqual(providedBuf, expectedBuf);
2018
+ }
1879
2019
 
1880
2020
  // src/routes/admin.ts
1881
2021
  var ADMIN_SECRET_HEADER = "x-admin-secret";
@@ -1885,7 +2025,7 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
1885
2025
  const adminSecret = config.adminSecret;
1886
2026
  const adminAuth = (req, res, next) => {
1887
2027
  const provided = req.headers[ADMIN_SECRET_HEADER];
1888
- if (typeof provided !== "string" || provided !== adminSecret) {
2028
+ if (typeof provided !== "string" || !secretsEqual(provided, adminSecret)) {
1889
2029
  sendError(res, 401, shared.ONESUB_ERROR_CODE.INVALID_ADMIN_SECRET, "INVALID_ADMIN_SECRET");
1890
2030
  return;
1891
2031
  }
@@ -1916,12 +2056,12 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
1916
2056
  let body;
1917
2057
  try {
1918
2058
  body = transferSchema.parse(req.body);
1919
- } catch (err) {
1920
- if (err instanceof zod.z.ZodError) {
1921
- sendZodError(res, err);
2059
+ } catch (err2) {
2060
+ if (err2 instanceof zod.z.ZodError) {
2061
+ sendZodError(res, err2);
1922
2062
  return;
1923
2063
  }
1924
- throw err;
2064
+ throw err2;
1925
2065
  }
1926
2066
  const existing = await purchaseStore.getPurchaseByTransactionId(body.transactionId);
1927
2067
  if (!existing) {
@@ -1947,12 +2087,12 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
1947
2087
  let body;
1948
2088
  try {
1949
2089
  body = grantSchema.parse(req.body);
1950
- } catch (err) {
1951
- if (err instanceof zod.z.ZodError) {
1952
- sendZodError(res, err);
2090
+ } catch (err2) {
2091
+ if (err2 instanceof zod.z.ZodError) {
2092
+ sendZodError(res, err2);
1953
2093
  return;
1954
2094
  }
1955
- throw err;
2095
+ throw err2;
1956
2096
  }
1957
2097
  const transactionId = body.transactionId ?? `admin_grant_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
1958
2098
  const purchase = {
@@ -1988,12 +2128,12 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
1988
2128
  let query;
1989
2129
  try {
1990
2130
  query = listQuerySchema.parse(req.query);
1991
- } catch (err) {
1992
- if (err instanceof zod.z.ZodError) {
1993
- sendZodError(res, err);
2131
+ } catch (err2) {
2132
+ if (err2 instanceof zod.z.ZodError) {
2133
+ sendZodError(res, err2);
1994
2134
  return;
1995
2135
  }
1996
- throw err;
2136
+ throw err2;
1997
2137
  }
1998
2138
  try {
1999
2139
  const result = await store.listFiltered(query);
@@ -2004,8 +2144,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2004
2144
  offset: result.offset
2005
2145
  };
2006
2146
  res.status(200).json(response);
2007
- } catch (err) {
2008
- sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "list error");
2147
+ } catch (err2) {
2148
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "list error");
2009
2149
  }
2010
2150
  });
2011
2151
  const detailParamsSchema = zod.z.object({
@@ -2026,8 +2166,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2026
2166
  return;
2027
2167
  }
2028
2168
  res.status(200).json(sub);
2029
- } catch (err) {
2030
- sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "detail error");
2169
+ } catch (err2) {
2170
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "detail error");
2031
2171
  }
2032
2172
  });
2033
2173
  const customerParamsSchema = zod.z.object({
@@ -2060,8 +2200,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2060
2200
  ...entitlements ? { entitlements } : {}
2061
2201
  };
2062
2202
  res.status(200).json(response);
2063
- } catch (err) {
2064
- sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "customer error");
2203
+ } catch (err2) {
2204
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "customer error");
2065
2205
  }
2066
2206
  });
2067
2207
  const syncAppleParamsSchema = zod.z.object({
@@ -2092,8 +2232,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2092
2232
  fresh.userId = existing?.userId ?? originalTransactionId;
2093
2233
  await store.save(fresh);
2094
2234
  res.status(200).json({ ok: true, subscription: fresh });
2095
- } catch (err) {
2096
- sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, err.message ?? "sync error");
2235
+ } catch (err2) {
2236
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, err2.message ?? "sync error");
2097
2237
  }
2098
2238
  });
2099
2239
  if (webhookQueue?.listDeadLetters) {
@@ -2101,8 +2241,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2101
2241
  try {
2102
2242
  const items = await webhookQueue.listDeadLetters();
2103
2243
  res.status(200).json({ items });
2104
- } catch (err) {
2105
- sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "list error");
2244
+ } catch (err2) {
2245
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "list error");
2106
2246
  }
2107
2247
  });
2108
2248
  }
@@ -2119,8 +2259,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2119
2259
  try {
2120
2260
  await webhookQueue.replayDeadLetter(params.id);
2121
2261
  res.status(200).json({ ok: true });
2122
- } catch (err) {
2123
- sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "replay error");
2262
+ } catch (err2) {
2263
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "replay error");
2124
2264
  }
2125
2265
  });
2126
2266
  }
@@ -2133,7 +2273,7 @@ function createMetricsRouter(config, store, purchaseStore) {
2133
2273
  const adminSecret = config.adminSecret;
2134
2274
  router.use("/onesub/metrics", (req, res, next) => {
2135
2275
  const provided = req.headers[ADMIN_SECRET_HEADER2];
2136
- if (typeof provided !== "string" || provided !== adminSecret) {
2276
+ if (typeof provided !== "string" || !secretsEqual(provided, adminSecret)) {
2137
2277
  sendError(res, 401, shared.ONESUB_ERROR_CODE.INVALID_ADMIN_SECRET, "INVALID_ADMIN_SECRET");
2138
2278
  return;
2139
2279
  }
@@ -2184,8 +2324,8 @@ function createMetricsRouter(config, store, purchaseStore) {
2184
2324
  byPlatform
2185
2325
  };
2186
2326
  res.status(200).json(response);
2187
- } catch (err) {
2188
- log.error("[onesub/metrics/active] error:", err);
2327
+ } catch (err2) {
2328
+ log.error("[onesub/metrics/active] error:", err2);
2189
2329
  sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2190
2330
  }
2191
2331
  });
@@ -2194,6 +2334,8 @@ function createMetricsRouter(config, store, purchaseStore) {
2194
2334
  to: zod.z.string().min(1),
2195
2335
  groupBy: zod.z.enum(["none", "day"]).optional()
2196
2336
  });
2337
+ const MAX_DAY_BUCKET_SPAN_DAYS = 366;
2338
+ const MAX_DAY_BUCKET_SPAN_MS = MAX_DAY_BUCKET_SPAN_DAYS * 864e5;
2197
2339
  function parseRange(req) {
2198
2340
  const parsed = rangeSchema.safeParse(req.query);
2199
2341
  if (!parsed.success) return { error: "from and to are required (ISO 8601)" };
@@ -2203,7 +2345,13 @@ function createMetricsRouter(config, store, purchaseStore) {
2203
2345
  return { error: "from / to must be ISO 8601 timestamps" };
2204
2346
  }
2205
2347
  if (fromMs > toMs) return { error: "from must be \u2264 to" };
2206
- return { fromMs, toMs, groupBy: parsed.data.groupBy ?? "none" };
2348
+ const groupBy = parsed.data.groupBy ?? "none";
2349
+ if (groupBy === "day" && toMs - fromMs > MAX_DAY_BUCKET_SPAN_MS) {
2350
+ return {
2351
+ error: `groupBy=day supports a range of at most ${MAX_DAY_BUCKET_SPAN_DAYS} days \u2014 narrow the from/to window or omit groupBy`
2352
+ };
2353
+ }
2354
+ return { fromMs, toMs, groupBy };
2207
2355
  }
2208
2356
  function utcDateKey(ms) {
2209
2357
  const d = new Date(ms);
@@ -2256,8 +2404,8 @@ function createMetricsRouter(config, store, purchaseStore) {
2256
2404
  ...buckets ? { buckets } : {}
2257
2405
  };
2258
2406
  res.status(200).json(response);
2259
- } catch (err) {
2260
- log.error("[onesub/metrics/started] error:", err);
2407
+ } catch (err2) {
2408
+ log.error("[onesub/metrics/started] error:", err2);
2261
2409
  sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2262
2410
  }
2263
2411
  });
@@ -2295,8 +2443,8 @@ function createMetricsRouter(config, store, purchaseStore) {
2295
2443
  ...buckets ? { buckets } : {}
2296
2444
  };
2297
2445
  res.status(200).json(response);
2298
- } catch (err) {
2299
- log.error("[onesub/metrics/expired] error:", err);
2446
+ } catch (err2) {
2447
+ log.error("[onesub/metrics/expired] error:", err2);
2300
2448
  sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2301
2449
  }
2302
2450
  });
@@ -2334,8 +2482,8 @@ function createMetricsRouter(config, store, purchaseStore) {
2334
2482
  ...buckets ? { buckets } : {}
2335
2483
  };
2336
2484
  res.status(200).json(response);
2337
- } catch (err) {
2338
- log.error("[onesub/metrics/purchases/started] error:", err);
2485
+ } catch (err2) {
2486
+ log.error("[onesub/metrics/purchases/started] error:", err2);
2339
2487
  sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2340
2488
  }
2341
2489
  });
@@ -2354,7 +2502,7 @@ function createAppleOfferRouter(config) {
2354
2502
  router.post("/onesub/apple/offer-signature", async (req, res) => {
2355
2503
  if (config.adminSecret) {
2356
2504
  const provided = req.headers[OFFER_SECRET_HEADER];
2357
- if (typeof provided !== "string" || provided !== config.adminSecret) {
2505
+ if (typeof provided !== "string" || !secretsEqual(provided, config.adminSecret)) {
2358
2506
  sendError(res, 401, shared.ONESUB_ERROR_CODE.UNAUTHORIZED, "Unauthorized");
2359
2507
  return;
2360
2508
  }
@@ -2362,12 +2510,12 @@ function createAppleOfferRouter(config) {
2362
2510
  let body;
2363
2511
  try {
2364
2512
  body = offerBodySchema.parse(req.body);
2365
- } catch (err) {
2366
- if (err instanceof zod.z.ZodError) {
2367
- sendZodError(res, err);
2513
+ } catch (err2) {
2514
+ if (err2 instanceof zod.z.ZodError) {
2515
+ sendZodError(res, err2);
2368
2516
  return;
2369
2517
  }
2370
- throw err;
2518
+ throw err2;
2371
2519
  }
2372
2520
  if (!apple.bundleId) {
2373
2521
  sendError(res, 400, shared.ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "config.apple.bundleId is required for offer signing");
@@ -2384,8 +2532,8 @@ function createAppleOfferRouter(config) {
2384
2532
  apple
2385
2533
  );
2386
2534
  res.status(200).json(result);
2387
- } catch (err) {
2388
- sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, err.message ?? "Offer signing failed");
2535
+ } catch (err2) {
2536
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, err2.message ?? "Offer signing failed");
2389
2537
  }
2390
2538
  });
2391
2539
  return router;
@@ -2449,6 +2597,17 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_onesub_purchases_non_consumable
2449
2597
  `.trim();
2450
2598
 
2451
2599
  // src/stores/postgres.ts
2600
+ async function createPgPool(connectionString, label) {
2601
+ const pg = await import('pg').catch(() => {
2602
+ throw new Error(`[onesub] ${label} requires the \`pg\` package. Run: npm install pg`);
2603
+ });
2604
+ const Pool = pg.default?.Pool ?? pg.Pool;
2605
+ const pool = new Pool({ connectionString, max: 10 });
2606
+ pool.on("error", (err2) => {
2607
+ log.error(`[onesub] ${label} pool error (idle client):`, err2);
2608
+ });
2609
+ return pool;
2610
+ }
2452
2611
  var PostgresSubscriptionStore = class {
2453
2612
  constructor(connectionString) {
2454
2613
  this.connectionString = connectionString;
@@ -2458,19 +2617,7 @@ var PostgresSubscriptionStore = class {
2458
2617
  poolPromise = null;
2459
2618
  getPool() {
2460
2619
  if (!this.poolPromise) {
2461
- this.poolPromise = (async () => {
2462
- const pg = await import('pg').catch(() => {
2463
- throw new Error(
2464
- "[onesub] PostgresSubscriptionStore requires the `pg` package. Run: npm install pg"
2465
- );
2466
- });
2467
- const Pool = pg.default?.Pool ?? pg.Pool;
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;
2473
- })();
2620
+ this.poolPromise = createPgPool(this.connectionString, "PostgresSubscriptionStore");
2474
2621
  }
2475
2622
  return this.poolPromise;
2476
2623
  }
@@ -2634,19 +2781,7 @@ var PostgresPurchaseStore = class {
2634
2781
  poolPromise = null;
2635
2782
  getPool() {
2636
2783
  if (!this.poolPromise) {
2637
- this.poolPromise = (async () => {
2638
- const pg = await import('pg').catch(() => {
2639
- throw new Error(
2640
- "[onesub] PostgresPurchaseStore requires the `pg` package. Run: npm install pg"
2641
- );
2642
- });
2643
- const Pool = pg.default?.Pool ?? pg.Pool;
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;
2649
- })();
2784
+ this.poolPromise = createPgPool(this.connectionString, "PostgresPurchaseStore");
2650
2785
  }
2651
2786
  return this.poolPromise;
2652
2787
  }
@@ -2683,9 +2818,9 @@ var PostgresPurchaseStore = class {
2683
2818
  );
2684
2819
  const owner = existing.rows[0]?.user_id;
2685
2820
  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;
2821
+ const err2 = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2822
+ err2.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2823
+ throw err2;
2689
2824
  }
2690
2825
  }
2691
2826
  async getPurchasesByUserId(userId) {
@@ -2786,6 +2921,7 @@ function rowToPurchaseInfo(row) {
2786
2921
 
2787
2922
  // src/stores/redis.ts
2788
2923
  var SUB_TX_PREFIX = "onesub:sub:tx:";
2924
+ var SUB_OWNER_PREFIX = "onesub:sub:owner:";
2789
2925
  var SUB_USER_PREFIX = "onesub:sub:user:";
2790
2926
  var SUB_ALL_SORTED = "onesub:sub:all:sorted";
2791
2927
  var PUR_TX_PREFIX = "onesub:purchase:tx:";
@@ -2800,11 +2936,16 @@ var RedisSubscriptionStore = class {
2800
2936
  async save(sub) {
2801
2937
  const score = Date.now();
2802
2938
  const txKey = SUB_TX_PREFIX + sub.originalTransactionId;
2939
+ const ownerKey = SUB_OWNER_PREFIX + sub.originalTransactionId;
2803
2940
  const userKey = SUB_USER_PREFIX + sub.userId;
2804
- const prevRaw = await this.redis.get(txKey);
2805
- const prevUserId = prevRaw ? JSON.parse(prevRaw).userId : null;
2941
+ let prevUserId = await this.redis.get(ownerKey);
2942
+ if (prevUserId === null) {
2943
+ const prevRaw = await this.redis.get(txKey);
2944
+ prevUserId = prevRaw ? JSON.parse(prevRaw).userId : null;
2945
+ }
2806
2946
  const pipeline = this.redis.multi();
2807
2947
  pipeline.set(txKey, JSON.stringify(sub));
2948
+ pipeline.set(ownerKey, sub.userId);
2808
2949
  if (prevUserId !== null && prevUserId !== sub.userId) {
2809
2950
  pipeline.zrem(SUB_USER_PREFIX + prevUserId, sub.originalTransactionId);
2810
2951
  }
@@ -2885,9 +3026,9 @@ var RedisPurchaseStore = class {
2885
3026
  const existing = await this.redis.get(txKey);
2886
3027
  const owner = existing ? JSON.parse(existing).userId : null;
2887
3028
  if (owner !== null && owner !== purchase.userId) {
2888
- const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2889
- err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2890
- throw err;
3029
+ const err2 = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
3030
+ err2.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
3031
+ throw err2;
2891
3032
  }
2892
3033
  }
2893
3034
  const score = Date.parse(purchase.purchasedAt) || Date.now();
@@ -3007,50 +3148,6 @@ var RedisWebhookEventStore = class {
3007
3148
  }
3008
3149
  };
3009
3150
 
3010
- // src/webhook-events.ts
3011
- var DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60;
3012
- var InMemoryWebhookEventStore = class {
3013
- constructor(ttlSeconds = DEFAULT_TTL_SECONDS) {
3014
- this.ttlSeconds = ttlSeconds;
3015
- }
3016
- ttlSeconds;
3017
- seen = /* @__PURE__ */ new Map();
3018
- async markIfNew(provider, eventId) {
3019
- this.evictExpired();
3020
- const key = `${provider}:${eventId}`;
3021
- if (this.seen.has(key)) return false;
3022
- this.seen.set(key, Date.now() + this.ttlSeconds * 1e3);
3023
- return true;
3024
- }
3025
- async unmark(provider, eventId) {
3026
- this.seen.delete(`${provider}:${eventId}`);
3027
- }
3028
- evictExpired() {
3029
- const now = Date.now();
3030
- for (const [key, expiresAt] of this.seen) {
3031
- if (expiresAt < now) this.seen.delete(key);
3032
- }
3033
- }
3034
- };
3035
- var CacheWebhookEventStore = class {
3036
- constructor(cache, ttlSeconds = DEFAULT_TTL_SECONDS) {
3037
- this.cache = cache;
3038
- this.ttlSeconds = ttlSeconds;
3039
- }
3040
- cache;
3041
- ttlSeconds;
3042
- async markIfNew(provider, eventId) {
3043
- const key = `webhook:event:${provider}:${eventId}`;
3044
- const existing = await this.cache.get(key);
3045
- if (existing) return false;
3046
- await this.cache.set(key, "1", this.ttlSeconds);
3047
- return true;
3048
- }
3049
- async unmark(provider, eventId) {
3050
- await this.cache.del(`webhook:event:${provider}:${eventId}`);
3051
- }
3052
- };
3053
-
3054
3151
  // src/webhook-queue.ts
3055
3152
  var InProcessWebhookQueue = class {
3056
3153
  handler = null;
@@ -3075,6 +3172,13 @@ var BullMQWebhookQueue = class {
3075
3172
  queuePromise = null;
3076
3173
  workerPromise = null;
3077
3174
  handler = null;
3175
+ /**
3176
+ * Captured worker-startup failure (e.g. `bullmq` not installed). `setHandler`
3177
+ * is synchronous, so it can't reject — the error is surfaced on the next
3178
+ * `enqueue()` instead of becoming an unhandled rejection that kills the
3179
+ * process.
3180
+ */
3181
+ workerStartupError = null;
3078
3182
  constructor(opts) {
3079
3183
  this.connection = opts.connection;
3080
3184
  this.queueName = opts.queueName ?? "onesub-webhooks";
@@ -3091,7 +3195,11 @@ var BullMQWebhookQueue = class {
3091
3195
  if (!this.queuePromise) {
3092
3196
  this.queuePromise = (async () => {
3093
3197
  const { Queue } = await this.getBullMQ();
3094
- return new Queue(this.queueName, { connection: this.connection });
3198
+ const queue = new Queue(this.queueName, { connection: this.connection });
3199
+ queue.on?.("error", (err2) => {
3200
+ log.error("[onesub] BullMQ queue error:", err2);
3201
+ });
3202
+ return queue;
3095
3203
  })();
3096
3204
  }
3097
3205
  return this.queuePromise;
@@ -3101,7 +3209,7 @@ var BullMQWebhookQueue = class {
3101
3209
  if (!this.workerPromise) {
3102
3210
  this.workerPromise = (async () => {
3103
3211
  const { Worker } = await this.getBullMQ();
3104
- return new Worker(
3212
+ const worker = new Worker(
3105
3213
  this.queueName,
3106
3214
  async (job) => {
3107
3215
  if (!this.handler) throw new Error("[onesub] handler not set");
@@ -3112,10 +3220,19 @@ var BullMQWebhookQueue = class {
3112
3220
  concurrency: this.concurrency
3113
3221
  }
3114
3222
  );
3223
+ worker.on?.("error", (err2) => {
3224
+ log.error("[onesub] BullMQ worker error:", err2);
3225
+ });
3226
+ return worker;
3115
3227
  })();
3228
+ this.workerPromise.catch((err2) => {
3229
+ this.workerStartupError = err2 instanceof Error ? err2 : new Error(String(err2));
3230
+ log.error("[onesub] BullMQ worker failed to start:", err2);
3231
+ });
3116
3232
  }
3117
3233
  }
3118
3234
  async enqueue(job) {
3235
+ if (this.workerStartupError) throw this.workerStartupError;
3119
3236
  const queue = await this.getQueue();
3120
3237
  await queue.add("webhook", job, {
3121
3238
  attempts: this.maxAttempts,
@@ -3144,26 +3261,42 @@ var BullMQWebhookQueue = class {
3144
3261
  }
3145
3262
  async close() {
3146
3263
  if (this.workerPromise) {
3147
- const worker = await this.workerPromise;
3148
- await worker.close();
3264
+ const worker = await this.workerPromise.catch(() => null);
3265
+ if (worker) await worker.close();
3149
3266
  }
3150
3267
  if (this.queuePromise) {
3151
- const queue = await this.queuePromise;
3152
- await queue.close();
3268
+ const queue = await this.queuePromise.catch(() => null);
3269
+ if (queue) await queue.close();
3153
3270
  }
3154
3271
  }
3155
3272
  };
3273
+ var ERROR_CONTENT = {
3274
+ "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }
3275
+ };
3276
+ var err = (description) => ({ description, content: ERROR_CONTENT });
3277
+ var ADMIN_SECRET_PARAM = {
3278
+ in: "header",
3279
+ name: "X-Admin-Secret",
3280
+ required: true,
3281
+ schema: { type: "string" }
3282
+ };
3283
+ var METRICS_RANGE_PARAMS = [
3284
+ { in: "query", name: "from", required: true, schema: { type: "string", format: "date-time" }, description: "Window start (ISO 8601)." },
3285
+ { in: "query", name: "to", required: true, schema: { type: "string", format: "date-time" }, description: "Window end (ISO 8601)." },
3286
+ { in: "query", name: "groupBy", schema: { type: "string", enum: ["none", "day"] }, description: "'day' adds a zero-filled daily `buckets` series to the response." }
3287
+ ];
3156
3288
  var ONESUB_OPENAPI = {
3157
3289
  openapi: "3.1.0",
3158
3290
  info: {
3159
3291
  title: "onesub HTTP API",
3160
3292
  version: "1.0.0",
3161
- description: "Receipt validation, subscription status, webhooks, and admin endpoints exposed by `createOneSubMiddleware`."
3293
+ description: "Receipt validation, subscription status, one-time purchases, entitlements, webhooks, metrics, and admin endpoints exposed by `createOneSubMiddleware`."
3162
3294
  },
3163
3295
  servers: [
3164
3296
  { url: "http://localhost:4100", description: "Local dev (examples/server)" }
3165
3297
  ],
3166
3298
  paths: {
3299
+ // ── public: subscriptions ────────────────────────────────────────────────
3167
3300
  [shared.ROUTES.VALIDATE]: {
3168
3301
  post: {
3169
3302
  summary: "Validate an Apple/Google receipt and persist subscription state.",
@@ -3173,7 +3306,9 @@ var ONESUB_OPENAPI = {
3173
3306
  },
3174
3307
  responses: {
3175
3308
  200: { description: "Validation succeeded; subscription state persisted.", content: { "application/json": { schema: { $ref: "#/components/schemas/ValidateResponse" } } } },
3176
- 400: { description: "Bad request \u2014 missing fields, malformed receipt, package mismatch." }
3309
+ 400: err("Bad request \u2014 missing fields, malformed receipt, package mismatch."),
3310
+ 409: err("Receipt is account-bound to a different userId (TRANSACTION_BELONGS_TO_OTHER_USER)."),
3311
+ 422: err("Receipt validation failed (RECEIPT_VALIDATION_FAILED).")
3177
3312
  }
3178
3313
  }
3179
3314
  },
@@ -3182,17 +3317,49 @@ var ONESUB_OPENAPI = {
3182
3317
  summary: "Fetch the most recent subscription state for a user.",
3183
3318
  parameters: [{ in: "query", name: "userId", required: true, schema: { type: "string" } }],
3184
3319
  responses: {
3185
- 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/StatusResponse" } } } }
3320
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/StatusResponse" } } } },
3321
+ 400: err("Missing userId or userId too long (INVALID_INPUT / USER_ID_TOO_LONG).")
3322
+ }
3323
+ }
3324
+ },
3325
+ // ── public: one-time purchases ───────────────────────────────────────────
3326
+ [shared.ROUTES.VALIDATE_PURCHASE]: {
3327
+ post: {
3328
+ summary: "Validate a consumable / non-consumable receipt and record the purchase.",
3329
+ description: 'Non-consumables are idempotent: an already-owned product returns the stored purchase with `action: "restored"`. Consumable receipts can only be redeemed once.',
3330
+ requestBody: {
3331
+ required: true,
3332
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ValidatePurchaseRequest" } } }
3333
+ },
3334
+ responses: {
3335
+ 200: { description: 'Purchase validated \u2014 freshly recorded (`action: "new"`) or restored.', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidatePurchaseResponse" } } } },
3336
+ 400: err("Bad request \u2014 missing/invalid fields (INVALID_INPUT)."),
3337
+ 409: err("Receipt is account-bound or already redeemed by another user (TRANSACTION_BELONGS_TO_OTHER_USER)."),
3338
+ 422: err("Receipt validation failed (RECEIPT_VALIDATION_FAILED).")
3186
3339
  }
3187
3340
  }
3188
3341
  },
3342
+ [shared.ROUTES.PURCHASE_STATUS]: {
3343
+ get: {
3344
+ summary: "List all recorded purchases for a user, optionally filtered by product.",
3345
+ parameters: [
3346
+ { in: "query", name: "userId", required: true, schema: { type: "string" } },
3347
+ { in: "query", name: "productId", schema: { type: "string" } }
3348
+ ],
3349
+ responses: {
3350
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/PurchaseStatusResponse" } } } },
3351
+ 400: err("Missing/invalid userId (INVALID_INPUT).")
3352
+ }
3353
+ }
3354
+ },
3355
+ // ── webhooks ─────────────────────────────────────────────────────────────
3189
3356
  [shared.ROUTES.WEBHOOK_APPLE]: {
3190
3357
  post: {
3191
3358
  summary: "Apple App Store Server Notification V2 receiver.",
3192
3359
  description: "Apple POSTs `{ signedPayload }`. JWS-verified, idempotent when `webhookEventStore` is configured.",
3193
3360
  responses: {
3194
3361
  200: { description: "Acknowledged." },
3195
- 400: { description: "Missing or invalid signedPayload." }
3362
+ 400: err("Missing or invalid signedPayload.")
3196
3363
  }
3197
3364
  }
3198
3365
  },
@@ -3201,8 +3368,23 @@ var ONESUB_OPENAPI = {
3201
3368
  summary: "Google Play Real-Time Developer Notification (Pub/Sub push) receiver.",
3202
3369
  responses: {
3203
3370
  200: { description: "Acknowledged." },
3204
- 400: { description: "Missing message.data or package mismatch." },
3205
- 401: { description: "pushAudience configured and JWT verification failed." }
3371
+ 400: err("Missing message.data or package mismatch."),
3372
+ 401: err("pushAudience configured and JWT verification failed.")
3373
+ }
3374
+ }
3375
+ },
3376
+ // ── entitlements (mounted when config.entitlements is set) ──────────────
3377
+ [shared.ROUTES.ENTITLEMENT]: {
3378
+ get: {
3379
+ summary: "Evaluate a single configured entitlement for a user.",
3380
+ parameters: [
3381
+ { in: "query", name: "userId", required: true, schema: { type: "string" } },
3382
+ { in: "query", name: "id", required: true, schema: { type: "string" }, description: "Entitlement id from `config.entitlements`." }
3383
+ ],
3384
+ responses: {
3385
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/EntitlementResponse" } } } },
3386
+ 400: err("userId and id are required (INVALID_INPUT)."),
3387
+ 404: err("Unknown entitlement id (ENTITLEMENT_NOT_FOUND).")
3206
3388
  }
3207
3389
  }
3208
3390
  },
@@ -3211,15 +3393,17 @@ var ONESUB_OPENAPI = {
3211
3393
  summary: "Evaluate every configured entitlement for a user.",
3212
3394
  parameters: [{ in: "query", name: "userId", required: true, schema: { type: "string" } }],
3213
3395
  responses: {
3214
- 200: { description: "Map of entitlementId \u2192 { granted, source }." }
3396
+ 200: { description: "Map of entitlementId \u2192 EntitlementStatus.", content: { "application/json": { schema: { $ref: "#/components/schemas/EntitlementsResponse" } } } },
3397
+ 400: err("userId is required (INVALID_INPUT).")
3215
3398
  }
3216
3399
  }
3217
3400
  },
3401
+ // ── admin (mounted when config.adminSecret is set) ───────────────────────
3218
3402
  [shared.ROUTES.ADMIN_SUBSCRIPTIONS]: {
3219
3403
  get: {
3220
3404
  summary: "Filtered, paginated subscription list (admin).",
3221
3405
  parameters: [
3222
- { in: "header", name: "X-Admin-Secret", required: true, schema: { type: "string" } },
3406
+ ADMIN_SECRET_PARAM,
3223
3407
  { in: "query", name: "userId", schema: { type: "string" } },
3224
3408
  { in: "query", name: "status", schema: { type: "string", enum: ["active", "grace_period", "on_hold", "paused", "expired", "canceled", "none"] } },
3225
3409
  { in: "query", name: "productId", schema: { type: "string" } },
@@ -3227,39 +3411,203 @@ var ONESUB_OPENAPI = {
3227
3411
  { in: "query", name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } },
3228
3412
  { in: "query", name: "offset", schema: { type: "integer", minimum: 0 } }
3229
3413
  ],
3230
- responses: { 200: { description: "OK" }, 401: { description: "Invalid admin secret" } }
3414
+ responses: {
3415
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/ListSubscriptionsResponse" } } } },
3416
+ 400: err("Invalid filter/pagination params (INVALID_INPUT)."),
3417
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3418
+ }
3419
+ }
3420
+ },
3421
+ "/onesub/admin/subscriptions/{transactionId}": {
3422
+ get: {
3423
+ summary: "Single subscription record by originalTransactionId (admin).",
3424
+ parameters: [
3425
+ ADMIN_SECRET_PARAM,
3426
+ { in: "path", name: "transactionId", required: true, schema: { type: "string" } }
3427
+ ],
3428
+ responses: {
3429
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/SubscriptionInfo" } } } },
3430
+ 400: err("transactionId required (INVALID_INPUT)."),
3431
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET)."),
3432
+ 404: err("No record for this transactionId (TRANSACTION_NOT_FOUND).")
3433
+ }
3231
3434
  }
3232
3435
  },
3233
3436
  "/onesub/admin/customers/{userId}": {
3234
3437
  get: {
3235
3438
  summary: "Customer profile bundle: subscriptions + purchases + entitlements (admin).",
3236
3439
  parameters: [
3237
- { in: "header", name: "X-Admin-Secret", required: true, schema: { type: "string" } },
3440
+ ADMIN_SECRET_PARAM,
3238
3441
  { in: "path", name: "userId", required: true, schema: { type: "string" } }
3239
3442
  ],
3240
- responses: { 200: { description: "OK" }, 401: { description: "Invalid admin secret" } }
3443
+ responses: {
3444
+ 200: { description: 'OK \u2014 empty arrays mean "no record of this user" (never 404).', content: { "application/json": { schema: { $ref: "#/components/schemas/CustomerProfileResponse" } } } },
3445
+ 400: err("userId required (INVALID_INPUT)."),
3446
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3447
+ }
3448
+ }
3449
+ },
3450
+ "/onesub/admin/sync-apple/{originalTransactionId}": {
3451
+ post: {
3452
+ summary: "Reconcile one subscription from the Apple Status API into the local store (admin).",
3453
+ parameters: [
3454
+ ADMIN_SECRET_PARAM,
3455
+ { in: "path", name: "originalTransactionId", required: true, schema: { type: "string" } },
3456
+ { in: "query", name: "sandbox", schema: { type: "string", enum: ["true"] }, description: "Force the sandbox Status API host (otherwise inferred from the stored record)." }
3457
+ ],
3458
+ responses: {
3459
+ 200: { description: "Fresh state fetched and upserted.", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, subscription: { $ref: "#/components/schemas/SubscriptionInfo" } } } } } },
3460
+ 400: err("originalTransactionId missing or Apple API credentials not configured (INVALID_INPUT / APPLE_CONFIG_MISSING)."),
3461
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET)."),
3462
+ 404: err("Apple Status API returned no record (TRANSACTION_NOT_FOUND).")
3463
+ }
3464
+ }
3465
+ },
3466
+ "/onesub/purchase/admin/{userId}/{productId}": {
3467
+ delete: {
3468
+ summary: "Reset a purchase so the user can test the flow again (admin).",
3469
+ parameters: [
3470
+ ADMIN_SECRET_PARAM,
3471
+ { in: "path", name: "userId", required: true, schema: { type: "string" } },
3472
+ { in: "path", name: "productId", required: true, schema: { type: "string" } }
3473
+ ],
3474
+ responses: {
3475
+ 200: { description: "OK", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, deleted: { type: "integer", description: "Number of purchase rows removed." } } } } } },
3476
+ 400: err("userId and productId required (INVALID_INPUT)."),
3477
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3478
+ }
3479
+ }
3480
+ },
3481
+ "/onesub/purchase/admin/transfer": {
3482
+ post: {
3483
+ summary: "Reassign a transactionId to a new userId \u2014 device/account migration (admin).",
3484
+ parameters: [ADMIN_SECRET_PARAM],
3485
+ requestBody: {
3486
+ required: true,
3487
+ content: { "application/json": { schema: { type: "object", required: ["transactionId", "newUserId"], properties: { transactionId: { type: "string" }, newUserId: { type: "string" } } } } }
3488
+ },
3489
+ responses: {
3490
+ 200: { description: "Transferred.", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, purchase: { $ref: "#/components/schemas/PurchaseInfo" } } } } } },
3491
+ 400: err("Missing/invalid body fields (INVALID_INPUT)."),
3492
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET)."),
3493
+ 404: err("No purchase with this transactionId (TRANSACTION_NOT_FOUND).")
3494
+ }
3495
+ }
3496
+ },
3497
+ "/onesub/purchase/admin/grant": {
3498
+ post: {
3499
+ summary: "Manually insert a purchase record, skipping store verification (admin).",
3500
+ parameters: [ADMIN_SECRET_PARAM],
3501
+ requestBody: {
3502
+ required: true,
3503
+ content: { "application/json": { schema: { $ref: "#/components/schemas/GrantPurchaseRequest" } } }
3504
+ },
3505
+ responses: {
3506
+ 200: { description: "Granted.", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, purchase: { $ref: "#/components/schemas/PurchaseInfo" } } } } } },
3507
+ 400: err("Missing/invalid body fields (INVALID_INPUT)."),
3508
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3509
+ }
3241
3510
  }
3242
3511
  },
3243
3512
  "/onesub/admin/webhook-deadletters": {
3244
3513
  get: {
3245
3514
  summary: "List failed webhook jobs (when a queue with DLQ support is configured).",
3246
- parameters: [{ in: "header", name: "X-Admin-Secret", required: true, schema: { type: "string" } }],
3247
- responses: { 200: { description: "OK" }, 401: { description: "Invalid admin secret" } }
3515
+ parameters: [ADMIN_SECRET_PARAM],
3516
+ responses: {
3517
+ 200: { description: "OK", content: { "application/json": { schema: { type: "object", properties: { items: { type: "array", items: { type: "object", description: "DeadLetterRecord: { id, job, attemptsMade, lastError, failedAt }." } } } } } } },
3518
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3519
+ }
3248
3520
  }
3249
3521
  },
3250
3522
  "/onesub/admin/webhook-replay/{id}": {
3251
3523
  post: {
3252
3524
  summary: "Replay a dead-letter job through the webhook handler.",
3253
3525
  parameters: [
3254
- { in: "header", name: "X-Admin-Secret", required: true, schema: { type: "string" } },
3526
+ ADMIN_SECRET_PARAM,
3255
3527
  { in: "path", name: "id", required: true, schema: { type: "string" } }
3256
3528
  ],
3257
- responses: { 200: { description: "OK" }, 401: { description: "Invalid admin secret" } }
3529
+ responses: {
3530
+ 200: { description: "OK" },
3531
+ 400: err("id required (INVALID_INPUT)."),
3532
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3533
+ }
3534
+ }
3535
+ },
3536
+ // ── metrics (mounted when config.adminSecret is set) ─────────────────────
3537
+ [shared.ROUTES.METRICS_ACTIVE]: {
3538
+ get: {
3539
+ summary: "Snapshot of currently-entitled users (active subs + non-consumable owners).",
3540
+ parameters: [ADMIN_SECRET_PARAM],
3541
+ responses: {
3542
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/MetricsActiveResponse" } } } },
3543
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3544
+ }
3545
+ }
3546
+ },
3547
+ [shared.ROUTES.METRICS_STARTED]: {
3548
+ get: {
3549
+ summary: "Subscriptions started (purchasedAt) within a window.",
3550
+ parameters: [ADMIN_SECRET_PARAM, ...METRICS_RANGE_PARAMS],
3551
+ responses: {
3552
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/MetricsCountResponse" } } } },
3553
+ 400: err("from/to missing, non-ISO, or from > to (INVALID_INPUT)."),
3554
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3555
+ }
3556
+ }
3557
+ },
3558
+ [shared.ROUTES.METRICS_EXPIRED]: {
3559
+ get: {
3560
+ summary: "Subscriptions expired/canceled (expiresAt) within a window.",
3561
+ parameters: [ADMIN_SECRET_PARAM, ...METRICS_RANGE_PARAMS],
3562
+ responses: {
3563
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/MetricsCountResponse" } } } },
3564
+ 400: err("from/to missing, non-ISO, or from > to (INVALID_INPUT)."),
3565
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3566
+ }
3567
+ }
3568
+ },
3569
+ [shared.ROUTES.METRICS_PURCHASES_STARTED]: {
3570
+ get: {
3571
+ summary: "Non-consumable purchases started (purchasedAt) within a window.",
3572
+ parameters: [ADMIN_SECRET_PARAM, ...METRICS_RANGE_PARAMS],
3573
+ responses: {
3574
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/MetricsCountResponse" } } } },
3575
+ 400: err("from/to missing, non-ISO, or from > to (INVALID_INPUT)."),
3576
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3577
+ }
3578
+ }
3579
+ },
3580
+ // ── Apple Promotional Offer signing (mounted when offerKeyId + offerPrivateKey set) ──
3581
+ [shared.ROUTES.APPLE_OFFER_SIGNATURE]: {
3582
+ post: {
3583
+ summary: "Sign an Apple Promotional Offer payload server-side (ES256).",
3584
+ description: "The client passes the returned `{ keyId, nonce, timestamp, signature }` to StoreKit to redeem the offer. When `config.adminSecret` is set, the request must carry `X-Onesub-Offer-Secret` with the same value.",
3585
+ parameters: [
3586
+ { in: "header", name: "X-Onesub-Offer-Secret", required: false, schema: { type: "string" }, description: "Required (must equal config.adminSecret) when adminSecret is configured." }
3587
+ ],
3588
+ requestBody: {
3589
+ required: true,
3590
+ content: { "application/json": { schema: { $ref: "#/components/schemas/OfferSignatureRequest" } } }
3591
+ },
3592
+ responses: {
3593
+ 200: { description: "Signed.", content: { "application/json": { schema: { $ref: "#/components/schemas/OfferSignatureResponse" } } } },
3594
+ 400: err("Missing/invalid body fields or apple.bundleId not configured (INVALID_INPUT / APPLE_CONFIG_MISSING)."),
3595
+ 401: err("Offer secret missing or wrong (UNAUTHORIZED).")
3596
+ }
3258
3597
  }
3259
3598
  }
3260
3599
  },
3261
3600
  components: {
3262
3601
  schemas: {
3602
+ ErrorResponse: {
3603
+ type: "object",
3604
+ required: ["error", "errorCode"],
3605
+ description: "Canonical error body \u2014 every onesub 4xx/5xx carries it. Some routes append shape-compat fields (e.g. `valid: false`, `subscription: null`, `purchases: []`).",
3606
+ properties: {
3607
+ error: { type: "string", description: "Human-readable message." },
3608
+ errorCode: { type: "string", enum: Object.values(shared.ONESUB_ERROR_CODE), description: "Machine-readable canonical code." }
3609
+ }
3610
+ },
3263
3611
  ValidateRequest: {
3264
3612
  type: "object",
3265
3613
  required: ["userId", "platform", "productId", "receipt"],
@@ -3279,6 +3627,31 @@ var ONESUB_OPENAPI = {
3279
3627
  purchase: { $ref: "#/components/schemas/PurchaseInfo" }
3280
3628
  }
3281
3629
  },
3630
+ ValidatePurchaseRequest: {
3631
+ type: "object",
3632
+ required: ["userId", "platform", "productId", "receipt", "type"],
3633
+ properties: {
3634
+ userId: { type: "string" },
3635
+ platform: { type: "string", enum: ["apple", "google"] },
3636
+ productId: { type: "string" },
3637
+ receipt: { type: "string", description: "Apple JWS or Google purchaseToken" },
3638
+ type: { type: "string", enum: ["consumable", "non_consumable"] }
3639
+ }
3640
+ },
3641
+ ValidatePurchaseResponse: {
3642
+ type: "object",
3643
+ properties: {
3644
+ valid: { type: "boolean" },
3645
+ purchase: { $ref: "#/components/schemas/PurchaseInfo" },
3646
+ action: { type: "string", enum: ["new", "restored"], description: 'Present on valid:true \u2014 "restored" means the transactionId was already recorded (idempotent retry or reassignment).' }
3647
+ }
3648
+ },
3649
+ PurchaseStatusResponse: {
3650
+ type: "object",
3651
+ properties: {
3652
+ purchases: { type: "array", items: { $ref: "#/components/schemas/PurchaseInfo" } }
3653
+ }
3654
+ },
3282
3655
  StatusResponse: {
3283
3656
  type: "object",
3284
3657
  properties: {
@@ -3310,6 +3683,106 @@ var ONESUB_OPENAPI = {
3310
3683
  quantity: { type: "integer" },
3311
3684
  purchasedAt: { type: "string", format: "date-time" }
3312
3685
  }
3686
+ },
3687
+ EntitlementStatus: {
3688
+ type: "object",
3689
+ required: ["active", "source"],
3690
+ properties: {
3691
+ active: { type: "boolean" },
3692
+ source: { type: ["string", "null"], enum: ["subscription", "purchase", null], description: "Where the entitlement came from when active; null when not active." },
3693
+ productId: { type: "string", description: "Matched productId (only when active)." },
3694
+ expiresAt: { type: "string", format: "date-time", description: 'Only when source === "subscription".' }
3695
+ }
3696
+ },
3697
+ EntitlementResponse: {
3698
+ allOf: [
3699
+ { $ref: "#/components/schemas/EntitlementStatus" },
3700
+ { type: "object", required: ["id"], properties: { id: { type: "string", description: "The entitlement id queried, echoed back." } } }
3701
+ ]
3702
+ },
3703
+ EntitlementsResponse: {
3704
+ type: "object",
3705
+ properties: {
3706
+ entitlements: { type: "object", additionalProperties: { $ref: "#/components/schemas/EntitlementStatus" } }
3707
+ }
3708
+ },
3709
+ ListSubscriptionsResponse: {
3710
+ type: "object",
3711
+ properties: {
3712
+ items: { type: "array", items: { $ref: "#/components/schemas/SubscriptionInfo" } },
3713
+ total: { type: "integer", description: "Total matches before limit/offset." },
3714
+ limit: { type: "integer" },
3715
+ offset: { type: "integer" }
3716
+ }
3717
+ },
3718
+ CustomerProfileResponse: {
3719
+ type: "object",
3720
+ properties: {
3721
+ userId: { type: "string" },
3722
+ subscriptions: { type: "array", items: { $ref: "#/components/schemas/SubscriptionInfo" } },
3723
+ purchases: { type: "array", items: { $ref: "#/components/schemas/PurchaseInfo" } },
3724
+ entitlements: { type: "object", additionalProperties: { $ref: "#/components/schemas/EntitlementStatus" }, description: "Omitted when the server has no entitlements config." }
3725
+ }
3726
+ },
3727
+ GrantPurchaseRequest: {
3728
+ type: "object",
3729
+ required: ["userId", "productId", "platform"],
3730
+ properties: {
3731
+ userId: { type: "string" },
3732
+ productId: { type: "string" },
3733
+ platform: { type: "string", enum: ["apple", "google"] },
3734
+ type: { type: "string", enum: ["consumable", "non_consumable"], default: "non_consumable" },
3735
+ transactionId: { type: "string", description: "Auto-generated `admin_grant_*` id when omitted." }
3736
+ }
3737
+ },
3738
+ MetricsActiveResponse: {
3739
+ type: "object",
3740
+ properties: {
3741
+ total: { type: "integer", description: "Active subs + grace_period subs + non-consumable owners." },
3742
+ activeSubscriptions: { type: "integer" },
3743
+ gracePeriodSubscriptions: { type: "integer" },
3744
+ nonConsumablePurchases: { type: "integer" },
3745
+ byProduct: { type: "object", additionalProperties: { type: "integer" }, description: "Subscription product distribution (subs only)." },
3746
+ byProductPurchases: { type: "object", additionalProperties: { type: "integer" }, description: "Non-consumable purchase distribution." },
3747
+ byPlatform: { type: "object", additionalProperties: { type: "integer" } }
3748
+ }
3749
+ },
3750
+ MetricsBucket: {
3751
+ type: "object",
3752
+ properties: {
3753
+ date: { type: "string", description: "UTC calendar day, YYYY-MM-DD." },
3754
+ count: { type: "integer" }
3755
+ }
3756
+ },
3757
+ MetricsCountResponse: {
3758
+ type: "object",
3759
+ properties: {
3760
+ from: { type: "string", format: "date-time" },
3761
+ to: { type: "string", format: "date-time" },
3762
+ total: { type: "integer" },
3763
+ byProduct: { type: "object", additionalProperties: { type: "integer" } },
3764
+ byPlatform: { type: "object", additionalProperties: { type: "integer" } },
3765
+ buckets: { type: "array", items: { $ref: "#/components/schemas/MetricsBucket" }, description: "Only present with ?groupBy=day \u2014 zero-filled, sorted ascending." }
3766
+ }
3767
+ },
3768
+ OfferSignatureRequest: {
3769
+ type: "object",
3770
+ required: ["productId", "offerId", "applicationUsername"],
3771
+ properties: {
3772
+ productId: { type: "string" },
3773
+ offerId: { type: "string", description: "Promotional offer id defined in App Store Connect." },
3774
+ applicationUsername: { type: "string", description: "Unique per-request UUID (the nonce seed)." }
3775
+ }
3776
+ },
3777
+ OfferSignatureResponse: {
3778
+ type: "object",
3779
+ required: ["keyId", "nonce", "timestamp", "signature"],
3780
+ properties: {
3781
+ keyId: { type: "string" },
3782
+ nonce: { type: "string" },
3783
+ timestamp: { type: "integer", description: "Milliseconds since epoch." },
3784
+ signature: { type: "string", description: "Base64 ES256 signature for StoreKit." }
3785
+ }
3313
3786
  }
3314
3787
  },
3315
3788
  securitySchemes: {
@@ -3359,10 +3832,10 @@ async function withSpan(name, attributes, fn) {
3359
3832
  const result = await fn(span);
3360
3833
  span.setStatus({ code: 1 });
3361
3834
  return result;
3362
- } catch (err) {
3363
- span.recordException(err);
3364
- span.setStatus({ code: 2, message: err.message });
3365
- throw err;
3835
+ } catch (err2) {
3836
+ span.recordException(err2);
3837
+ span.setStatus({ code: 2, message: err2.message });
3838
+ throw err2;
3366
3839
  } finally {
3367
3840
  span.end();
3368
3841
  }
@@ -3384,7 +3857,7 @@ function createOneSubMiddleware(config) {
3384
3857
  router.use(express__default.default.json({ limit: "50kb" }));
3385
3858
  router.use(createValidateRouter(config, store));
3386
3859
  router.use(createStatusRouter(store));
3387
- router.use(createWebhookRouter(config, store, purchaseStore, config.webhookEventStore));
3860
+ router.use(createWebhookRouter(config, store, purchaseStore, config.webhookEventStore, config.webhookQueue));
3388
3861
  router.use(createPurchaseRouter(config, purchaseStore));
3389
3862
  const adminRouter = createAdminRouter(config, purchaseStore, store, config.webhookQueue);
3390
3863
  if (adminRouter) router.use(adminRouter);
@@ -3451,9 +3924,12 @@ exports.fetchAppleTransactionHistory = fetchAppleTransactionHistory;
3451
3924
  exports.getDefaultCache = getDefaultCache;
3452
3925
  exports.log = log;
3453
3926
  exports.openapiHandler = openapiHandler;
3927
+ exports.processAppleNotification = processAppleNotification;
3928
+ exports.processGoogleNotification = processGoogleNotification;
3454
3929
  exports.setDefaultCache = setDefaultCache;
3455
3930
  exports.setLogger = setLogger;
3456
3931
  exports.signApplePromotionalOffer = signApplePromotionalOffer;
3932
+ exports.unmarkWebhookEvent = unmarkWebhookEvent;
3457
3933
  exports.validateAppleReceipt = validateAppleReceipt;
3458
3934
  exports.validateGoogleReceipt = validateGoogleReceipt;
3459
3935
  exports.withSpan = withSpan;