@onesub/server 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +1 -1
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import express, { Router } from 'express';
2
- import { PURCHASE_TYPE, ROUTES, DEFAULT_PORT, ONESUB_ERROR_CODE, SUBSCRIPTION_STATUS, MOCK_RECEIPT_PREFIX } from '@onesub/shared';
2
+ import { PURCHASE_TYPE, ONESUB_ERROR_CODE, ROUTES, DEFAULT_PORT, SUBSCRIPTION_STATUS, MOCK_RECEIPT_PREFIX } from '@onesub/shared';
3
3
  import { z } from 'zod';
4
4
  import { decodeJwt, decodeProtectedHeader, importX509, jwtVerify, importPKCS8, SignJWT, createRemoteJWKSet } from 'jose';
5
- import { X509Certificate, randomUUID, createSign, createHash } from 'crypto';
5
+ import { X509Certificate, randomUUID, timingSafeEqual, createSign, createHash } from 'crypto';
6
6
  import { createRequire } from 'module';
7
7
 
8
8
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
@@ -76,9 +76,9 @@ var InMemoryPurchaseStore = class {
76
76
  const existing = this.byTransactionId.get(purchase.transactionId);
77
77
  if (existing) {
78
78
  if (existing.userId !== purchase.userId) {
79
- const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
80
- err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
81
- throw err;
79
+ const err2 = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
80
+ err2.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
81
+ throw err2;
82
82
  }
83
83
  return;
84
84
  }
@@ -258,6 +258,9 @@ function deterministicTransactionId(prefix, receipt) {
258
258
  const digest = createHash("sha256").update(receipt).digest("hex").slice(0, 24);
259
259
  return `${prefix}_${digest}`;
260
260
  }
261
+ function extractBoundToken(receipt) {
262
+ return receipt.match(/#token=([^#]+)/)?.[1];
263
+ }
261
264
  var HOURS = 60 * 60 * 1e3;
262
265
  var DAYS = 24 * HOURS;
263
266
  function mockValidateAppleSubscription(receipt) {
@@ -265,7 +268,7 @@ function mockValidateAppleSubscription(receipt) {
265
268
  if (!outcomePasses(outcome, "apple")) return null;
266
269
  const now = Date.now();
267
270
  const expiresAt = outcome.kind === "sandbox" ? now + 1 * HOURS : now + 30 * DAYS;
268
- const tokenMatch = receipt.match(/#token=([^#]+)/);
271
+ const boundToken = extractBoundToken(receipt);
269
272
  return {
270
273
  userId: "",
271
274
  productId: "mock_subscription",
@@ -275,19 +278,19 @@ function mockValidateAppleSubscription(receipt) {
275
278
  originalTransactionId: deterministicTransactionId("mock_apple_orig", receipt),
276
279
  purchasedAt: new Date(now).toISOString(),
277
280
  willRenew: true,
278
- ...tokenMatch ? { boundAccountId: tokenMatch[1] } : {}
281
+ ...boundToken ? { boundAccountId: boundToken } : {}
279
282
  };
280
283
  }
281
284
  function mockValidateAppleProduct(receipt, expectedProductId) {
282
285
  const outcome = classifyMockReceipt(receipt);
283
286
  if (!outcomePasses(outcome, "apple")) return null;
284
287
  const productId = expectedProductId ?? "mock_product";
285
- const tokenMatch = receipt.match(/#token=([^#]+)/);
288
+ const boundToken = extractBoundToken(receipt);
286
289
  return {
287
290
  transactionId: deterministicTransactionId(`mock_apple_${productId}`, receipt),
288
291
  productId,
289
292
  purchasedAt: (/* @__PURE__ */ new Date()).toISOString(),
290
- ...tokenMatch ? { appAccountToken: tokenMatch[1] } : {}
293
+ ...boundToken ? { appAccountToken: boundToken } : {}
291
294
  };
292
295
  }
293
296
  function mockValidateGoogleSubscription(receipt, productId) {
@@ -295,7 +298,7 @@ function mockValidateGoogleSubscription(receipt, productId) {
295
298
  if (!outcomePasses(outcome, "google")) return null;
296
299
  const now = Date.now();
297
300
  const expiresAt = outcome.kind === "sandbox" ? now + 1 * HOURS : now + 30 * DAYS;
298
- const tokenMatch = receipt.match(/#token=([^#]+)/);
301
+ const boundToken = extractBoundToken(receipt);
299
302
  return {
300
303
  userId: "",
301
304
  productId,
@@ -307,17 +310,17 @@ function mockValidateGoogleSubscription(receipt, productId) {
307
310
  originalTransactionId: receipt,
308
311
  purchasedAt: new Date(now).toISOString(),
309
312
  willRenew: true,
310
- ...tokenMatch ? { boundAccountId: tokenMatch[1] } : {}
313
+ ...boundToken ? { boundAccountId: boundToken } : {}
311
314
  };
312
315
  }
313
316
  function mockValidateGoogleProduct(receipt, productId) {
314
317
  const outcome = classifyMockReceipt(receipt);
315
318
  if (!outcomePasses(outcome, "google")) return null;
316
- const tokenMatch = receipt.match(/#token=([^#]+)/);
319
+ const boundToken = extractBoundToken(receipt);
317
320
  return {
318
321
  transactionId: deterministicTransactionId(`mock_google_${productId}`, receipt),
319
322
  purchasedAt: (/* @__PURE__ */ new Date()).toISOString(),
320
- ...tokenMatch ? { obfuscatedExternalAccountId: tokenMatch[1] } : {}
323
+ ...boundToken ? { obfuscatedExternalAccountId: boundToken } : {}
321
324
  };
322
325
  }
323
326
 
@@ -326,6 +329,13 @@ function derBase64ToPem(der) {
326
329
  return "-----BEGIN CERTIFICATE-----\n" + (der.match(/.{1,64}/g) ?? []).join("\n") + "\n-----END CERTIFICATE-----";
327
330
  }
328
331
  var APPLE_ROOT_CERTS = APPLE_ROOT_CA_PEMS.map((pem) => new X509Certificate(pem));
332
+ function assertIssuerCanSign(cert, index) {
333
+ if (!cert.ca) {
334
+ throw new Error(
335
+ `[onesub/apple] cert[${index}] is used as an issuer but is not a CA (basicConstraints CA=false)`
336
+ );
337
+ }
338
+ }
329
339
  function verifyAppleCertChain(x5c) {
330
340
  if (x5c.length === 0) {
331
341
  throw new Error("[onesub/apple] empty x5c");
@@ -337,6 +347,7 @@ function verifyAppleCertChain(x5c) {
337
347
  if (new Date(cert.validFrom) > now || new Date(cert.validTo) < now) {
338
348
  throw new Error(`[onesub/apple] cert[${i}] outside validity window`);
339
349
  }
350
+ if (i >= 1) assertIssuerCanSign(cert, i);
340
351
  if (i + 1 < chain.length) {
341
352
  if (!cert.checkIssued(chain[i + 1]) || !cert.verify(chain[i + 1].publicKey)) {
342
353
  throw new Error(`[onesub/apple] cert[${i}] not signed by cert[${i + 1}]`);
@@ -347,6 +358,7 @@ function verifyAppleCertChain(x5c) {
347
358
  const topDer = top.raw.toString("base64");
348
359
  const trustsRoot = APPLE_ROOT_CERTS.some((root) => {
349
360
  if (root.raw.toString("base64") === topDer) return true;
361
+ if (!root.ca) return false;
350
362
  if (!top.checkIssued(root)) return false;
351
363
  try {
352
364
  return top.verify(root.publicKey);
@@ -392,12 +404,12 @@ async function validateAppleReceipt(receipt, config) {
392
404
  let tx;
393
405
  try {
394
406
  tx = await decodeJws(receipt, config.skipJwsVerification);
395
- } catch (err) {
407
+ } catch (err2) {
396
408
  const preview = receipt.slice(0, 60);
397
409
  const parts = receipt.split(".").length;
398
410
  log.warn(
399
411
  "[onesub/apple] Failed to decode receipt as JWS:",
400
- err?.message ?? err,
412
+ err2?.message ?? err2,
401
413
  `| preview: "${preview}..." (len=${receipt.length}, parts=${parts})`
402
414
  );
403
415
  return null;
@@ -434,13 +446,13 @@ async function validateAppleConsumableReceipt(signedTransaction, config, expecte
434
446
  let tx;
435
447
  try {
436
448
  tx = await decodeJws(signedTransaction, config.skipJwsVerification);
437
- } catch (err) {
449
+ } catch (err2) {
438
450
  const preview = signedTransaction.slice(0, 60);
439
451
  const parts = signedTransaction.split(".").length;
440
452
  const looksLikeJws = parts === 3;
441
453
  log.warn(
442
454
  "[onesub/apple] Failed to decode consumable JWS:",
443
- err?.message ?? err,
455
+ err2?.message ?? err2,
444
456
  `| receipt preview: "${preview}..." (len=${signedTransaction.length}, parts=${parts}, looksLikeJws=${looksLikeJws})`
445
457
  );
446
458
  return null;
@@ -554,8 +566,8 @@ async function sendAppleConsumptionResponse(transactionId, body, config, options
554
566
  let jwt;
555
567
  try {
556
568
  jwt = await makeAppleApiJwt(config);
557
- } catch (err) {
558
- log.warn("[onesub/apple] Cannot send consumption response \u2014 JWT mint failed:", err);
569
+ } catch (err2) {
570
+ log.warn("[onesub/apple] Cannot send consumption response \u2014 JWT mint failed:", err2);
559
571
  return;
560
572
  }
561
573
  const host = options?.sandbox ? "api.storekit-sandbox.itunes.apple.com" : "api.storekit.itunes.apple.com";
@@ -573,8 +585,8 @@ async function sendAppleConsumptionResponse(transactionId, body, config, options
573
585
  const text = await resp.text();
574
586
  log.warn(`[onesub/apple] Consumption response API error ${resp.status}: ${text}`);
575
587
  }
576
- } catch (err) {
577
- log.warn("[onesub/apple] Consumption response network error:", err);
588
+ } catch (err2) {
589
+ log.warn("[onesub/apple] Consumption response network error:", err2);
578
590
  }
579
591
  }
580
592
  var APPLE_SUBSCRIPTION_STATUS_CODE = {
@@ -604,8 +616,8 @@ async function fetchAppleSubscriptionStatus(originalTransactionId, config, optio
604
616
  let jwt;
605
617
  try {
606
618
  jwt = await makeAppleApiJwt(config);
607
- } catch (err) {
608
- log.warn("[onesub/apple] Cannot fetch subscription status \u2014 JWT mint failed:", err);
619
+ } catch (err2) {
620
+ log.warn("[onesub/apple] Cannot fetch subscription status \u2014 JWT mint failed:", err2);
609
621
  return null;
610
622
  }
611
623
  const host = options?.sandbox ? "api.storekit-sandbox.itunes.apple.com" : "api.storekit.itunes.apple.com";
@@ -621,8 +633,8 @@ async function fetchAppleSubscriptionStatus(originalTransactionId, config, optio
621
633
  return null;
622
634
  }
623
635
  body = await resp.json();
624
- } catch (err) {
625
- log.warn("[onesub/apple] Status API network error:", err);
636
+ } catch (err2) {
637
+ log.warn("[onesub/apple] Status API network error:", err2);
626
638
  return null;
627
639
  }
628
640
  const entry = body.data?.flatMap((g) => g.lastTransactions ?? []).find((t) => t.originalTransactionId === originalTransactionId);
@@ -633,8 +645,8 @@ async function fetchAppleSubscriptionStatus(originalTransactionId, config, optio
633
645
  let tx;
634
646
  try {
635
647
  tx = await decodeJws(entry.signedTransactionInfo, config.skipJwsVerification);
636
- } catch (err) {
637
- log.warn("[onesub/apple] Failed to decode signedTransactionInfo from Status API:", err);
648
+ } catch (err2) {
649
+ log.warn("[onesub/apple] Failed to decode signedTransactionInfo from Status API:", err2);
638
650
  return null;
639
651
  }
640
652
  let renewal = null;
@@ -662,18 +674,20 @@ async function fetchAppleSubscriptionStatus(originalTransactionId, config, optio
662
674
  willRenew: renewal?.autoRenewStatus === 1
663
675
  };
664
676
  }
677
+ var MAX_HISTORY_PAGES = 50;
665
678
  async function fetchAppleTransactionHistory(originalTransactionId, config, options) {
666
679
  if (config.mockMode) return null;
667
680
  let jwt;
668
681
  try {
669
682
  jwt = await makeAppleApiJwt(config);
670
- } catch (err) {
671
- log.warn("[onesub/apple] Cannot fetch transaction history \u2014 JWT mint failed:", err);
683
+ } catch (err2) {
684
+ log.warn("[onesub/apple] Cannot fetch transaction history \u2014 JWT mint failed:", err2);
672
685
  return null;
673
686
  }
674
687
  const host = options?.sandbox ? "api.storekit-sandbox.itunes.apple.com" : "api.storekit.itunes.apple.com";
675
688
  const results = [];
676
689
  let revision;
690
+ let pageCount = 0;
677
691
  for (; ; ) {
678
692
  const url = new URL(`https://${host}/inApps/v2/history/${encodeURIComponent(originalTransactionId)}`);
679
693
  if (revision) url.searchParams.set("revision", revision);
@@ -688,8 +702,8 @@ async function fetchAppleTransactionHistory(originalTransactionId, config, optio
688
702
  return null;
689
703
  }
690
704
  page = await resp.json();
691
- } catch (err) {
692
- log.warn("[onesub/apple] Transaction History API network error:", err);
705
+ } catch (err2) {
706
+ log.warn("[onesub/apple] Transaction History API network error:", err2);
693
707
  return null;
694
708
  }
695
709
  for (const signed of page.signedTransactions ?? []) {
@@ -710,7 +724,20 @@ async function fetchAppleTransactionHistory(originalTransactionId, config, optio
710
724
  } catch {
711
725
  }
712
726
  }
727
+ pageCount++;
713
728
  if (!page.hasMore) break;
729
+ if (!page.revision || page.revision === revision) {
730
+ log.warn(
731
+ "[onesub/apple] Transaction History API returned hasMore without a new revision cursor \u2014 stopping pagination (partial history returned)"
732
+ );
733
+ break;
734
+ }
735
+ if (pageCount >= MAX_HISTORY_PAGES) {
736
+ log.warn(
737
+ `[onesub/apple] Transaction History pagination hit the ${MAX_HISTORY_PAGES}-page cap \u2014 stopping (partial history returned)`
738
+ );
739
+ break;
740
+ }
714
741
  revision = page.revision;
715
742
  }
716
743
  return results;
@@ -741,8 +768,8 @@ async function signApplePromotionalOffer(input, config) {
741
768
  sign.update(message);
742
769
  sign.end();
743
770
  signatureBuffer = sign.sign(offerPrivateKey);
744
- } catch (err) {
745
- throw new Error(`[onesub/apple] Failed to sign promotional offer \u2014 check that offerPrivateKey is a valid ES256 PEM key: ${err.message}`);
771
+ } catch (err2) {
772
+ throw new Error(`[onesub/apple] Failed to sign promotional offer \u2014 check that offerPrivateKey is a valid ES256 PEM key: ${err2.message}`);
746
773
  }
747
774
  const signature = signatureBuffer.toString("base64");
748
775
  return { keyId: offerKeyId, nonce, timestamp, signature };
@@ -857,8 +884,8 @@ async function acknowledgeGoogleSubscription(purchaseToken, productId, config) {
857
884
  let accessToken;
858
885
  try {
859
886
  accessToken = await getCachedAccessToken(config.serviceAccountKey);
860
- } catch (err) {
861
- log.warn("[onesub/google] Could not get access token for subscription ack:", err);
887
+ } catch (err2) {
888
+ log.warn("[onesub/google] Could not get access token for subscription ack:", err2);
862
889
  return;
863
890
  }
864
891
  const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(config.packageName)}/purchases/subscriptions/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:acknowledge`;
@@ -872,8 +899,8 @@ async function acknowledgeGoogleSubscription(purchaseToken, productId, config) {
872
899
  const body = await resp.text();
873
900
  log.warn(`[onesub/google] Subscription acknowledge API error ${resp.status}: ${body} \u2014 auto-refund risk`);
874
901
  }
875
- } catch (err) {
876
- log.warn("[onesub/google] Subscription acknowledge network error \u2014 auto-refund risk:", err);
902
+ } catch (err2) {
903
+ log.warn("[onesub/google] Subscription acknowledge network error \u2014 auto-refund risk:", err2);
877
904
  }
878
905
  }
879
906
  async function acknowledgeGoogleProduct(purchaseToken, productId, config) {
@@ -883,8 +910,8 @@ async function acknowledgeGoogleProduct(purchaseToken, productId, config) {
883
910
  let accessToken;
884
911
  try {
885
912
  accessToken = await getCachedAccessToken(config.serviceAccountKey);
886
- } catch (err) {
887
- log.warn("[onesub/google] Could not get access token for product ack:", err);
913
+ } catch (err2) {
914
+ log.warn("[onesub/google] Could not get access token for product ack:", err2);
888
915
  return;
889
916
  }
890
917
  const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(config.packageName)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:acknowledge`;
@@ -898,8 +925,8 @@ async function acknowledgeGoogleProduct(purchaseToken, productId, config) {
898
925
  const body = await resp.text();
899
926
  log.warn(`[onesub/google] Product acknowledge API error ${resp.status}: ${body} \u2014 auto-refund risk`);
900
927
  }
901
- } catch (err) {
902
- log.warn("[onesub/google] Product acknowledge network error \u2014 auto-refund risk:", err);
928
+ } catch (err2) {
929
+ log.warn("[onesub/google] Product acknowledge network error \u2014 auto-refund risk:", err2);
903
930
  }
904
931
  }
905
932
  async function consumeGoogleProductReceipt(purchaseToken, productId, config) {
@@ -908,8 +935,8 @@ async function consumeGoogleProductReceipt(purchaseToken, productId, config) {
908
935
  let accessToken;
909
936
  try {
910
937
  accessToken = await getCachedAccessToken(config.serviceAccountKey);
911
- } catch (err) {
912
- log.warn("[onesub/google] Could not get access token for consume:", err);
938
+ } catch (err2) {
939
+ log.warn("[onesub/google] Could not get access token for consume:", err2);
913
940
  return;
914
941
  }
915
942
  const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(config.packageName)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:consume`;
@@ -922,8 +949,8 @@ async function consumeGoogleProductReceipt(purchaseToken, productId, config) {
922
949
  const body = await resp.text();
923
950
  log.warn(`[onesub/google] Consume API error ${resp.status}: ${body} \u2014 auto-refund risk`);
924
951
  }
925
- } catch (err) {
926
- log.warn("[onesub/google] Consume network error \u2014 auto-refund risk:", err);
952
+ } catch (err2) {
953
+ log.warn("[onesub/google] Consume network error \u2014 auto-refund risk:", err2);
927
954
  }
928
955
  }
929
956
  function deriveStatusV2(state) {
@@ -960,8 +987,8 @@ async function validateGoogleReceipt(receipt, productId, config) {
960
987
  try {
961
988
  const token = await getCachedAccessToken(config.serviceAccountKey);
962
989
  purchase = await fetchSubscriptionPurchaseV2(config.packageName, receipt, token);
963
- } catch (err) {
964
- log.error("[onesub/google] Receipt validation failed:", err);
990
+ } catch (err2) {
991
+ log.error("[onesub/google] Receipt validation failed:", err2);
965
992
  return null;
966
993
  }
967
994
  const status = deriveStatusV2(purchase.subscriptionState);
@@ -1022,8 +1049,8 @@ async function validateGoogleProductReceipt(purchaseToken, productId, config, ty
1022
1049
  try {
1023
1050
  const token = await getCachedAccessToken(config.serviceAccountKey);
1024
1051
  purchase = await fetchProductPurchase(config.packageName, productId, purchaseToken, token);
1025
- } catch (err) {
1026
- log.error("[onesub/google] Product receipt validation failed:", err);
1052
+ } catch (err2) {
1053
+ log.error("[onesub/google] Product receipt validation failed:", err2);
1027
1054
  return null;
1028
1055
  }
1029
1056
  if (purchase.purchaseState !== 0) {
@@ -1129,12 +1156,12 @@ function isGooglePriceChangeConfirmedNotification(notificationType) {
1129
1156
  function sendError(res, status, code, error, extra = {}) {
1130
1157
  res.status(status).json({ ...extra, error, errorCode: code });
1131
1158
  }
1132
- function sendZodError(res, err, extra = {}) {
1159
+ function sendZodError(res, err2, extra = {}) {
1133
1160
  sendError(
1134
1161
  res,
1135
1162
  400,
1136
1163
  ONESUB_ERROR_CODE.INVALID_INPUT,
1137
- err.issues.map((e) => e.message).join(", "),
1164
+ err2.issues.map((e) => e.message).join(", "),
1138
1165
  extra
1139
1166
  );
1140
1167
  }
@@ -1156,12 +1183,12 @@ function createValidateRouter(config, store) {
1156
1183
  let productId;
1157
1184
  try {
1158
1185
  ({ platform, receipt, userId, productId } = validateSchema.parse(req.body));
1159
- } catch (err) {
1160
- if (err instanceof z.ZodError) {
1161
- sendZodError(res, err, NO_SUB);
1186
+ } catch (err2) {
1187
+ if (err2 instanceof z.ZodError) {
1188
+ sendZodError(res, err2, NO_SUB);
1162
1189
  return;
1163
1190
  }
1164
- throw err;
1191
+ throw err2;
1165
1192
  }
1166
1193
  try {
1167
1194
  let sub = null;
@@ -1205,8 +1232,8 @@ function createValidateRouter(config, store) {
1205
1232
  }
1206
1233
  const response = { valid: true, subscription: sub };
1207
1234
  res.status(200).json(response);
1208
- } catch (err) {
1209
- log.error("[onesub/validate] Unexpected error:", err);
1235
+ } catch (err2) {
1236
+ log.error("[onesub/validate] Unexpected error:", err2);
1210
1237
  sendError(res, 500, ONESUB_ERROR_CODE.INTERNAL_ERROR, "Internal server error during receipt validation", NO_SUB);
1211
1238
  }
1212
1239
  });
@@ -1237,13 +1264,66 @@ function createStatusRouter(store) {
1237
1264
  const active = statusAllows && notYetExpired;
1238
1265
  const response = { active, subscription: sub };
1239
1266
  res.status(200).json(response);
1240
- } catch (err) {
1241
- log.error("[onesub/status] Store error:", err);
1267
+ } catch (err2) {
1268
+ log.error("[onesub/status] Store error:", err2);
1242
1269
  sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error", NO_SUB2);
1243
1270
  }
1244
1271
  });
1245
1272
  return router;
1246
1273
  }
1274
+
1275
+ // src/webhook-events.ts
1276
+ async function unmarkWebhookEvent(store, provider, eventId) {
1277
+ if (!store?.unmark || typeof eventId !== "string") return;
1278
+ try {
1279
+ await store.unmark(provider, eventId);
1280
+ } catch {
1281
+ }
1282
+ }
1283
+ var DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60;
1284
+ var InMemoryWebhookEventStore = class {
1285
+ constructor(ttlSeconds = DEFAULT_TTL_SECONDS) {
1286
+ this.ttlSeconds = ttlSeconds;
1287
+ }
1288
+ ttlSeconds;
1289
+ seen = /* @__PURE__ */ new Map();
1290
+ async markIfNew(provider, eventId) {
1291
+ this.evictExpired();
1292
+ const key = `${provider}:${eventId}`;
1293
+ if (this.seen.has(key)) return false;
1294
+ this.seen.set(key, Date.now() + this.ttlSeconds * 1e3);
1295
+ return true;
1296
+ }
1297
+ async unmark(provider, eventId) {
1298
+ this.seen.delete(`${provider}:${eventId}`);
1299
+ }
1300
+ evictExpired() {
1301
+ const now = Date.now();
1302
+ for (const [key, expiresAt] of this.seen) {
1303
+ if (expiresAt < now) this.seen.delete(key);
1304
+ }
1305
+ }
1306
+ };
1307
+ var CacheWebhookEventStore = class {
1308
+ constructor(cache, ttlSeconds = DEFAULT_TTL_SECONDS) {
1309
+ this.cache = cache;
1310
+ this.ttlSeconds = ttlSeconds;
1311
+ }
1312
+ cache;
1313
+ ttlSeconds;
1314
+ async markIfNew(provider, eventId) {
1315
+ const key = `webhook:event:${provider}:${eventId}`;
1316
+ const existing = await this.cache.get(key);
1317
+ if (existing) return false;
1318
+ await this.cache.set(key, "1", this.ttlSeconds);
1319
+ return true;
1320
+ }
1321
+ async unmark(provider, eventId) {
1322
+ await this.cache.del(`webhook:event:${provider}:${eventId}`);
1323
+ }
1324
+ };
1325
+
1326
+ // src/routes/webhook-apple.ts
1247
1327
  var APPLE_ACTIVE_TYPES = /* @__PURE__ */ new Set(["SUBSCRIBED", "DID_RENEW", "DID_RECOVER", "OFFER_REDEEMED"]);
1248
1328
  var APPLE_CANCELED_TYPES = /* @__PURE__ */ new Set(["REVOKE", "REFUND"]);
1249
1329
  var APPLE_EXPIRED_TYPES = /* @__PURE__ */ new Set(["EXPIRED"]);
@@ -1257,36 +1337,8 @@ function mapAppleNotificationStatus(notificationType, subtype) {
1257
1337
  if (APPLE_ACTIVE_TYPES.has(notificationType)) return SUBSCRIPTION_STATUS.ACTIVE;
1258
1338
  return null;
1259
1339
  }
1260
- async function handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore) {
1261
- const body = req.body;
1262
- if (!body.signedPayload) {
1263
- sendError(res, 400, ONESUB_ERROR_CODE.MISSING_SIGNED_PAYLOAD, "Missing signedPayload");
1264
- return;
1265
- }
1266
- let payload;
1267
- try {
1268
- payload = await decodeJws(
1269
- body.signedPayload,
1270
- config.apple?.skipJwsVerification
1271
- );
1272
- } catch (err) {
1273
- log.error("[onesub/webhook/apple] Failed to decode signedPayload:", err);
1274
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_SIGNED_PAYLOAD, "Invalid signedPayload");
1275
- return;
1276
- }
1277
- if (webhookEventStore && typeof payload.notificationUUID === "string") {
1278
- const fresh = await webhookEventStore.markIfNew("apple", payload.notificationUUID);
1279
- if (!fresh) {
1280
- log.info("[onesub/webhook/apple] dedupe: already processed", payload.notificationUUID);
1281
- res.status(200).json({ received: true, deduped: true });
1282
- return;
1283
- }
1284
- }
1285
- const decoded = await decodeAppleNotification(payload, config.apple?.skipJwsVerification);
1286
- if (!decoded) {
1287
- res.status(200).json({ received: true });
1288
- return;
1289
- }
1340
+ async function processAppleNotification(work, config, store, purchaseStore) {
1341
+ const { notificationType, subtype } = work;
1290
1342
  const {
1291
1343
  originalTransactionId,
1292
1344
  transactionId,
@@ -1299,14 +1351,7 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1299
1351
  expiresAt,
1300
1352
  appAccountToken,
1301
1353
  inAppOwnershipType
1302
- } = decoded;
1303
- const notificationType = payload.notificationType;
1304
- const subtype = payload.subtype;
1305
- if (config.apple?.bundleId && bundleId && bundleId !== config.apple.bundleId) {
1306
- log.warn("[onesub/webhook/apple] Bundle ID mismatch:", bundleId, "!==", config.apple.bundleId);
1307
- sendError(res, 400, ONESUB_ERROR_CODE.BUNDLE_ID_MISMATCH, "Bundle ID mismatch");
1308
- return;
1309
- }
1354
+ } = work.decoded;
1310
1355
  const mapped = mapAppleNotificationStatus(notificationType, subtype);
1311
1356
  const finalStatus = mapped ?? status;
1312
1357
  if (notificationType === "CONSUMPTION_REQUEST" && config.apple?.consumptionInfoProvider && transactionId && productId) {
@@ -1326,75 +1371,129 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1326
1371
  sandbox: environment === "Sandbox"
1327
1372
  });
1328
1373
  }
1329
- } catch (err) {
1330
- log.warn("[onesub/webhook/apple] consumptionInfoProvider failed:", err);
1374
+ } catch (err2) {
1375
+ log.warn("[onesub/webhook/apple] consumptionInfoProvider failed:", err2);
1331
1376
  }
1332
1377
  })();
1333
1378
  }
1334
1379
  const isOneTimePurchase = type === "Consumable" || type === "Non-Consumable";
1335
1380
  const isRefundOrRevoke = APPLE_CANCELED_TYPES.has(notificationType);
1336
1381
  if (isOneTimePurchase && notificationType === "CONSUMPTION_REQUEST") {
1337
- res.status(200).json({ received: true });
1338
1382
  return;
1339
1383
  }
1340
- try {
1341
- if (isOneTimePurchase && isRefundOrRevoke) {
1342
- const lookupId = transactionId ?? originalTransactionId;
1343
- const removed = await purchaseStore.deletePurchaseByTransactionId(lookupId);
1344
- if (!removed) {
1345
- log.warn("[onesub/webhook/apple] IAP refund for unknown transaction:", lookupId);
1346
- }
1347
- res.status(200).json({ received: true });
1348
- return;
1384
+ if (isOneTimePurchase && isRefundOrRevoke) {
1385
+ const lookupId = transactionId ?? originalTransactionId;
1386
+ const removed = await purchaseStore.deletePurchaseByTransactionId(lookupId);
1387
+ if (!removed) {
1388
+ log.warn("[onesub/webhook/apple] IAP refund for unknown transaction:", lookupId);
1349
1389
  }
1350
- const existing = await store.getByTransactionId(originalTransactionId);
1351
- if (existing) {
1352
- const isSubscriptionRefund = isRefundOrRevoke && !isOneTimePurchase;
1353
- const keepEntitlement = isSubscriptionRefund && config.refundPolicy === "until_expiry";
1354
- const correctedUserId = appAccountToken && existing.userId === originalTransactionId ? appAccountToken : existing.userId;
1355
- if (correctedUserId !== existing.userId) {
1356
- log.info("[onesub/webhook/apple] correcting userId from originalTransactionId to appAccountToken:", correctedUserId);
1357
- }
1358
- const updated = keepEntitlement ? { ...existing, userId: correctedUserId, willRenew: false } : {
1359
- ...existing,
1360
- userId: correctedUserId,
1361
- status: finalStatus,
1362
- willRenew,
1363
- expiresAt: expiresAt ?? existing.expiresAt
1364
- };
1365
- await store.save(updated);
1366
- } else if (config.apple?.issuerId && config.apple?.keyId && config.apple?.privateKey) {
1367
- const fresh = await fetchAppleSubscriptionStatus(originalTransactionId, config.apple, {
1368
- sandbox: environment === "Sandbox"
1369
- });
1370
- if (fresh) {
1371
- fresh.userId = appAccountToken ?? originalTransactionId;
1372
- if (inAppOwnershipType === "FAMILY_SHARED") {
1373
- const source = appAccountToken ? "appAccountToken" : "originalTransactionId (fallback)";
1374
- log.info(`[onesub/webhook/apple] FAMILY_SHARED \u2014 userId: ${fresh.userId} (source: ${source})`);
1375
- }
1376
- await store.save(fresh);
1377
- } else {
1378
- log.warn(
1379
- "[onesub/webhook/apple] Unknown transaction and Status API returned no record:",
1380
- originalTransactionId
1381
- );
1390
+ return;
1391
+ }
1392
+ const existing = await store.getByTransactionId(originalTransactionId);
1393
+ if (existing) {
1394
+ const isSubscriptionRefund = isRefundOrRevoke && !isOneTimePurchase;
1395
+ const keepEntitlement = isSubscriptionRefund && config.refundPolicy === "until_expiry";
1396
+ const correctedUserId = appAccountToken && existing.userId === originalTransactionId ? appAccountToken : existing.userId;
1397
+ if (correctedUserId !== existing.userId) {
1398
+ log.info("[onesub/webhook/apple] correcting userId from originalTransactionId to appAccountToken:", correctedUserId);
1399
+ }
1400
+ const updated = keepEntitlement ? { ...existing, userId: correctedUserId, willRenew: false } : {
1401
+ ...existing,
1402
+ userId: correctedUserId,
1403
+ status: finalStatus,
1404
+ willRenew,
1405
+ expiresAt: expiresAt ?? existing.expiresAt
1406
+ };
1407
+ await store.save(updated);
1408
+ } else if (config.apple?.issuerId && config.apple?.keyId && config.apple?.privateKey) {
1409
+ const fresh = await fetchAppleSubscriptionStatus(originalTransactionId, config.apple, {
1410
+ sandbox: environment === "Sandbox"
1411
+ });
1412
+ if (fresh) {
1413
+ fresh.userId = appAccountToken ?? originalTransactionId;
1414
+ if (inAppOwnershipType === "FAMILY_SHARED") {
1415
+ const source = appAccountToken ? "appAccountToken" : "originalTransactionId (fallback)";
1416
+ log.info(`[onesub/webhook/apple] FAMILY_SHARED \u2014 userId: ${fresh.userId} (source: ${source})`);
1382
1417
  }
1418
+ await store.save(fresh);
1383
1419
  } else {
1384
1420
  log.warn(
1385
- "[onesub/webhook/apple] Received notification for unknown transaction (no API creds to recover):",
1421
+ "[onesub/webhook/apple] Unknown transaction and Status API returned no record:",
1386
1422
  originalTransactionId
1387
1423
  );
1388
1424
  }
1425
+ } else {
1426
+ log.warn(
1427
+ "[onesub/webhook/apple] Received notification for unknown transaction (no API creds to recover):",
1428
+ originalTransactionId
1429
+ );
1430
+ }
1431
+ }
1432
+ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue) {
1433
+ const body = req.body;
1434
+ if (!body.signedPayload) {
1435
+ sendError(res, 400, ONESUB_ERROR_CODE.MISSING_SIGNED_PAYLOAD, "Missing signedPayload");
1436
+ return;
1437
+ }
1438
+ let payload;
1439
+ try {
1440
+ payload = await decodeJws(
1441
+ body.signedPayload,
1442
+ config.apple?.skipJwsVerification
1443
+ );
1444
+ } catch (err2) {
1445
+ log.error("[onesub/webhook/apple] Failed to decode signedPayload:", err2);
1446
+ sendError(res, 400, ONESUB_ERROR_CODE.INVALID_SIGNED_PAYLOAD, "Invalid signedPayload");
1447
+ return;
1448
+ }
1449
+ let markedEventId;
1450
+ if (webhookEventStore && typeof payload.notificationUUID === "string") {
1451
+ const fresh = await webhookEventStore.markIfNew("apple", payload.notificationUUID);
1452
+ if (!fresh) {
1453
+ log.info("[onesub/webhook/apple] dedupe: already processed", payload.notificationUUID);
1454
+ res.status(200).json({ received: true, deduped: true });
1455
+ return;
1456
+ }
1457
+ markedEventId = payload.notificationUUID;
1458
+ }
1459
+ const decoded = await decodeAppleNotification(payload, config.apple?.skipJwsVerification);
1460
+ if (!decoded) {
1389
1461
  res.status(200).json({ received: true });
1390
- } catch (err) {
1391
- log.error("[onesub/webhook/apple] Store update error:", err);
1392
- if (webhookEventStore?.unmark && typeof payload.notificationUUID === "string") {
1393
- try {
1394
- await webhookEventStore.unmark("apple", payload.notificationUUID);
1395
- } catch {
1396
- }
1462
+ return;
1463
+ }
1464
+ if (config.apple?.bundleId && decoded.bundleId && decoded.bundleId !== config.apple.bundleId) {
1465
+ log.warn("[onesub/webhook/apple] Bundle ID mismatch:", decoded.bundleId, "!==", config.apple.bundleId);
1466
+ sendError(res, 400, ONESUB_ERROR_CODE.BUNDLE_ID_MISMATCH, "Bundle ID mismatch");
1467
+ return;
1468
+ }
1469
+ const work = {
1470
+ decoded,
1471
+ notificationType: payload.notificationType,
1472
+ subtype: payload.subtype
1473
+ };
1474
+ if (webhookQueue) {
1475
+ try {
1476
+ await webhookQueue.enqueue({
1477
+ provider: "apple",
1478
+ // Stable job id feeds queue-level dedup (BullMQ jobId). Random
1479
+ // fallback when Apple omits the UUID — no dedup possible anyway.
1480
+ eventId: typeof payload.notificationUUID === "string" ? payload.notificationUUID : randomUUID(),
1481
+ payload: work
1482
+ });
1483
+ res.status(200).json({ received: true, queued: true });
1484
+ } catch (err2) {
1485
+ log.error("[onesub/webhook/apple] Failed to enqueue webhook job:", err2);
1486
+ await unmarkWebhookEvent(webhookEventStore, "apple", markedEventId);
1487
+ sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to update subscription");
1397
1488
  }
1489
+ return;
1490
+ }
1491
+ try {
1492
+ await processAppleNotification(work, config, store, purchaseStore);
1493
+ res.status(200).json({ received: true });
1494
+ } catch (err2) {
1495
+ log.error("[onesub/webhook/apple] Store update error:", err2);
1496
+ await unmarkWebhookEvent(webhookEventStore, "apple", markedEventId);
1398
1497
  sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to update subscription");
1399
1498
  }
1400
1499
  }
@@ -1426,7 +1525,117 @@ async function verifyGooglePushToken(req, expectedAudience, expectedServiceAccou
1426
1525
  return false;
1427
1526
  }
1428
1527
  }
1429
- async function handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore) {
1528
+ var GOOGLE_FAILURE_MESSAGES = {
1529
+ voided: {
1530
+ logPrefix: "[onesub/webhook/google] voided notification error:",
1531
+ message: "Failed to process voided notification"
1532
+ },
1533
+ oneTimeProduct: {
1534
+ logPrefix: "[onesub/webhook/google] oneTimeProduct error:",
1535
+ message: "Failed to process oneTimeProduct notification"
1536
+ },
1537
+ subscription: {
1538
+ logPrefix: "[onesub/webhook/google] Error handling notification:",
1539
+ message: "Failed to process notification"
1540
+ }
1541
+ };
1542
+ async function processGoogleNotification(work, config, store, purchaseStore) {
1543
+ if (work.kind === "voided") {
1544
+ const { voided } = work;
1545
+ if (voided.productType === 1) {
1546
+ const existing2 = await store.getByTransactionId(voided.purchaseToken);
1547
+ if (existing2) {
1548
+ const updated = config.refundPolicy === "until_expiry" ? { ...existing2, willRenew: false } : { ...existing2, status: SUBSCRIPTION_STATUS.CANCELED };
1549
+ await store.save(updated);
1550
+ } else {
1551
+ log.warn("[onesub/webhook/google] voided subscription for unknown purchaseToken:", voided.purchaseToken);
1552
+ }
1553
+ } else {
1554
+ const removed = await purchaseStore.deletePurchaseByTransactionId(voided.orderId);
1555
+ if (!removed) {
1556
+ log.warn("[onesub/webhook/google] voided IAP for unknown orderId:", voided.orderId);
1557
+ }
1558
+ }
1559
+ return;
1560
+ }
1561
+ if (work.kind === "oneTimeProduct") {
1562
+ const { notificationType: notificationType2, purchaseToken: purchaseToken2, sku } = work.oneTimeProduct;
1563
+ if (notificationType2 === 1) {
1564
+ log.info("[onesub/webhook/google] oneTimeProduct PURCHASED:", sku);
1565
+ if (config.google?.serviceAccountKey && config.google.packageName) {
1566
+ void acknowledgeGoogleProduct(purchaseToken2, sku, config.google).catch(
1567
+ (err2) => log.warn(`[onesub/webhook/google] oneTimeProduct ack failed for SKU ${sku} \u2014 3-day auto-refund risk:`, err2)
1568
+ );
1569
+ }
1570
+ } else {
1571
+ log.info("[onesub/webhook/google] oneTimeProduct CANCELED (pre-completion):", sku);
1572
+ }
1573
+ return;
1574
+ }
1575
+ const { notificationType, purchaseToken, subscriptionId, packageName } = work.notification;
1576
+ const existing = await store.getByTransactionId(purchaseToken);
1577
+ let finalStatus;
1578
+ if (isGoogleActiveNotification(notificationType)) {
1579
+ finalStatus = SUBSCRIPTION_STATUS.ACTIVE;
1580
+ } else if (isGoogleGracePeriodNotification(notificationType)) {
1581
+ finalStatus = SUBSCRIPTION_STATUS.GRACE_PERIOD;
1582
+ } else if (isGoogleOnHoldNotification(notificationType)) {
1583
+ finalStatus = SUBSCRIPTION_STATUS.ON_HOLD;
1584
+ } else if (isGooglePausedNotification(notificationType)) {
1585
+ finalStatus = SUBSCRIPTION_STATUS.PAUSED;
1586
+ } else if (isGooglePriceChangeConfirmedNotification(notificationType)) {
1587
+ finalStatus = SUBSCRIPTION_STATUS.ACTIVE;
1588
+ } else if (isGoogleCanceledNotification(notificationType)) {
1589
+ finalStatus = SUBSCRIPTION_STATUS.CANCELED;
1590
+ } else if (isGoogleExpiredNotification(notificationType)) {
1591
+ finalStatus = SUBSCRIPTION_STATUS.EXPIRED;
1592
+ } else {
1593
+ finalStatus = existing?.status ?? SUBSCRIPTION_STATUS.ACTIVE;
1594
+ }
1595
+ if (isGooglePriceChangeConfirmedNotification(notificationType) && config.google?.onPriceChangeConfirmed) {
1596
+ const hook = config.google.onPriceChangeConfirmed;
1597
+ void Promise.resolve().then(() => hook({ purchaseToken, subscriptionId, packageName })).catch((err2) => log.warn("[onesub/webhook/google] onPriceChangeConfirmed hook failed:", err2));
1598
+ }
1599
+ if (existing) {
1600
+ let updated = { ...existing, status: finalStatus };
1601
+ if (config.google?.serviceAccountKey) {
1602
+ const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1603
+ if (fresh) {
1604
+ const preserveNotificationStatus = isGoogleGracePeriodNotification(notificationType) || isGoogleOnHoldNotification(notificationType);
1605
+ updated = {
1606
+ ...existing,
1607
+ status: preserveNotificationStatus ? finalStatus : fresh.status,
1608
+ expiresAt: fresh.expiresAt,
1609
+ willRenew: fresh.willRenew,
1610
+ autoResumeTime: fresh.autoResumeTime,
1611
+ linkedPurchaseToken: fresh.linkedPurchaseToken ?? existing.linkedPurchaseToken
1612
+ };
1613
+ }
1614
+ }
1615
+ await store.save(updated);
1616
+ } else {
1617
+ if (config.google?.serviceAccountKey) {
1618
+ const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1619
+ if (fresh) {
1620
+ const boundAccountId = fresh.boundAccountId;
1621
+ delete fresh.boundAccountId;
1622
+ if (fresh.linkedPurchaseToken) {
1623
+ const previous = await store.getByTransactionId(fresh.linkedPurchaseToken);
1624
+ fresh.userId = previous ? previous.userId : boundAccountId ?? purchaseToken;
1625
+ if (previous) {
1626
+ log.info(`[onesub/webhook/google] inherited userId ${previous.userId} from linkedPurchaseToken ${fresh.linkedPurchaseToken} \u2192 new token ${purchaseToken}`);
1627
+ }
1628
+ } else {
1629
+ fresh.userId = boundAccountId ?? purchaseToken;
1630
+ }
1631
+ await store.save(fresh);
1632
+ }
1633
+ } else {
1634
+ log.warn("[onesub/webhook/google] Unknown purchase token and no serviceAccountKey to re-fetch:", purchaseToken);
1635
+ }
1636
+ }
1637
+ }
1638
+ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue) {
1430
1639
  if (config.google?.pushAudience) {
1431
1640
  const authenticated = await verifyGooglePushToken(
1432
1641
  req,
@@ -1443,14 +1652,7 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1443
1652
  sendError(res, 400, ONESUB_ERROR_CODE.MISSING_MESSAGE_DATA, "Missing message.data");
1444
1653
  return;
1445
1654
  }
1446
- const unmarkEvent = async () => {
1447
- if (webhookEventStore?.unmark && typeof body.message?.messageId === "string") {
1448
- try {
1449
- await webhookEventStore.unmark("google", body.message.messageId);
1450
- } catch {
1451
- }
1452
- }
1453
- };
1655
+ let markedEventId;
1454
1656
  if (webhookEventStore && typeof body.message.messageId === "string") {
1455
1657
  const fresh = await webhookEventStore.markIfNew("google", body.message.messageId);
1456
1658
  if (!fresh) {
@@ -1458,7 +1660,9 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1458
1660
  res.status(200).json({ received: true, deduped: true });
1459
1661
  return;
1460
1662
  }
1663
+ markedEventId = body.message.messageId;
1461
1664
  }
1665
+ let work;
1462
1666
  const voided = decodeGoogleVoidedNotification(body);
1463
1667
  if (voided) {
1464
1668
  if (config.google?.packageName && voided.packageName !== config.google.packageName) {
@@ -1466,147 +1670,77 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1466
1670
  sendError(res, 400, ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1467
1671
  return;
1468
1672
  }
1469
- try {
1470
- if (voided.productType === 1) {
1471
- const existing = await store.getByTransactionId(voided.purchaseToken);
1472
- if (existing) {
1473
- const updated = config.refundPolicy === "until_expiry" ? { ...existing, willRenew: false } : { ...existing, status: SUBSCRIPTION_STATUS.CANCELED };
1474
- await store.save(updated);
1475
- } else {
1476
- log.warn("[onesub/webhook/google] voided subscription for unknown purchaseToken:", voided.purchaseToken);
1477
- }
1478
- } else {
1479
- const removed = await purchaseStore.deletePurchaseByTransactionId(voided.orderId);
1480
- if (!removed) {
1481
- log.warn("[onesub/webhook/google] voided IAP for unknown orderId:", voided.orderId);
1482
- }
1673
+ work = { kind: "voided", voided };
1674
+ } else {
1675
+ const oneTimeProduct = decodeGoogleOneTimeProductNotification(body);
1676
+ if (oneTimeProduct) {
1677
+ if (config.google?.packageName && oneTimeProduct.packageName !== config.google.packageName) {
1678
+ log.warn("[onesub/webhook/google] oneTimeProduct package name mismatch:", oneTimeProduct.packageName, "!==", config.google.packageName);
1679
+ sendError(res, 400, ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1680
+ return;
1483
1681
  }
1484
- res.status(200).json({ received: true });
1485
- } catch (err) {
1486
- log.error("[onesub/webhook/google] voided notification error:", err);
1487
- await unmarkEvent();
1488
- sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process voided notification");
1682
+ work = { kind: "oneTimeProduct", oneTimeProduct };
1683
+ } else {
1684
+ const notification = decodeGoogleNotification(body);
1685
+ if (!notification) {
1686
+ res.status(200).json({ received: true });
1687
+ return;
1688
+ }
1689
+ if (config.google?.packageName && notification.packageName !== config.google.packageName) {
1690
+ log.warn("[onesub/webhook/google] Package name mismatch:", notification.packageName, "!==", config.google.packageName);
1691
+ sendError(res, 400, ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1692
+ return;
1693
+ }
1694
+ work = { kind: "subscription", notification };
1489
1695
  }
1490
- return;
1491
1696
  }
1492
- const oneTimeProduct = decodeGoogleOneTimeProductNotification(body);
1493
- if (oneTimeProduct) {
1494
- if (config.google?.packageName && oneTimeProduct.packageName !== config.google.packageName) {
1495
- log.warn("[onesub/webhook/google] oneTimeProduct package name mismatch:", oneTimeProduct.packageName, "!==", config.google.packageName);
1496
- sendError(res, 400, ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1497
- return;
1498
- }
1697
+ if (webhookQueue) {
1499
1698
  try {
1500
- const { notificationType: notificationType2, purchaseToken: purchaseToken2, sku } = oneTimeProduct;
1501
- if (notificationType2 === 1) {
1502
- log.info("[onesub/webhook/google] oneTimeProduct PURCHASED:", sku);
1503
- if (config.google?.serviceAccountKey && config.google.packageName) {
1504
- void acknowledgeGoogleProduct(purchaseToken2, sku, config.google).catch(
1505
- (err) => log.warn(`[onesub/webhook/google] oneTimeProduct ack failed for SKU ${sku} \u2014 3-day auto-refund risk:`, err)
1506
- );
1507
- }
1508
- } else {
1509
- log.info("[onesub/webhook/google] oneTimeProduct CANCELED (pre-completion):", sku);
1510
- }
1511
- res.status(200).json({ received: true });
1512
- } catch (err) {
1513
- log.error("[onesub/webhook/google] oneTimeProduct error:", err);
1514
- await unmarkEvent();
1515
- sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process oneTimeProduct notification");
1699
+ await webhookQueue.enqueue({
1700
+ provider: "google",
1701
+ // Stable job id feeds queue-level dedup (BullMQ jobId). Random
1702
+ // fallback when the push lacks a messageId — no dedup possible anyway.
1703
+ eventId: typeof body.message.messageId === "string" ? body.message.messageId : randomUUID(),
1704
+ payload: work
1705
+ });
1706
+ res.status(200).json({ received: true, queued: true });
1707
+ } catch (err2) {
1708
+ log.error("[onesub/webhook/google] Failed to enqueue webhook job:", err2);
1709
+ await unmarkWebhookEvent(webhookEventStore, "google", markedEventId);
1710
+ sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, GOOGLE_FAILURE_MESSAGES[work.kind].message);
1516
1711
  }
1517
1712
  return;
1518
1713
  }
1519
- const notification = decodeGoogleNotification(body);
1520
- if (!notification) {
1521
- res.status(200).json({ received: true });
1522
- return;
1523
- }
1524
- const { notificationType, purchaseToken, subscriptionId, packageName } = notification;
1525
- if (config.google?.packageName && packageName !== config.google.packageName) {
1526
- log.warn("[onesub/webhook/google] Package name mismatch:", packageName, "!==", config.google.packageName);
1527
- sendError(res, 400, ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1528
- return;
1529
- }
1530
1714
  try {
1531
- const existing = await store.getByTransactionId(purchaseToken);
1532
- let finalStatus;
1533
- if (isGoogleActiveNotification(notificationType)) {
1534
- finalStatus = SUBSCRIPTION_STATUS.ACTIVE;
1535
- } else if (isGoogleGracePeriodNotification(notificationType)) {
1536
- finalStatus = SUBSCRIPTION_STATUS.GRACE_PERIOD;
1537
- } else if (isGoogleOnHoldNotification(notificationType)) {
1538
- finalStatus = SUBSCRIPTION_STATUS.ON_HOLD;
1539
- } else if (isGooglePausedNotification(notificationType)) {
1540
- finalStatus = SUBSCRIPTION_STATUS.PAUSED;
1541
- } else if (isGooglePriceChangeConfirmedNotification(notificationType)) {
1542
- finalStatus = SUBSCRIPTION_STATUS.ACTIVE;
1543
- } else if (isGoogleCanceledNotification(notificationType)) {
1544
- finalStatus = SUBSCRIPTION_STATUS.CANCELED;
1545
- } else if (isGoogleExpiredNotification(notificationType)) {
1546
- finalStatus = SUBSCRIPTION_STATUS.EXPIRED;
1547
- } else {
1548
- finalStatus = existing?.status ?? SUBSCRIPTION_STATUS.ACTIVE;
1549
- }
1550
- if (isGooglePriceChangeConfirmedNotification(notificationType) && config.google?.onPriceChangeConfirmed) {
1551
- const hook = config.google.onPriceChangeConfirmed;
1552
- void Promise.resolve().then(() => hook({ purchaseToken, subscriptionId, packageName })).catch((err) => log.warn("[onesub/webhook/google] onPriceChangeConfirmed hook failed:", err));
1553
- }
1554
- if (existing) {
1555
- let updated = { ...existing, status: finalStatus };
1556
- if (config.google?.serviceAccountKey) {
1557
- const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1558
- if (fresh) {
1559
- const preserveNotificationStatus = isGoogleGracePeriodNotification(notificationType) || isGoogleOnHoldNotification(notificationType);
1560
- updated = {
1561
- ...existing,
1562
- status: preserveNotificationStatus ? finalStatus : fresh.status,
1563
- expiresAt: fresh.expiresAt,
1564
- willRenew: fresh.willRenew,
1565
- autoResumeTime: fresh.autoResumeTime,
1566
- linkedPurchaseToken: fresh.linkedPurchaseToken ?? existing.linkedPurchaseToken
1567
- };
1568
- }
1569
- }
1570
- await store.save(updated);
1571
- } else {
1572
- if (config.google?.serviceAccountKey) {
1573
- const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1574
- if (fresh) {
1575
- const boundAccountId = fresh.boundAccountId;
1576
- delete fresh.boundAccountId;
1577
- if (fresh.linkedPurchaseToken) {
1578
- const previous = await store.getByTransactionId(fresh.linkedPurchaseToken);
1579
- fresh.userId = previous ? previous.userId : boundAccountId ?? purchaseToken;
1580
- if (previous) {
1581
- log.info(`[onesub/webhook/google] inherited userId ${previous.userId} from linkedPurchaseToken ${fresh.linkedPurchaseToken} \u2192 new token ${purchaseToken}`);
1582
- }
1583
- } else {
1584
- fresh.userId = boundAccountId ?? purchaseToken;
1585
- }
1586
- await store.save(fresh);
1587
- }
1588
- } else {
1589
- log.warn("[onesub/webhook/google] Unknown purchase token and no serviceAccountKey to re-fetch:", purchaseToken);
1590
- }
1591
- }
1715
+ await processGoogleNotification(work, config, store, purchaseStore);
1592
1716
  res.status(200).json({ received: true });
1593
- } catch (err) {
1594
- log.error("[onesub/webhook/google] Error handling notification:", err);
1595
- await unmarkEvent();
1596
- sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process notification");
1717
+ } catch (err2) {
1718
+ const { logPrefix, message } = GOOGLE_FAILURE_MESSAGES[work.kind];
1719
+ log.error(logPrefix, err2);
1720
+ await unmarkWebhookEvent(webhookEventStore, "google", markedEventId);
1721
+ sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, message);
1597
1722
  }
1598
1723
  }
1599
1724
 
1600
1725
  // src/routes/webhook.ts
1601
- function createWebhookRouter(config, store, purchaseStore, webhookEventStore) {
1726
+ function createWebhookRouter(config, store, purchaseStore, webhookEventStore, webhookQueue) {
1727
+ if (webhookQueue) {
1728
+ webhookQueue.setHandler(async (job) => {
1729
+ if (job.provider === "apple") {
1730
+ await processAppleNotification(job.payload, config, store, purchaseStore);
1731
+ } else {
1732
+ await processGoogleNotification(job.payload, config, store, purchaseStore);
1733
+ }
1734
+ });
1735
+ }
1602
1736
  const router = Router();
1603
1737
  router.post(
1604
1738
  ROUTES.WEBHOOK_APPLE,
1605
- (req, res) => handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore)
1739
+ (req, res) => handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue)
1606
1740
  );
1607
1741
  router.post(
1608
1742
  ROUTES.WEBHOOK_GOOGLE,
1609
- (req, res) => handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore)
1743
+ (req, res) => handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue)
1610
1744
  );
1611
1745
  return router;
1612
1746
  }
@@ -1628,12 +1762,12 @@ function createPurchaseRouter(config, purchaseStore) {
1628
1762
  let body;
1629
1763
  try {
1630
1764
  body = validatePurchaseSchema.parse(req.body);
1631
- } catch (err) {
1632
- if (err instanceof z.ZodError) {
1633
- sendZodError(res, err, NO_PURCHASE);
1765
+ } catch (err2) {
1766
+ if (err2 instanceof z.ZodError) {
1767
+ sendZodError(res, err2, NO_PURCHASE);
1634
1768
  return;
1635
1769
  }
1636
- throw err;
1770
+ throw err2;
1637
1771
  }
1638
1772
  const { platform, receipt, userId, productId, type } = body;
1639
1773
  try {
@@ -1752,8 +1886,8 @@ function createPurchaseRouter(config, purchaseStore) {
1752
1886
  action
1753
1887
  };
1754
1888
  res.status(200).json(response);
1755
- } catch (err) {
1756
- log.error("[onesub/purchase/validate] Unexpected error:", err);
1889
+ } catch (err2) {
1890
+ log.error("[onesub/purchase/validate] Unexpected error:", err2);
1757
1891
  sendError(res, 500, ONESUB_ERROR_CODE.INTERNAL_ERROR, "Internal server error during purchase validation", NO_PURCHASE);
1758
1892
  }
1759
1893
  });
@@ -1761,12 +1895,12 @@ function createPurchaseRouter(config, purchaseStore) {
1761
1895
  let query;
1762
1896
  try {
1763
1897
  query = purchaseStatusQuerySchema.parse(req.query);
1764
- } catch (err) {
1765
- if (err instanceof z.ZodError) {
1766
- sendZodError(res, err, { purchases: [] });
1898
+ } catch (err2) {
1899
+ if (err2 instanceof z.ZodError) {
1900
+ sendZodError(res, err2, { purchases: [] });
1767
1901
  return;
1768
1902
  }
1769
- throw err;
1903
+ throw err2;
1770
1904
  }
1771
1905
  const { userId, productId } = query;
1772
1906
  try {
@@ -1776,8 +1910,8 @@ function createPurchaseRouter(config, purchaseStore) {
1776
1910
  }
1777
1911
  const response = { purchases };
1778
1912
  res.status(200).json(response);
1779
- } catch (err) {
1780
- log.error("[onesub/purchase/status] Store error:", err);
1913
+ } catch (err2) {
1914
+ log.error("[onesub/purchase/status] Store error:", err2);
1781
1915
  sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error", { purchases: [] });
1782
1916
  }
1783
1917
  });
@@ -1838,8 +1972,8 @@ function createEntitlementRouter(config, store, purchaseStore) {
1838
1972
  const status = await evaluateEntitlement(userId, entitlement, store, purchaseStore);
1839
1973
  const response = { id, ...status };
1840
1974
  res.status(200).json(response);
1841
- } catch (err) {
1842
- log.error("[onesub/entitlement] evaluation error:", err);
1975
+ } catch (err2) {
1976
+ log.error("[onesub/entitlement] evaluation error:", err2);
1843
1977
  sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
1844
1978
  }
1845
1979
  });
@@ -1862,13 +1996,19 @@ function createEntitlementRouter(config, store, purchaseStore) {
1862
1996
  entitlements: Object.fromEntries(entries)
1863
1997
  };
1864
1998
  res.status(200).json(response);
1865
- } catch (err) {
1866
- log.error("[onesub/entitlements] evaluation error:", err);
1999
+ } catch (err2) {
2000
+ log.error("[onesub/entitlements] evaluation error:", err2);
1867
2001
  sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error", { entitlements: {} });
1868
2002
  }
1869
2003
  });
1870
2004
  return router;
1871
2005
  }
2006
+ function secretsEqual(provided, expected) {
2007
+ const providedBuf = Buffer.from(provided, "utf8");
2008
+ const expectedBuf = Buffer.from(expected, "utf8");
2009
+ if (providedBuf.length !== expectedBuf.length) return false;
2010
+ return timingSafeEqual(providedBuf, expectedBuf);
2011
+ }
1872
2012
 
1873
2013
  // src/routes/admin.ts
1874
2014
  var ADMIN_SECRET_HEADER = "x-admin-secret";
@@ -1878,7 +2018,7 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
1878
2018
  const adminSecret = config.adminSecret;
1879
2019
  const adminAuth = (req, res, next) => {
1880
2020
  const provided = req.headers[ADMIN_SECRET_HEADER];
1881
- if (typeof provided !== "string" || provided !== adminSecret) {
2021
+ if (typeof provided !== "string" || !secretsEqual(provided, adminSecret)) {
1882
2022
  sendError(res, 401, ONESUB_ERROR_CODE.INVALID_ADMIN_SECRET, "INVALID_ADMIN_SECRET");
1883
2023
  return;
1884
2024
  }
@@ -1909,12 +2049,12 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
1909
2049
  let body;
1910
2050
  try {
1911
2051
  body = transferSchema.parse(req.body);
1912
- } catch (err) {
1913
- if (err instanceof z.ZodError) {
1914
- sendZodError(res, err);
2052
+ } catch (err2) {
2053
+ if (err2 instanceof z.ZodError) {
2054
+ sendZodError(res, err2);
1915
2055
  return;
1916
2056
  }
1917
- throw err;
2057
+ throw err2;
1918
2058
  }
1919
2059
  const existing = await purchaseStore.getPurchaseByTransactionId(body.transactionId);
1920
2060
  if (!existing) {
@@ -1940,12 +2080,12 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
1940
2080
  let body;
1941
2081
  try {
1942
2082
  body = grantSchema.parse(req.body);
1943
- } catch (err) {
1944
- if (err instanceof z.ZodError) {
1945
- sendZodError(res, err);
2083
+ } catch (err2) {
2084
+ if (err2 instanceof z.ZodError) {
2085
+ sendZodError(res, err2);
1946
2086
  return;
1947
2087
  }
1948
- throw err;
2088
+ throw err2;
1949
2089
  }
1950
2090
  const transactionId = body.transactionId ?? `admin_grant_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
1951
2091
  const purchase = {
@@ -1981,12 +2121,12 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
1981
2121
  let query;
1982
2122
  try {
1983
2123
  query = listQuerySchema.parse(req.query);
1984
- } catch (err) {
1985
- if (err instanceof z.ZodError) {
1986
- sendZodError(res, err);
2124
+ } catch (err2) {
2125
+ if (err2 instanceof z.ZodError) {
2126
+ sendZodError(res, err2);
1987
2127
  return;
1988
2128
  }
1989
- throw err;
2129
+ throw err2;
1990
2130
  }
1991
2131
  try {
1992
2132
  const result = await store.listFiltered(query);
@@ -1997,8 +2137,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
1997
2137
  offset: result.offset
1998
2138
  };
1999
2139
  res.status(200).json(response);
2000
- } catch (err) {
2001
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "list error");
2140
+ } catch (err2) {
2141
+ sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "list error");
2002
2142
  }
2003
2143
  });
2004
2144
  const detailParamsSchema = z.object({
@@ -2019,8 +2159,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2019
2159
  return;
2020
2160
  }
2021
2161
  res.status(200).json(sub);
2022
- } catch (err) {
2023
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "detail error");
2162
+ } catch (err2) {
2163
+ sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "detail error");
2024
2164
  }
2025
2165
  });
2026
2166
  const customerParamsSchema = z.object({
@@ -2053,8 +2193,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2053
2193
  ...entitlements ? { entitlements } : {}
2054
2194
  };
2055
2195
  res.status(200).json(response);
2056
- } catch (err) {
2057
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "customer error");
2196
+ } catch (err2) {
2197
+ sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "customer error");
2058
2198
  }
2059
2199
  });
2060
2200
  const syncAppleParamsSchema = z.object({
@@ -2085,8 +2225,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2085
2225
  fresh.userId = existing?.userId ?? originalTransactionId;
2086
2226
  await store.save(fresh);
2087
2227
  res.status(200).json({ ok: true, subscription: fresh });
2088
- } catch (err) {
2089
- sendError(res, 500, ONESUB_ERROR_CODE.INTERNAL_ERROR, err.message ?? "sync error");
2228
+ } catch (err2) {
2229
+ sendError(res, 500, ONESUB_ERROR_CODE.INTERNAL_ERROR, err2.message ?? "sync error");
2090
2230
  }
2091
2231
  });
2092
2232
  if (webhookQueue?.listDeadLetters) {
@@ -2094,8 +2234,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2094
2234
  try {
2095
2235
  const items = await webhookQueue.listDeadLetters();
2096
2236
  res.status(200).json({ items });
2097
- } catch (err) {
2098
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "list error");
2237
+ } catch (err2) {
2238
+ sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "list error");
2099
2239
  }
2100
2240
  });
2101
2241
  }
@@ -2112,8 +2252,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2112
2252
  try {
2113
2253
  await webhookQueue.replayDeadLetter(params.id);
2114
2254
  res.status(200).json({ ok: true });
2115
- } catch (err) {
2116
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "replay error");
2255
+ } catch (err2) {
2256
+ sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "replay error");
2117
2257
  }
2118
2258
  });
2119
2259
  }
@@ -2126,7 +2266,7 @@ function createMetricsRouter(config, store, purchaseStore) {
2126
2266
  const adminSecret = config.adminSecret;
2127
2267
  router.use("/onesub/metrics", (req, res, next) => {
2128
2268
  const provided = req.headers[ADMIN_SECRET_HEADER2];
2129
- if (typeof provided !== "string" || provided !== adminSecret) {
2269
+ if (typeof provided !== "string" || !secretsEqual(provided, adminSecret)) {
2130
2270
  sendError(res, 401, ONESUB_ERROR_CODE.INVALID_ADMIN_SECRET, "INVALID_ADMIN_SECRET");
2131
2271
  return;
2132
2272
  }
@@ -2177,8 +2317,8 @@ function createMetricsRouter(config, store, purchaseStore) {
2177
2317
  byPlatform
2178
2318
  };
2179
2319
  res.status(200).json(response);
2180
- } catch (err) {
2181
- log.error("[onesub/metrics/active] error:", err);
2320
+ } catch (err2) {
2321
+ log.error("[onesub/metrics/active] error:", err2);
2182
2322
  sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2183
2323
  }
2184
2324
  });
@@ -2187,6 +2327,8 @@ function createMetricsRouter(config, store, purchaseStore) {
2187
2327
  to: z.string().min(1),
2188
2328
  groupBy: z.enum(["none", "day"]).optional()
2189
2329
  });
2330
+ const MAX_DAY_BUCKET_SPAN_DAYS = 366;
2331
+ const MAX_DAY_BUCKET_SPAN_MS = MAX_DAY_BUCKET_SPAN_DAYS * 864e5;
2190
2332
  function parseRange(req) {
2191
2333
  const parsed = rangeSchema.safeParse(req.query);
2192
2334
  if (!parsed.success) return { error: "from and to are required (ISO 8601)" };
@@ -2196,7 +2338,13 @@ function createMetricsRouter(config, store, purchaseStore) {
2196
2338
  return { error: "from / to must be ISO 8601 timestamps" };
2197
2339
  }
2198
2340
  if (fromMs > toMs) return { error: "from must be \u2264 to" };
2199
- return { fromMs, toMs, groupBy: parsed.data.groupBy ?? "none" };
2341
+ const groupBy = parsed.data.groupBy ?? "none";
2342
+ if (groupBy === "day" && toMs - fromMs > MAX_DAY_BUCKET_SPAN_MS) {
2343
+ return {
2344
+ error: `groupBy=day supports a range of at most ${MAX_DAY_BUCKET_SPAN_DAYS} days \u2014 narrow the from/to window or omit groupBy`
2345
+ };
2346
+ }
2347
+ return { fromMs, toMs, groupBy };
2200
2348
  }
2201
2349
  function utcDateKey(ms) {
2202
2350
  const d = new Date(ms);
@@ -2249,8 +2397,8 @@ function createMetricsRouter(config, store, purchaseStore) {
2249
2397
  ...buckets ? { buckets } : {}
2250
2398
  };
2251
2399
  res.status(200).json(response);
2252
- } catch (err) {
2253
- log.error("[onesub/metrics/started] error:", err);
2400
+ } catch (err2) {
2401
+ log.error("[onesub/metrics/started] error:", err2);
2254
2402
  sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2255
2403
  }
2256
2404
  });
