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