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