@@ -2288,8 +2436,8 @@ function createMetricsRouter(config, store, purchaseStore) {
2288
2436
  ...buckets ? { buckets } : {}
2289
2437
  };
2290
2438
  res.status(200).json(response);
2291
- } catch (err) {
2292
- log.error("[onesub/metrics/expired] error:", err);
2439
+ } catch (err2) {
2440
+ log.error("[onesub/metrics/expired] error:", err2);
2293
2441
  sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2294
2442
  }
2295
2443
  });
@@ -2327,8 +2475,8 @@ function createMetricsRouter(config, store, purchaseStore) {
2327
2475
  ...buckets ? { buckets } : {}
2328
2476
  };
2329
2477
  res.status(200).json(response);
2330
- } catch (err) {
2331
- log.error("[onesub/metrics/purchases/started] error:", err);
2478
+ } catch (err2) {
2479
+ log.error("[onesub/metrics/purchases/started] error:", err2);
2332
2480
  sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2333
2481
  }
2334
2482
  });
@@ -2347,7 +2495,7 @@ function createAppleOfferRouter(config) {
2347
2495
  router.post("/onesub/apple/offer-signature", async (req, res) => {
2348
2496
  if (config.adminSecret) {
2349
2497
  const provided = req.headers[OFFER_SECRET_HEADER];
2350
- if (typeof provided !== "string" || provided !== config.adminSecret) {
2498
+ if (typeof provided !== "string" || !secretsEqual(provided, config.adminSecret)) {
2351
2499
  sendError(res, 401, ONESUB_ERROR_CODE.UNAUTHORIZED, "Unauthorized");
2352
2500
  return;
2353
2501
  }
@@ -2355,12 +2503,12 @@ function createAppleOfferRouter(config) {
2355
2503
  let body;
2356
2504
  try {
2357
2505
  body = offerBodySchema.parse(req.body);
2358
- } catch (err) {
2359
- if (err instanceof z.ZodError) {
2360
- sendZodError(res, err);
2506
+ } catch (err2) {
2507
+ if (err2 instanceof z.ZodError) {
2508
+ sendZodError(res, err2);
2361
2509
  return;
2362
2510
  }
2363
- throw err;
2511
+ throw err2;
2364
2512
  }
2365
2513
  if (!apple.bundleId) {
2366
2514
  sendError(res, 400, ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "config.apple.bundleId is required for offer signing");
@@ -2377,8 +2525,8 @@ function createAppleOfferRouter(config) {
2377
2525
  apple
2378
2526
  );
2379
2527
  res.status(200).json(result);
2380
- } catch (err) {
2381
- sendError(res, 500, ONESUB_ERROR_CODE.INTERNAL_ERROR, err.message ?? "Offer signing failed");
2528
+ } catch (err2) {
2529
+ sendError(res, 500, ONESUB_ERROR_CODE.INTERNAL_ERROR, err2.message ?? "Offer signing failed");
2382
2530
  }
2383
2531
  });
2384
2532
  return router;
@@ -2442,6 +2590,17 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_onesub_purchases_non_consumable
2442
2590
  `.trim();
2443
2591
 
2444
2592
  // src/stores/postgres.ts
2593
+ async function createPgPool(connectionString, label) {
2594
+ const pg = await import('pg').catch(() => {
2595
+ throw new Error(`[onesub] ${label} requires the \`pg\` package. Run: npm install pg`);
2596
+ });
2597
+ const Pool = pg.default?.Pool ?? pg.Pool;
2598
+ const pool = new Pool({ connectionString, max: 10 });
2599
+ pool.on("error", (err2) => {
2600
+ log.error(`[onesub] ${label} pool error (idle client):`, err2);
2601
+ });
2602
+ return pool;
2603
+ }
2445
2604
  var PostgresSubscriptionStore = class {
2446
2605
  constructor(connectionString) {
2447
2606
  this.connectionString = connectionString;
@@ -2451,19 +2610,7 @@ var PostgresSubscriptionStore = class {
2451
2610
  poolPromise = null;
2452
2611
  getPool() {
2453
2612
  if (!this.poolPromise) {
2454
- this.poolPromise = (async () => {
2455
- const pg = await import('pg').catch(() => {
2456
- throw new Error(
2457
- "[onesub] PostgresSubscriptionStore requires the `pg` package. Run: npm install pg"
2458
- );
2459
- });
2460
- const Pool = pg.default?.Pool ?? pg.Pool;
2461
- const pool = new Pool({ connectionString: this.connectionString, max: 10 });
2462
- pool.on("error", (err) => {
2463
- log.error("[onesub] PostgresSubscriptionStore pool error (idle client):", err);
2464
- });
2465
- return pool;
2466
- })();
2613
+ this.poolPromise = createPgPool(this.connectionString, "PostgresSubscriptionStore");
2467
2614
  }
2468
2615
  return this.poolPromise;
2469
2616
  }
@@ -2627,19 +2774,7 @@ var PostgresPurchaseStore = class {
2627
2774
  poolPromise = null;
2628
2775
  getPool() {
2629
2776
  if (!this.poolPromise) {
2630
- this.poolPromise = (async () => {
2631
- const pg = await import('pg').catch(() => {
2632
- throw new Error(
2633
- "[onesub] PostgresPurchaseStore requires the `pg` package. Run: npm install pg"
2634
- );
2635
- });
2636
- const Pool = pg.default?.Pool ?? pg.Pool;
2637
- const pool = new Pool({ connectionString: this.connectionString, max: 10 });
2638
- pool.on("error", (err) => {
2639
- log.error("[onesub] PostgresPurchaseStore pool error (idle client):", err);
2640
- });
2641
- return pool;
2642
- })();
2777
+ this.poolPromise = createPgPool(this.connectionString, "PostgresPurchaseStore");
2643
2778
  }
2644
2779
  return this.poolPromise;
2645
2780
  }
@@ -2676,9 +2811,9 @@ var PostgresPurchaseStore = class {
2676
2811
  );
2677
2812
  const owner = existing.rows[0]?.user_id;
2678
2813
  if (owner !== void 0 && owner !== purchase.userId) {
2679
- const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2680
- err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2681
- throw err;
2814
+ const err2 = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2815
+ err2.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2816
+ throw err2;
2682
2817
  }
2683
2818
  }
2684
2819
  async getPurchasesByUserId(userId) {
@@ -2779,6 +2914,7 @@ function rowToPurchaseInfo(row) {
2779
2914
 
2780
2915
  // src/stores/redis.ts
2781
2916
  var SUB_TX_PREFIX = "onesub:sub:tx:";
2917
+ var SUB_OWNER_PREFIX = "onesub:sub:owner:";
2782
2918
  var SUB_USER_PREFIX = "onesub:sub:user:";
2783
2919
  var SUB_ALL_SORTED = "onesub:sub:all:sorted";
2784
2920
  var PUR_TX_PREFIX = "onesub:purchase:tx:";
@@ -2793,11 +2929,16 @@ var RedisSubscriptionStore = class {
2793
2929
  async save(sub) {
2794
2930
  const score = Date.now();
2795
2931
  const txKey = SUB_TX_PREFIX + sub.originalTransactionId;
2932
+ const ownerKey = SUB_OWNER_PREFIX + sub.originalTransactionId;
2796
2933
  const userKey = SUB_USER_PREFIX + sub.userId;
2797
- const prevRaw = await this.redis.get(txKey);
2798
- const prevUserId = prevRaw ? JSON.parse(prevRaw).userId : null;
2934
+ let prevUserId = await this.redis.get(ownerKey);
2935
+ if (prevUserId === null) {
2936
+ const prevRaw = await this.redis.get(txKey);
2937
+ prevUserId = prevRaw ? JSON.parse(prevRaw).userId : null;
2938
+ }
2799
2939
  const pipeline = this.redis.multi();
2800
2940
  pipeline.set(txKey, JSON.stringify(sub));
2941
+ pipeline.set(ownerKey, sub.userId);
2801
2942
  if (prevUserId !== null && prevUserId !== sub.userId) {
2802
2943
  pipeline.zrem(SUB_USER_PREFIX + prevUserId, sub.originalTransactionId);
2803
2944
  }
@@ -2878,9 +3019,9 @@ var RedisPurchaseStore = class {
2878
3019
  const existing = await this.redis.get(txKey);
2879
3020
  const owner = existing ? JSON.parse(existing).userId : null;
2880
3021
  if (owner !== null && owner !== purchase.userId) {
2881
- const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2882
- err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2883
- throw err;
3022
+ const err2 = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
3023
+ err2.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
3024
+ throw err2;
2884
3025
  }
2885
3026
  }
2886
3027
  const score = Date.parse(purchase.purchasedAt) || Date.now();
@@ -3000,50 +3141,6 @@ var RedisWebhookEventStore = class {
3000
3141
  }
3001
3142
  };
3002
3143
 
3003
- // src/webhook-events.ts
3004
- var DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60;
3005
- var InMemoryWebhookEventStore = class {
3006
- constructor(ttlSeconds = DEFAULT_TTL_SECONDS) {
3007
- this.ttlSeconds = ttlSeconds;
3008
- }
3009
- ttlSeconds;
3010
- seen = /* @__PURE__ */ new Map();
3011
- async markIfNew(provider, eventId) {
3012
- this.evictExpired();
3013
- const key = `${provider}:${eventId}`;
3014
- if (this.seen.has(key)) return false;
3015
- this.seen.set(key, Date.now() + this.ttlSeconds * 1e3);
3016
- return true;
3017
- }
3018
- async unmark(provider, eventId) {
3019
- this.seen.delete(`${provider}:${eventId}`);
3020
- }
3021
- evictExpired() {
3022
- const now = Date.now();
3023
- for (const [key, expiresAt] of this.seen) {
3024
- if (expiresAt < now) this.seen.delete(key);
3025
- }
3026
- }
3027
- };
3028
- var CacheWebhookEventStore = class {
3029
- constructor(cache, ttlSeconds = DEFAULT_TTL_SECONDS) {
3030
- this.cache = cache;
3031
- this.ttlSeconds = ttlSeconds;
3032
- }
3033
- cache;
3034
- ttlSeconds;
3035
- async markIfNew(provider, eventId) {
3036
- const key = `webhook:event:${provider}:${eventId}`;
3037
- const existing = await this.cache.get(key);
3038
- if (existing) return false;
3039
- await this.cache.set(key, "1", this.ttlSeconds);
3040
- return true;
3041
- }
3042
- async unmark(provider, eventId) {
3043
- await this.cache.del(`webhook:event:${provider}:${eventId}`);
3044
- }
3045
- };
3046
-
3047
3144
  // src/webhook-queue.ts
3048
3145
  var InProcessWebhookQueue = class {
3049
3146
  handler = null;
@@ -3068,6 +3165,13 @@ var BullMQWebhookQueue = class {
3068
3165
  queuePromise = null;
3069
3166
  workerPromise = null;
3070
3167
  handler = null;
3168
+ /**
3169
+ * Captured worker-startup failure (e.g. `bullmq` not installed). `setHandler`
3170
+ * is synchronous, so it can't reject — the error is surfaced on the next
3171
+ * `enqueue()` instead of becoming an unhandled rejection that kills the
3172
+ * process.
3173
+ */
3174
+ workerStartupError = null;
3071
3175
  constructor(opts) {
3072
3176
  this.connection = opts.connection;
3073
3177
  this.queueName = opts.queueName ?? "onesub-webhooks";
@@ -3084,7 +3188,11 @@ var BullMQWebhookQueue = class {
3084
3188
  if (!this.queuePromise) {
3085
3189
  this.queuePromise = (async () => {
3086
3190
  const { Queue } = await this.getBullMQ();
3087
- return new Queue(this.queueName, { connection: this.connection });
3191
+ const queue = new Queue(this.queueName, { connection: this.connection });
3192
+ queue.on?.("error", (err2) => {
3193
+ log.error("[onesub] BullMQ queue error:", err2);
3194
+ });
3195
+ return queue;
3088
3196
  })();
3089
3197
  }
3090
3198
  return this.queuePromise;
@@ -3094,7 +3202,7 @@ var BullMQWebhookQueue = class {
3094
3202
  if (!this.workerPromise) {
3095
3203
  this.workerPromise = (async () => {
3096
3204
  const { Worker } = await this.getBullMQ();
3097
- return new Worker(
3205
+ const worker = new Worker(
3098
3206
  this.queueName,
3099
3207
  async (job) => {
3100
3208
  if (!this.handler) throw new Error("[onesub] handler not set");
@@ -3105,10 +3213,19 @@ var BullMQWebhookQueue = class {
3105
3213
  concurrency: this.concurrency
3106
3214
  }
3107
3215
  );
3216
+ worker.on?.("error", (err2) => {
3217
+ log.error("[onesub] BullMQ worker error:", err2);
3218
+ });
3219
+ return worker;
3108
3220
  })();
3221
+ this.workerPromise.catch((err2) => {
3222
+ this.workerStartupError = err2 instanceof Error ? err2 : new Error(String(err2));
3223
+ log.error("[onesub] BullMQ worker failed to start:", err2);
3224
+ });
3109
3225
  }
3110
3226
  }
3111
3227
  async enqueue(job) {
3228
+ if (this.workerStartupError) throw this.workerStartupError;
3112
3229
  const queue = await this.getQueue();
3113
3230
  await queue.add("webhook", job, {
3114
3231
  attempts: this.maxAttempts,
@@ -3137,26 +3254,42 @@ var BullMQWebhookQueue = class {
3137
3254
  }
3138
3255
  async close() {
3139
3256
  if (this.workerPromise) {
3140
- const worker = await this.workerPromise;
3141
- await worker.close();
3257
+ const worker = await this.workerPromise.catch(() => null);
3258
+ if (worker) await worker.close();
3142
3259
  }
3143
3260
  if (this.queuePromise) {
3144
- const queue = await this.queuePromise;
3145
- await queue.close();
3261
+ const queue = await this.queuePromise.catch(() => null);
3262
+ if (queue) await queue.close();
3146
3263
  }
3147
3264
  }
3148
3265
  };
3266
+ var ERROR_CONTENT = {
3267
+ "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }
3268
+ };
3269
+ var err = (description) => ({ description, content: ERROR_CONTENT });
3270
+ var ADMIN_SECRET_PARAM = {
3271
+ in: "header",
3272
+ name: "X-Admin-Secret",
3273
+ required: true,
3274
+ schema: { type: "string" }
3275
+ };
3276
+ var METRICS_RANGE_PARAMS = [
3277
+ { in: "query", name: "from", required: true, schema: { type: "string", format: "date-time" }, description: "Window start (ISO 8601)." },
3278
+ { in: "query", name: "to", required: true, schema: { type: "string", format: "date-time" }, description: "Window end (ISO 8601)." },
3279
+ { in: "query", name: "groupBy", schema: { type: "string", enum: ["none", "day"] }, description: "'day' adds a zero-filled daily `buckets` series to the response." }
3280
+ ];
3149
3281
  var ONESUB_OPENAPI = {
3150
3282
  openapi: "3.1.0",
3151
3283
  info: {
3152
3284
  title: "onesub HTTP API",
3153
3285
  version: "1.0.0",
3154
- description: "Receipt validation, subscription status, webhooks, and admin endpoints exposed by `createOneSubMiddleware`."
3286
+ description: "Receipt validation, subscription status, one-time purchases, entitlements, webhooks, metrics, and admin endpoints exposed by `createOneSubMiddleware`."
3155
3287
  },
3156
3288
  servers: [
3157
3289
  { url: "http://localhost:4100", description: "Local dev (examples/server)" }
3158
3290
  ],
3159
3291
  paths: {
3292
+ // ── public: subscriptions ────────────────────────────────────────────────
3160
3293
  [ROUTES.VALIDATE]: {
3161
3294
  post: {
3162
3295
  summary: "Validate an Apple/Google receipt and persist subscription state.",
@@ -3166,7 +3299,9 @@ var ONESUB_OPENAPI = {
3166
3299
  },
3167
3300
  responses: {
3168
3301
  200: { description: "Validation succeeded; subscription state persisted.", content: { "application/json": { schema: { $ref: "#/components/schemas/ValidateResponse" } } } },
3169
- 400: { description: "Bad request \u2014 missing fields, malformed receipt, package mismatch." }
3302
+ 400: err("Bad request \u2014 missing fields, malformed receipt, package mismatch."),
3303
+ 409: err("Receipt is account-bound to a different userId (TRANSACTION_BELONGS_TO_OTHER_USER)."),
3304
+ 422: err("Receipt validation failed (RECEIPT_VALIDATION_FAILED).")
3170
3305
  }
3171
3306
  }
3172
3307
  },
@@ -3175,17 +3310,49 @@ var ONESUB_OPENAPI = {
3175
3310
  summary: "Fetch the most recent subscription state for a user.",
3176
3311
  parameters: [{ in: "query", name: "userId", required: true, schema: { type: "string" } }],
3177
3312
  responses: {
3178
- 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/StatusResponse" } } } }
3313
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/StatusResponse" } } } },
3314
+ 400: err("Missing userId or userId too long (INVALID_INPUT / USER_ID_TOO_LONG).")
3315
+ }
3316
+ }
3317
+ },
3318
+ // ── public: one-time purchases ───────────────────────────────────────────
3319
+ [ROUTES.VALIDATE_PURCHASE]: {
3320
+ post: {
3321
+ summary: "Validate a consumable / non-consumable receipt and record the purchase.",
3322
+ description: 'Non-consumables are idempotent: an already-owned product returns the stored purchase with `action: "restored"`. Consumable receipts can only be redeemed once.',
3323
+ requestBody: {
3324
+ required: true,
3325
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ValidatePurchaseRequest" } } }
3326
+ },
3327
+ responses: {
3328
+ 200: { description: 'Purchase validated \u2014 freshly recorded (`action: "new"`) or restored.', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidatePurchaseResponse" } } } },
3329
+ 400: err("Bad request \u2014 missing/invalid fields (INVALID_INPUT)."),
3330
+ 409: err("Receipt is account-bound or already redeemed by another user (TRANSACTION_BELONGS_TO_OTHER_USER)."),
3331
+ 422: err("Receipt validation failed (RECEIPT_VALIDATION_FAILED).")
3179
3332
  }
3180
3333
  }
3181
3334
  },
3335
+ [ROUTES.PURCHASE_STATUS]: {
3336
+ get: {
3337
+ summary: "List all recorded purchases for a user, optionally filtered by product.",
3338
+ parameters: [
3339
+ { in: "query", name: "userId", required: true, schema: { type: "string" } },
3340
+ { in: "query", name: "productId", schema: { type: "string" } }
3341
+ ],
3342
+ responses: {
3343
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/PurchaseStatusResponse" } } } },
3344
+ 400: err("Missing/invalid userId (INVALID_INPUT).")
3345
+ }
3346
+ }
3347
+ },
3348
+ // ── webhooks ─────────────────────────────────────────────────────────────
3182
3349
  [ROUTES.WEBHOOK_APPLE]: {
3183
3350
  post: {
3184
3351
  summary: "Apple App Store Server Notification V2 receiver.",
3185
3352
  description: "Apple POSTs `{ signedPayload }`. JWS-verified, idempotent when `webhookEventStore` is configured.",
3186
3353
  responses: {
3187
3354
  200: { description: "Acknowledged." },
3188
- 400: { description: "Missing or invalid signedPayload." }
3355
+ 400: err("Missing or invalid signedPayload.")
3189
3356
  }
3190
3357
  }
3191
3358
  },
@@ -3194,8 +3361,23 @@ var ONESUB_OPENAPI = {
3194
3361
  summary: "Google Play Real-Time Developer Notification (Pub/Sub push) receiver.",
3195
3362
  responses: {
3196
3363
  200: { description: "Acknowledged." },
3197
- 400: { description: "Missing message.data or package mismatch." },
3198
- 401: { description: "pushAudience configured and JWT verification failed." }
3364
+ 400: err("Missing message.data or package mismatch."),
3365
+ 401: err("pushAudience configured and JWT verification failed.")
3366
+ }
3367
+ }
3368
+ },
3369
+ // ── entitlements (mounted when config.entitlements is set) ──────────────
3370
+ [ROUTES.ENTITLEMENT]: {
3371
+ get: {
3372
+ summary: "Evaluate a single configured entitlement for a user.",
3373
+ parameters: [
3374
+ { in: "query", name: "userId", required: true, schema: { type: "string" } },
3375
+ { in: "query", name: "id", required: true, schema: { type: "string" }, description: "Entitlement id from `config.entitlements`." }
3376
+ ],
3377
+ responses: {
3378
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/EntitlementResponse" } } } },
3379
+ 400: err("userId and id are required (INVALID_INPUT)."),
3380
+ 404: err("Unknown entitlement id (ENTITLEMENT_NOT_FOUND).")
3199
3381
  }
3200
3382
  }
3201
3383
  },
@@ -3204,15 +3386,17 @@ var ONESUB_OPENAPI = {
3204
3386
  summary: "Evaluate every configured entitlement for a user.",
3205
3387
  parameters: [{ in: "query", name: "userId", required: true, schema: { type: "string" } }],
3206
3388
  responses: {
3207
- 200: { description: "Map of entitlementId \u2192 { granted, source }." }
3389
+ 200: { description: "Map of entitlementId \u2192 EntitlementStatus.", content: { "application/json": { schema: { $ref: "#/components/schemas/EntitlementsResponse" } } } },
3390
+ 400: err("userId is required (INVALID_INPUT).")
3208
3391
  }
3209
3392
  }
3210
3393
  },
3394
+ // ── admin (mounted when config.adminSecret is set) ───────────────────────
3211
3395
  [ROUTES.ADMIN_SUBSCRIPTIONS]: {
3212
3396
  get: {
3213
3397
  summary: "Filtered, paginated subscription list (admin).",
3214
3398
  parameters: [
3215
- { in: "header", name: "X-Admin-Secret", required: true, schema: { type: "string" } },
3399
+ ADMIN_SECRET_PARAM,
3216
3400
  { in: "query", name: "userId", schema: { type: "string" } },
3217
3401
  { in: "query", name: "status", schema: { type: "string", enum: ["active", "grace_period", "on_hold", "paused", "expired", "canceled", "none"] } },
3218
3402
  { in: "query", name: "productId", schema: { type: "string" } },
@@ -3220,39 +3404,203 @@ var ONESUB_OPENAPI = {
3220
3404
  { in: "query", name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } },
3221
3405
  { in: "query", name: "offset", schema: { type: "integer", minimum: 0 } }
3222
3406
  ],
3223
- responses: { 200: { description: "OK" }, 401: { description: "Invalid admin secret" } }
3407
+ responses: {
3408
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/ListSubscriptionsResponse" } } } },
3409
+ 400: err("Invalid filter/pagination params (INVALID_INPUT)."),
3410
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3411
+ }
3412
+ }
3413
+ },
3414
+ "/onesub/admin/subscriptions/{transactionId}": {
3415
+ get: {
3416
+ summary: "Single subscription record by originalTransactionId (admin).",
3417
+ parameters: [
3418
+ ADMIN_SECRET_PARAM,
3419
+ { in: "path", name: "transactionId", required: true, schema: { type: "string" } }
3420
+ ],
3421
+ responses: {
3422
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/SubscriptionInfo" } } } },
3423
+ 400: err("transactionId required (INVALID_INPUT)."),
3424
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET)."),
3425
+ 404: err("No record for this transactionId (TRANSACTION_NOT_FOUND).")
3426
+ }
3224
3427
  }
3225
3428
  },
3226
3429
  "/onesub/admin/customers/{userId}": {
3227
3430
  get: {
3228
3431
  summary: "Customer profile bundle: subscriptions + purchases + entitlements (admin).",
3229
3432
  parameters: [
3230
- { in: "header", name: "X-Admin-Secret", required: true, schema: { type: "string" } },
3433
+ ADMIN_SECRET_PARAM,
3231
3434
  { in: "path", name: "userId", required: true, schema: { type: "string" } }
3232
3435
  ],
3233
- responses: { 200: { description: "OK" }, 401: { description: "Invalid admin secret" } }
3436
+ responses: {
3437
+ 200: { description: 'OK \u2014 empty arrays mean "no record of this user" (never 404).', content: { "application/json": { schema: { $ref: "#/components/schemas/CustomerProfileResponse" } } } },
3438
+ 400: err("userId required (INVALID_INPUT)."),
3439
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3440
+ }
3441
+ }
3442
+ },
3443
+ "/onesub/admin/sync-apple/{originalTransactionId}": {
3444
+ post: {
3445
+ summary: "Reconcile one subscription from the Apple Status API into the local store (admin).",
3446
+ parameters: [
3447
+ ADMIN_SECRET_PARAM,
3448
+ { in: "path", name: "originalTransactionId", required: true, schema: { type: "string" } },
3449
+ { in: "query", name: "sandbox", schema: { type: "string", enum: ["true"] }, description: "Force the sandbox Status API host (otherwise inferred from the stored record)." }
3450
+ ],
3451
+ responses: {
3452
+ 200: { description: "Fresh state fetched and upserted.", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, subscription: { $ref: "#/components/schemas/SubscriptionInfo" } } } } } },
3453
+ 400: err("originalTransactionId missing or Apple API credentials not configured (INVALID_INPUT / APPLE_CONFIG_MISSING)."),
3454
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET)."),
3455
+ 404: err("Apple Status API returned no record (TRANSACTION_NOT_FOUND).")
3456
+ }
3457
+ }
3458
+ },
3459
+ "/onesub/purchase/admin/{userId}/{productId}": {
3460
+ delete: {
3461
+ summary: "Reset a purchase so the user can test the flow again (admin).",
3462
+ parameters: [
3463
+ ADMIN_SECRET_PARAM,
3464
+ { in: "path", name: "userId", required: true, schema: { type: "string" } },
3465
+ { in: "path", name: "productId", required: true, schema: { type: "string" } }
3466
+ ],
3467
+ responses: {
3468
+ 200: { description: "OK", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, deleted: { type: "integer", description: "Number of purchase rows removed." } } } } } },
3469
+ 400: err("userId and productId required (INVALID_INPUT)."),
3470
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3471
+ }
3472
+ }
3473
+ },
3474
+ "/onesub/purchase/admin/transfer": {
3475
+ post: {
3476
+ summary: "Reassign a transactionId to a new userId \u2014 device/account migration (admin).",
3477
+ parameters: [ADMIN_SECRET_PARAM],
3478
+ requestBody: {
3479
+ required: true,
3480
+ content: { "application/json": { schema: { type: "object", required: ["transactionId", "newUserId"], properties: { transactionId: { type: "string" }, newUserId: { type: "string" } } } } }
3481
+ },
3482
+ responses: {
3483
+ 200: { description: "Transferred.", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, purchase: { $ref: "#/components/schemas/PurchaseInfo" } } } } } },
3484
+ 400: err("Missing/invalid body fields (INVALID_INPUT)."),
3485
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET)."),
3486
+ 404: err("No purchase with this transactionId (TRANSACTION_NOT_FOUND).")
3487
+ }
3488
+ }
3489
+ },
3490
+ "/onesub/purchase/admin/grant": {
3491
+ post: {
3492
+ summary: "Manually insert a purchase record, skipping store verification (admin).",
3493
+ parameters: [ADMIN_SECRET_PARAM],
3494
+ requestBody: {
3495
+ required: true,
3496
+ content: { "application/json": { schema: { $ref: "#/components/schemas/GrantPurchaseRequest" } } }
3497
+ },
3498
+ responses: {
3499
+ 200: { description: "Granted.", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, purchase: { $ref: "#/components/schemas/PurchaseInfo" } } } } } },
3500
+ 400: err("Missing/invalid body fields (INVALID_INPUT)."),
3501
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3502
+ }
3234
3503
  }
3235
3504
  },
3236
3505
  "/onesub/admin/webhook-deadletters": {
3237
3506
  get: {
3238
3507
  summary: "List failed webhook jobs (when a queue with DLQ support is configured).",
3239
- parameters: [{ in: "header", name: "X-Admin-Secret", required: true, schema: { type: "string" } }],
3240
- responses: { 200: { description: "OK" }, 401: { description: "Invalid admin secret" } }
3508
+ parameters: [ADMIN_SECRET_PARAM],
3509
+ responses: {
3510
+ 200: { description: "OK", content: { "application/json": { schema: { type: "object", properties: { items: { type: "array", items: { type: "object", description: "DeadLetterRecord: { id, job, attemptsMade, lastError, failedAt }." } } } } } } },
3511
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3512
+ }
3241
3513
  }
3242
3514
  },
3243
3515
  "/onesub/admin/webhook-replay/{id}": {
3244
3516
  post: {
3245
3517
  summary: "Replay a dead-letter job through the webhook handler.",
3246
3518
  parameters: [
3247
- { in: "header", name: "X-Admin-Secret", required: true, schema: { type: "string" } },
3519
+ ADMIN_SECRET_PARAM,
3248
3520
  { in: "path", name: "id", required: true, schema: { type: "string" } }
3249
3521
  ],
3250
- responses: { 200: { description: "OK" }, 401: { description: "Invalid admin secret" } }
3522
+ responses: {
3523
+ 200: { description: "OK" },
3524
+ 400: err("id required (INVALID_INPUT)."),
3525
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3526
+ }
3527
+ }
3528
+ },
3529
+ // ── metrics (mounted when config.adminSecret is set) ─────────────────────
3530
+ [ROUTES.METRICS_ACTIVE]: {
3531
+ get: {
3532
+ summary: "Snapshot of currently-entitled users (active subs + non-consumable owners).",
3533
+ parameters: [ADMIN_SECRET_PARAM],
3534
+ responses: {
3535
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/MetricsActiveResponse" } } } },
3536
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3537
+ }
3538
+ }
3539
+ },
3540
+ [ROUTES.METRICS_STARTED]: {
3541
+ get: {
3542
+ summary: "Subscriptions started (purchasedAt) within a window.",
3543
+ parameters: [ADMIN_SECRET_PARAM, ...METRICS_RANGE_PARAMS],
3544
+ responses: {
3545
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/MetricsCountResponse" } } } },
3546
+ 400: err("from/to missing, non-ISO, or from > to (INVALID_INPUT)."),
3547
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3548
+ }
3549
+ }
3550
+ },
3551
+ [ROUTES.METRICS_EXPIRED]: {
3552
+ get: {
3553
+ summary: "Subscriptions expired/canceled (expiresAt) within a window.",
3554
+ parameters: [ADMIN_SECRET_PARAM, ...METRICS_RANGE_PARAMS],
3555
+ responses: {
3556
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/MetricsCountResponse" } } } },
3557
+ 400: err("from/to missing, non-ISO, or from > to (INVALID_INPUT)."),
3558
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3559
+ }
3560
+ }
3561
+ },
3562
+ [ROUTES.METRICS_PURCHASES_STARTED]: {
3563
+ get: {
3564
+ summary: "Non-consumable purchases started (purchasedAt) within a window.",
3565
+ parameters: [ADMIN_SECRET_PARAM, ...METRICS_RANGE_PARAMS],
3566
+ responses: {
3567
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/MetricsCountResponse" } } } },
3568
+ 400: err("from/to missing, non-ISO, or from > to (INVALID_INPUT)."),
3569
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3570
+ }
3571
+ }
3572
+ },
3573
+ // ── Apple Promotional Offer signing (mounted when offerKeyId + offerPrivateKey set) ──
3574
+ [ROUTES.APPLE_OFFER_SIGNATURE]: {
3575
+ post: {
3576
+ summary: "Sign an Apple Promotional Offer payload server-side (ES256).",
3577
+ 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.",
3578
+ parameters: [
3579
+ { in: "header", name: "X-Onesub-Offer-Secret", required: false, schema: { type: "string" }, description: "Required (must equal config.adminSecret) when adminSecret is configured." }
3580
+ ],
3581
+ requestBody: {
3582
+ required: true,
3583
+ content: { "application/json": { schema: { $ref: "#/components/schemas/OfferSignatureRequest" } } }
3584
+ },
3585
+ responses: {
3586
+ 200: { description: "Signed.", content: { "application/json": { schema: { $ref: "#/components/schemas/OfferSignatureResponse" } } } },
3587
+ 400: err("Missing/invalid body fields or apple.bundleId not configured (INVALID_INPUT / APPLE_CONFIG_MISSING)."),
3588
+ 401: err("Offer secret missing or wrong (UNAUTHORIZED).")
3589
+ }
3251
3590
  }
3252
3591
  }
3253
3592
  },
3254
3593
  components: {
3255
3594
  schemas: {
3595
+ ErrorResponse: {
3596
+ type: "object",
3597
+ required: ["error", "errorCode"],
3598
+ description: "Canonical error body \u2014 every onesub 4xx/5xx carries it. Some routes append shape-compat fields (e.g. `valid: false`, `subscription: null`, `purchases: []`).",
3599
+ properties: {
3600
+ error: { type: "string", description: "Human-readable message." },
3601
+ errorCode: { type: "string", enum: Object.values(ONESUB_ERROR_CODE), description: "Machine-readable canonical code." }
3602
+ }
3603
+ },
3256
3604
  ValidateRequest: {
3257
3605
  type: "object",
3258
3606
  required: ["userId", "platform", "productId", "receipt"],
@@ -3272,6 +3620,31 @@ var ONESUB_OPENAPI = {
3272
3620
  purchase: { $ref: "#/components/schemas/PurchaseInfo" }
3273
3621
  }
3274
3622
  },
3623
+ ValidatePurchaseRequest: {
3624
+ type: "object",
3625
+ required: ["userId", "platform", "productId", "receipt", "type"],
3626
+ properties: {
3627
+ userId: { type: "string" },
3628
+ platform: { type: "string", enum: ["apple", "google"] },
3629
+ productId: { type: "string" },
3630
+ receipt: { type: "string", description: "Apple JWS or Google purchaseToken" },
3631
+ type: { type: "string", enum: ["consumable", "non_consumable"] }
3632
+ }
3633
+ },
3634
+ ValidatePurchaseResponse: {
3635
+ type: "object",
3636
+ properties: {
3637
+ valid: { type: "boolean" },
3638
+ purchase: { $ref: "#/components/schemas/PurchaseInfo" },
3639
+ action: { type: "string", enum: ["new", "restored"], description: 'Present on valid:true \u2014 "restored" means the transactionId was already recorded (idempotent retry or reassignment).' }
3640
+ }
3641
+ },
3642
+ PurchaseStatusResponse: {
3643
+ type: "object",
3644
+ properties: {
3645
+ purchases: { type: "array", items: { $ref: "#/components/schemas/PurchaseInfo" } }
3646
+ }
3647
+ },
3275
3648
  StatusResponse: {
3276
3649
  type: "object",
3277
3650
  properties: {
@@ -3303,6 +3676,106 @@ var ONESUB_OPENAPI = {
3303
3676
  quantity: { type: "integer" },
3304
3677
  purchasedAt: { type: "string", format: "date-time" }
3305
3678
  }
3679
+ },
3680
+ EntitlementStatus: {
3681
+ type: "object",
3682
+ required: ["active", "source"],
3683
+ properties: {
3684
+ active: { type: "boolean" },
3685
+ source: { type: ["string", "null"], enum: ["subscription", "purchase", null], description: "Where the entitlement came from when active; null when not active." },
3686
+ productId: { type: "string", description: "Matched productId (only when active)." },
3687
+ expiresAt: { type: "string", format: "date-time", description: 'Only when source === "subscription".' }
3688
+ }
3689
+ },
3690
+ EntitlementResponse: {
3691
+ allOf: [
3692
+ { $ref: "#/components/schemas/EntitlementStatus" },
3693
+ { type: "object", required: ["id"], properties: { id: { type: "string", description: "The entitlement id queried, echoed back." } } }
3694
+ ]
3695
+ },
3696
+ EntitlementsResponse: {
3697
+ type: "object",
3698
+ properties: {
3699
+ entitlements: { type: "object", additionalProperties: { $ref: "#/components/schemas/EntitlementStatus" } }
3700
+ }
3701
+ },
3702
+ ListSubscriptionsResponse: {
3703
+ type: "object",
3704
+ properties: {
3705
+ items: { type: "array", items: { $ref: "#/components/schemas/SubscriptionInfo" } },
3706
+ total: { type: "integer", description: "Total matches before limit/offset." },
3707
+ limit: { type: "integer" },
3708
+ offset: { type: "integer" }
3709
+ }
3710
+ },
3711
+ CustomerProfileResponse: {
3712
+ type: "object",
3713
+ properties: {
3714
+ userId: { type: "string" },
3715
+ subscriptions: { type: "array", items: { $ref: "#/components/schemas/SubscriptionInfo" } },
3716
+ purchases: { type: "array", items: { $ref: "#/components/schemas/PurchaseInfo" } },
3717
+ entitlements: { type: "object", additionalProperties: { $ref: "#/components/schemas/EntitlementStatus" }, description: "Omitted when the server has no entitlements config." }
3718
+ }
3719
+ },
3720
+ GrantPurchaseRequest: {
3721
+ type: "object",
3722
+ required: ["userId", "productId", "platform"],
3723
+ properties: {
3724
+ userId: { type: "string" },
3725
+ productId: { type: "string" },
3726
+ platform: { type: "string", enum: ["apple", "google"] },
3727
+ type: { type: "string", enum: ["consumable", "non_consumable"], default: "non_consumable" },
3728
+ transactionId: { type: "string", description: "Auto-generated `admin_grant_*` id when omitted." }
3729
+ }
3730
+ },
3731
+ MetricsActiveResponse: {
3732
+ type: "object",
3733
+ properties: {
3734
+ total: { type: "integer", description: "Active subs + grace_period subs + non-consumable owners." },
3735
+ activeSubscriptions: { type: "integer" },
3736
+ gracePeriodSubscriptions: { type: "integer" },
3737
+ nonConsumablePurchases: { type: "integer" },
3738
+ byProduct: { type: "object", additionalProperties: { type: "integer" }, description: "Subscription product distribution (subs only)." },
3739
+ byProductPurchases: { type: "object", additionalProperties: { type: "integer" }, description: "Non-consumable purchase distribution." },
3740
+ byPlatform: { type: "object", additionalProperties: { type: "integer" } }
3741
+ }
3742
+ },
3743
+ MetricsBucket: {
3744
+ type: "object",
3745
+ properties: {
3746
+ date: { type: "string", description: "UTC calendar day, YYYY-MM-DD." },
3747
+ count: { type: "integer" }
3748
+ }
3749
+ },
3750
+ MetricsCountResponse: {
3751
+ type: "object",
3752
+ properties: {
3753
+ from: { type: "string", format: "date-time" },
3754
+ to: { type: "string", format: "date-time" },
3755
+ total: { type: "integer" },
3756
+ byProduct: { type: "object", additionalProperties: { type: "integer" } },
3757
+ byPlatform: { type: "object", additionalProperties: { type: "integer" } },
3758
+ buckets: { type: "array", items: { $ref: "#/components/schemas/MetricsBucket" }, description: "Only present with ?groupBy=day \u2014 zero-filled, sorted ascending." }
3759
+ }
3760
+ },
3761
+ OfferSignatureRequest: {
3762
+ type: "object",
3763
+ required: ["productId", "offerId", "applicationUsername"],
3764
+ properties: {
3765
+ productId: { type: "string" },
3766
+ offerId: { type: "string", description: "Promotional offer id defined in App Store Connect." },
3767
+ applicationUsername: { type: "string", description: "Unique per-request UUID (the nonce seed)." }
3768
+ }
3769
+ },
3770
+ OfferSignatureResponse: {
3771
+ type: "object",
3772
+ required: ["keyId", "nonce", "timestamp", "signature"],
3773
+ properties: {
3774
+ keyId: { type: "string" },
3775
+ nonce: { type: "string" },
3776
+ timestamp: { type: "integer", description: "Milliseconds since epoch." },
3777
+ signature: { type: "string", description: "Base64 ES256 signature for StoreKit." }
3778
+ }
3306
3779
  }
3307
3780
  },
3308
3781
  securitySchemes: {
@@ -3352,10 +3825,10 @@ async function withSpan(name, attributes, fn) {
3352
3825
  const result = await fn(span);
3353
3826
  span.setStatus({ code: 1 });
3354
3827
  return result;
3355
- } catch (err) {
3356
- span.recordException(err);
3357
- span.setStatus({ code: 2, message: err.message });
3358
- throw err;
3828
+ } catch (err2) {
3829
+ span.recordException(err2);
3830
+ span.setStatus({ code: 2, message: err2.message });
3831
+ throw err2;
3359
3832
  } finally {
3360
3833
  span.end();
3361
3834
  }
@@ -3377,7 +3850,7 @@ function createOneSubMiddleware(config) {
3377
3850
  router.use(express.json({ limit: "50kb" }));
3378
3851
  router.use(createValidateRouter(config, store));
3379
3852
  router.use(createStatusRouter(store));
3380
- router.use(createWebhookRouter(config, store, purchaseStore, config.webhookEventStore));
3853
+ router.use(createWebhookRouter(config, store, purchaseStore, config.webhookEventStore, config.webhookQueue));
3381
3854
  router.use(createPurchaseRouter(config, purchaseStore));
3382
3855
  const adminRouter = createAdminRouter(config, purchaseStore, store, config.webhookQueue);
3383
3856
  if (adminRouter) router.use(adminRouter);
@@ -3422,6 +3895,6 @@ if (isMain) {
3422
3895
  });
3423
3896
  }
3424
3897
 
3425
- export { BullMQWebhookQueue, CacheWebhookEventStore, InMemoryCacheAdapter, InMemoryPurchaseStore, InMemorySubscriptionStore, InMemoryWebhookEventStore, InProcessWebhookQueue, ONESUB_OPENAPI, PostgresPurchaseStore, PostgresSubscriptionStore, RedisCacheAdapter, RedisPurchaseStore, RedisSubscriptionStore, RedisWebhookEventStore, createOneSubMiddleware, createOneSubServer, evaluateEntitlement, fetchAppleSubscriptionStatus, fetchAppleTransactionHistory, getDefaultCache, log, openapiHandler, setDefaultCache, setLogger, signApplePromotionalOffer, validateAppleReceipt, validateGoogleReceipt, withSpan };
3898
+ export { BullMQWebhookQueue, CacheWebhookEventStore, InMemoryCacheAdapter, InMemoryPurchaseStore, InMemorySubscriptionStore, InMemoryWebhookEventStore, InProcessWebhookQueue, ONESUB_OPENAPI, PostgresPurchaseStore, PostgresSubscriptionStore, RedisCacheAdapter, RedisPurchaseStore, RedisSubscriptionStore, RedisWebhookEventStore, createOneSubMiddleware, createOneSubServer, evaluateEntitlement, fetchAppleSubscriptionStatus, fetchAppleTransactionHistory, getDefaultCache, log, openapiHandler, processAppleNotification, processGoogleNotification, setDefaultCache, setLogger, signApplePromotionalOffer, unmarkWebhookEvent, validateAppleReceipt, validateGoogleReceipt, withSpan };
3426
3899
  //# sourceMappingURL=index.js.map
3427
3900
  //# sourceMappingURL=index.js.map