@onesub/server 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/field-vocabulary.test.d.ts +25 -0
- package/dist/__tests__/field-vocabulary.test.d.ts.map +1 -0
- package/dist/__tests__/provider-log-fields.test.d.ts +27 -0
- package/dist/__tests__/provider-log-fields.test.d.ts.map +1 -0
- package/dist/index.cjs +135 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +135 -64
- package/dist/index.js.map +1 -1
- package/dist/log-format.d.ts +16 -4
- package/dist/log-format.d.ts.map +1 -1
- package/dist/providers/apple.d.ts.map +1 -1
- package/dist/providers/google.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holds every `log.*` call site in the package to the field vocabulary declared in
|
|
3
|
+
* `log-format.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Why a source-scanning test rather than more per-call-site assertions. The value of
|
|
6
|
+
* moving data out of the message is that an operator can filter on `productId` — and
|
|
7
|
+
* that value is destroyed by one file calling it `product`, silently, on a path no
|
|
8
|
+
* test happens to exercise. That is not hypothetical: while building
|
|
9
|
+
* `provider-log-fields.test.ts` a mutation renaming `productId` to `product` at the
|
|
10
|
+
* "No orderId in product purchase" site **passed all 13 of its tests**, because that
|
|
11
|
+
* site is not one of the ones asserted. Covering ~100 sites individually to close
|
|
12
|
+
* that gap is not maintainable; checking the vocabulary once, over all of them, is.
|
|
13
|
+
*
|
|
14
|
+
* The vocabulary lives in `log-format.ts`'s module doc and is parsed out of it here,
|
|
15
|
+
* so there is exactly one list. A doc comment is an odd place for an enforced
|
|
16
|
+
* contract, but the alternative — an exported `const` array — would ship a
|
|
17
|
+
* test-only value in the bundle that `npm run size` gates.
|
|
18
|
+
*
|
|
19
|
+
* What this test cannot do: it does not know whether a field is *correctly named*
|
|
20
|
+
* for its value, only that the name is one of the sanctioned ones. `bundleId:
|
|
21
|
+
* tx.productId` passes here. Per-site assertions in `provider-log-fields.test.ts`
|
|
22
|
+
* cover that for the paths that matter.
|
|
23
|
+
*/
|
|
24
|
+
export {};
|
|
25
|
+
//# sourceMappingURL=field-vocabulary.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"field-vocabulary.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/field-vocabulary.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the Apple and Google providers actually put on a log line.
|
|
3
|
+
*
|
|
4
|
+
* These paths had **no log assertions at all** before this file, which is worth
|
|
5
|
+
* stating plainly: the 49 call sites in `providers/apple.ts` and
|
|
6
|
+
* `providers/google.ts` could have been migrated to `(message, fields)` — or
|
|
7
|
+
* broken — and the whole suite would have stayed green either way. So this file
|
|
8
|
+
* is not belt-and-braces on top of `log-format.test.ts`; it is the only thing
|
|
9
|
+
* that holds the providers to the contract.
|
|
10
|
+
*
|
|
11
|
+
* It tests through the real provider functions rather than through
|
|
12
|
+
* `formatLogArgs`, because the two failure modes that matter are call-site
|
|
13
|
+
* failures a pure formatter test cannot see:
|
|
14
|
+
*
|
|
15
|
+
* 1. **A value left in the message.** `formatLogArgs` escapes whatever it is
|
|
16
|
+
* given; it cannot know that `bundleId` should have been a field. Only an
|
|
17
|
+
* assertion on the rendered line catches an interpolated value.
|
|
18
|
+
* 2. **A field named differently in two places.** The point of moving values
|
|
19
|
+
* out of the message is that an operator can filter on `productId`. That is
|
|
20
|
+
* lost the moment one file calls it `product` — and nothing but an exact
|
|
21
|
+
* assertion notices.
|
|
22
|
+
*
|
|
23
|
+
* Hence exact-line assertions, not `toContain`. A substring match would pass on
|
|
24
|
+
* `productId=x` *and* on a message that happened to mention it.
|
|
25
|
+
*/
|
|
26
|
+
export {};
|
|
27
|
+
//# sourceMappingURL=provider-log-fields.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider-log-fields.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/provider-log-fields.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG"}
|
package/dist/index.cjs
CHANGED
|
@@ -624,22 +624,23 @@ async function validateAppleReceipt(receipt, config) {
|
|
|
624
624
|
} catch (err2) {
|
|
625
625
|
const preview = receipt.slice(0, 60);
|
|
626
626
|
const parts = receipt.split(".").length;
|
|
627
|
-
log.warn(
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
627
|
+
log.warn("[onesub/apple] Failed to decode receipt as JWS", {
|
|
628
|
+
receiptPreview: preview,
|
|
629
|
+
receiptLength: receipt.length,
|
|
630
|
+
jwsParts: parts,
|
|
631
|
+
err: err2
|
|
632
|
+
});
|
|
632
633
|
return null;
|
|
633
634
|
}
|
|
634
635
|
if (!tx.originalTransactionId || !tx.productId || !tx.expiresDate) {
|
|
635
636
|
return null;
|
|
636
637
|
}
|
|
637
638
|
if (!tx.bundleId || tx.bundleId !== config.bundleId) {
|
|
638
|
-
log.warn("[onesub/apple] Bundle ID mismatch
|
|
639
|
+
log.warn("[onesub/apple] Bundle ID mismatch", { bundleId: tx.bundleId, expected: config.bundleId });
|
|
639
640
|
return null;
|
|
640
641
|
}
|
|
641
642
|
if (process.env["NODE_ENV"] === "production" && tx.environment !== "Production" && process.env["ONESUB_ALLOW_SANDBOX"] !== "true") {
|
|
642
|
-
log.warn("[onesub/apple] Sandbox receipt rejected in production
|
|
643
|
+
log.warn("[onesub/apple] Sandbox receipt rejected in production", { environment: tx.environment });
|
|
643
644
|
return null;
|
|
644
645
|
}
|
|
645
646
|
const status = deriveStatus(tx, null);
|
|
@@ -679,23 +680,25 @@ async function validateAppleConsumableReceipt(signedTransaction, config, expecte
|
|
|
679
680
|
const preview = signedTransaction.slice(0, 60);
|
|
680
681
|
const parts = signedTransaction.split(".").length;
|
|
681
682
|
const looksLikeJws = parts === 3;
|
|
682
|
-
log.warn(
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
683
|
+
log.warn("[onesub/apple] Failed to decode consumable JWS", {
|
|
684
|
+
receiptPreview: preview,
|
|
685
|
+
receiptLength: signedTransaction.length,
|
|
686
|
+
jwsParts: parts,
|
|
687
|
+
looksLikeJws,
|
|
688
|
+
err: err2
|
|
689
|
+
});
|
|
687
690
|
return null;
|
|
688
691
|
}
|
|
689
692
|
if (!tx.bundleId || tx.bundleId !== config.bundleId) {
|
|
690
|
-
log.warn("[onesub/apple] Bundle ID mismatch
|
|
693
|
+
log.warn("[onesub/apple] Bundle ID mismatch", { bundleId: tx.bundleId, expected: config.bundleId });
|
|
691
694
|
return null;
|
|
692
695
|
}
|
|
693
696
|
if (tx.type !== "Consumable" && tx.type !== "Non-Consumable") {
|
|
694
|
-
log.warn("[onesub/apple] Invalid purchase type for product validation
|
|
697
|
+
log.warn("[onesub/apple] Invalid purchase type for product validation", { type: tx.type });
|
|
695
698
|
return null;
|
|
696
699
|
}
|
|
697
700
|
if (process.env["NODE_ENV"] === "production" && tx.environment !== "Production" && process.env["ONESUB_ALLOW_SANDBOX"] !== "true") {
|
|
698
|
-
log.warn("[onesub/apple] Sandbox receipt rejected in production
|
|
701
|
+
log.warn("[onesub/apple] Sandbox receipt rejected in production", { environment: tx.environment });
|
|
699
702
|
return null;
|
|
700
703
|
}
|
|
701
704
|
if (!tx.productId) {
|
|
@@ -703,21 +706,28 @@ async function validateAppleConsumableReceipt(signedTransaction, config, expecte
|
|
|
703
706
|
return null;
|
|
704
707
|
}
|
|
705
708
|
if (expectedProductId && tx.productId !== expectedProductId) {
|
|
706
|
-
log.warn("[onesub/apple] Product ID mismatch
|
|
709
|
+
log.warn("[onesub/apple] Product ID mismatch", { productId: tx.productId, expected: expectedProductId });
|
|
707
710
|
return null;
|
|
708
711
|
}
|
|
709
712
|
if (tx.revocationDate) {
|
|
710
|
-
log.warn("[onesub/apple] Purchase was revoked/refunded"
|
|
713
|
+
log.warn("[onesub/apple] Purchase was revoked/refunded", {
|
|
714
|
+
productId: tx.productId,
|
|
715
|
+
transactionId: tx.transactionId
|
|
716
|
+
});
|
|
711
717
|
return null;
|
|
712
718
|
}
|
|
713
719
|
const maxAgeMs = (config.productReceiptMaxAgeHours ?? 72) * 60 * 60 * 1e3;
|
|
714
720
|
if (tx.purchaseDate && Date.now() - tx.purchaseDate > maxAgeMs) {
|
|
715
|
-
log.warn(
|
|
721
|
+
log.warn("[onesub/apple] Consumable receipt too old", {
|
|
722
|
+
productId: tx.productId,
|
|
723
|
+
maxAgeHours: config.productReceiptMaxAgeHours ?? 72,
|
|
724
|
+
purchaseDate: new Date(tx.purchaseDate).toISOString()
|
|
725
|
+
});
|
|
716
726
|
return null;
|
|
717
727
|
}
|
|
718
728
|
const transactionId = tx.transactionId ?? tx.originalTransactionId;
|
|
719
729
|
if (!transactionId) {
|
|
720
|
-
log.warn("[onesub/apple] No transactionId in consumable transaction");
|
|
730
|
+
log.warn("[onesub/apple] No transactionId in consumable transaction", { productId: tx.productId });
|
|
721
731
|
return null;
|
|
722
732
|
}
|
|
723
733
|
return {
|
|
@@ -796,7 +806,7 @@ async function sendAppleConsumptionResponse(transactionId, body, config, options
|
|
|
796
806
|
try {
|
|
797
807
|
jwt = await makeAppleApiJwt(config);
|
|
798
808
|
} catch (err2) {
|
|
799
|
-
log.warn("[onesub/apple] Cannot send consumption response \u2014 JWT mint failed
|
|
809
|
+
log.warn("[onesub/apple] Cannot send consumption response \u2014 JWT mint failed", { transactionId, err: err2 });
|
|
800
810
|
return;
|
|
801
811
|
}
|
|
802
812
|
const host = options?.sandbox ? "api.storekit-sandbox.itunes.apple.com" : "api.storekit.itunes.apple.com";
|
|
@@ -812,10 +822,14 @@ async function sendAppleConsumptionResponse(transactionId, body, config, options
|
|
|
812
822
|
});
|
|
813
823
|
if (!resp.ok) {
|
|
814
824
|
const text = await resp.text();
|
|
815
|
-
log.warn(
|
|
825
|
+
log.warn("[onesub/apple] Consumption response API error", {
|
|
826
|
+
httpStatus: resp.status,
|
|
827
|
+
transactionId,
|
|
828
|
+
responseBody: text
|
|
829
|
+
});
|
|
816
830
|
}
|
|
817
831
|
} catch (err2) {
|
|
818
|
-
log.warn("[onesub/apple] Consumption response network error
|
|
832
|
+
log.warn("[onesub/apple] Consumption response network error", { transactionId, err: err2 });
|
|
819
833
|
}
|
|
820
834
|
}
|
|
821
835
|
var APPLE_SUBSCRIPTION_STATUS_CODE = {
|
|
@@ -846,7 +860,10 @@ async function fetchAppleSubscriptionStatus(originalTransactionId, config, optio
|
|
|
846
860
|
try {
|
|
847
861
|
jwt = await makeAppleApiJwt(config);
|
|
848
862
|
} catch (err2) {
|
|
849
|
-
log.warn("[onesub/apple] Cannot fetch subscription status \u2014 JWT mint failed
|
|
863
|
+
log.warn("[onesub/apple] Cannot fetch subscription status \u2014 JWT mint failed", {
|
|
864
|
+
originalTransactionId,
|
|
865
|
+
err: err2
|
|
866
|
+
});
|
|
850
867
|
return null;
|
|
851
868
|
}
|
|
852
869
|
const host = options?.sandbox ? "api.storekit-sandbox.itunes.apple.com" : "api.storekit.itunes.apple.com";
|
|
@@ -858,24 +875,31 @@ async function fetchAppleSubscriptionStatus(originalTransactionId, config, optio
|
|
|
858
875
|
});
|
|
859
876
|
if (!resp.ok) {
|
|
860
877
|
const text = await resp.text();
|
|
861
|
-
log.warn(
|
|
878
|
+
log.warn("[onesub/apple] Status API error", {
|
|
879
|
+
httpStatus: resp.status,
|
|
880
|
+
originalTransactionId,
|
|
881
|
+
responseBody: text
|
|
882
|
+
});
|
|
862
883
|
return null;
|
|
863
884
|
}
|
|
864
885
|
body = await resp.json();
|
|
865
886
|
} catch (err2) {
|
|
866
|
-
log.warn("[onesub/apple] Status API network error
|
|
887
|
+
log.warn("[onesub/apple] Status API network error", { originalTransactionId, err: err2 });
|
|
867
888
|
return null;
|
|
868
889
|
}
|
|
869
890
|
const entry = body.data?.flatMap((g) => g.lastTransactions ?? []).find((t) => t.originalTransactionId === originalTransactionId);
|
|
870
891
|
if (!entry || entry.status == null || !entry.signedTransactionInfo) {
|
|
871
|
-
log.warn("[onesub/apple] Status API returned no matching transaction
|
|
892
|
+
log.warn("[onesub/apple] Status API returned no matching transaction", { originalTransactionId });
|
|
872
893
|
return null;
|
|
873
894
|
}
|
|
874
895
|
let tx;
|
|
875
896
|
try {
|
|
876
897
|
tx = await decodeJws(entry.signedTransactionInfo, config.skipJwsVerification);
|
|
877
898
|
} catch (err2) {
|
|
878
|
-
log.warn("[onesub/apple] Failed to decode signedTransactionInfo from Status API
|
|
899
|
+
log.warn("[onesub/apple] Failed to decode signedTransactionInfo from Status API", {
|
|
900
|
+
originalTransactionId,
|
|
901
|
+
err: err2
|
|
902
|
+
});
|
|
879
903
|
return null;
|
|
880
904
|
}
|
|
881
905
|
let renewal = null;
|
|
@@ -886,7 +910,10 @@ async function fetchAppleSubscriptionStatus(originalTransactionId, config, optio
|
|
|
886
910
|
}
|
|
887
911
|
}
|
|
888
912
|
if (!tx.productId || !tx.expiresDate) {
|
|
889
|
-
log.warn("[onesub/apple] Status API transaction missing productId or expiresDate"
|
|
913
|
+
log.warn("[onesub/apple] Status API transaction missing productId or expiresDate", {
|
|
914
|
+
originalTransactionId,
|
|
915
|
+
productId: tx.productId
|
|
916
|
+
});
|
|
890
917
|
return null;
|
|
891
918
|
}
|
|
892
919
|
const status = mapAppleStatusCode(entry.status);
|
|
@@ -910,7 +937,10 @@ async function fetchAppleTransactionHistory(originalTransactionId, config, optio
|
|
|
910
937
|
try {
|
|
911
938
|
jwt = await makeAppleApiJwt(config);
|
|
912
939
|
} catch (err2) {
|
|
913
|
-
log.warn("[onesub/apple] Cannot fetch transaction history \u2014 JWT mint failed
|
|
940
|
+
log.warn("[onesub/apple] Cannot fetch transaction history \u2014 JWT mint failed", {
|
|
941
|
+
originalTransactionId,
|
|
942
|
+
err: err2
|
|
943
|
+
});
|
|
914
944
|
return null;
|
|
915
945
|
}
|
|
916
946
|
const host = options?.sandbox ? "api.storekit-sandbox.itunes.apple.com" : "api.storekit.itunes.apple.com";
|
|
@@ -927,12 +957,16 @@ async function fetchAppleTransactionHistory(originalTransactionId, config, optio
|
|
|
927
957
|
});
|
|
928
958
|
if (!resp.ok) {
|
|
929
959
|
const text = await resp.text();
|
|
930
|
-
log.warn(
|
|
960
|
+
log.warn("[onesub/apple] Transaction History API error", {
|
|
961
|
+
httpStatus: resp.status,
|
|
962
|
+
originalTransactionId,
|
|
963
|
+
responseBody: text
|
|
964
|
+
});
|
|
931
965
|
return null;
|
|
932
966
|
}
|
|
933
967
|
page = await resp.json();
|
|
934
968
|
} catch (err2) {
|
|
935
|
-
log.warn("[onesub/apple] Transaction History API network error
|
|
969
|
+
log.warn("[onesub/apple] Transaction History API network error", { originalTransactionId, err: err2 });
|
|
936
970
|
return null;
|
|
937
971
|
}
|
|
938
972
|
for (const signed of page.signedTransactions ?? []) {
|
|
@@ -957,14 +991,16 @@ async function fetchAppleTransactionHistory(originalTransactionId, config, optio
|
|
|
957
991
|
if (!page.hasMore) break;
|
|
958
992
|
if (!page.revision || page.revision === revision) {
|
|
959
993
|
log.warn(
|
|
960
|
-
"[onesub/apple] Transaction History API returned hasMore without a new revision cursor \u2014 stopping pagination (partial history returned)"
|
|
994
|
+
"[onesub/apple] Transaction History API returned hasMore without a new revision cursor \u2014 stopping pagination (partial history returned)",
|
|
995
|
+
{ originalTransactionId, pageCount }
|
|
961
996
|
);
|
|
962
997
|
break;
|
|
963
998
|
}
|
|
964
999
|
if (pageCount >= MAX_HISTORY_PAGES) {
|
|
965
|
-
log.warn(
|
|
966
|
-
|
|
967
|
-
|
|
1000
|
+
log.warn("[onesub/apple] Transaction History pagination hit the page cap \u2014 stopping (partial history returned)", {
|
|
1001
|
+
originalTransactionId,
|
|
1002
|
+
maxPages: MAX_HISTORY_PAGES
|
|
1003
|
+
});
|
|
968
1004
|
break;
|
|
969
1005
|
}
|
|
970
1006
|
revision = page.revision;
|
|
@@ -1114,7 +1150,7 @@ async function acknowledgeGoogleSubscription(purchaseToken, productId, config) {
|
|
|
1114
1150
|
try {
|
|
1115
1151
|
accessToken = await getCachedAccessToken(config.serviceAccountKey);
|
|
1116
1152
|
} catch (err2) {
|
|
1117
|
-
log.warn("[onesub/google] Could not get access token for subscription ack
|
|
1153
|
+
log.warn("[onesub/google] Could not get access token for subscription ack", { productId, err: err2 });
|
|
1118
1154
|
return;
|
|
1119
1155
|
}
|
|
1120
1156
|
const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(config.packageName)}/purchases/subscriptions/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:acknowledge`;
|
|
@@ -1126,10 +1162,14 @@ async function acknowledgeGoogleSubscription(purchaseToken, productId, config) {
|
|
|
1126
1162
|
});
|
|
1127
1163
|
if (!resp.ok) {
|
|
1128
1164
|
const body = await resp.text();
|
|
1129
|
-
log.warn(
|
|
1165
|
+
log.warn("[onesub/google] Subscription acknowledge API error \u2014 auto-refund risk", {
|
|
1166
|
+
httpStatus: resp.status,
|
|
1167
|
+
productId,
|
|
1168
|
+
responseBody: body
|
|
1169
|
+
});
|
|
1130
1170
|
}
|
|
1131
1171
|
} catch (err2) {
|
|
1132
|
-
log.warn("[onesub/google] Subscription acknowledge network error \u2014 auto-refund risk
|
|
1172
|
+
log.warn("[onesub/google] Subscription acknowledge network error \u2014 auto-refund risk", { productId, err: err2 });
|
|
1133
1173
|
}
|
|
1134
1174
|
}
|
|
1135
1175
|
async function acknowledgeGoogleProduct(purchaseToken, productId, config) {
|
|
@@ -1140,7 +1180,7 @@ async function acknowledgeGoogleProduct(purchaseToken, productId, config) {
|
|
|
1140
1180
|
try {
|
|
1141
1181
|
accessToken = await getCachedAccessToken(config.serviceAccountKey);
|
|
1142
1182
|
} catch (err2) {
|
|
1143
|
-
log.warn("[onesub/google] Could not get access token for product ack
|
|
1183
|
+
log.warn("[onesub/google] Could not get access token for product ack", { productId, err: err2 });
|
|
1144
1184
|
return;
|
|
1145
1185
|
}
|
|
1146
1186
|
const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(config.packageName)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:acknowledge`;
|
|
@@ -1152,10 +1192,14 @@ async function acknowledgeGoogleProduct(purchaseToken, productId, config) {
|
|
|
1152
1192
|
});
|
|
1153
1193
|
if (!resp.ok) {
|
|
1154
1194
|
const body = await resp.text();
|
|
1155
|
-
log.warn(
|
|
1195
|
+
log.warn("[onesub/google] Product acknowledge API error \u2014 auto-refund risk", {
|
|
1196
|
+
httpStatus: resp.status,
|
|
1197
|
+
productId,
|
|
1198
|
+
responseBody: body
|
|
1199
|
+
});
|
|
1156
1200
|
}
|
|
1157
1201
|
} catch (err2) {
|
|
1158
|
-
log.warn("[onesub/google] Product acknowledge network error \u2014 auto-refund risk
|
|
1202
|
+
log.warn("[onesub/google] Product acknowledge network error \u2014 auto-refund risk", { productId, err: err2 });
|
|
1159
1203
|
}
|
|
1160
1204
|
}
|
|
1161
1205
|
async function consumeGoogleProductReceipt(purchaseToken, productId, config) {
|
|
@@ -1165,7 +1209,7 @@ async function consumeGoogleProductReceipt(purchaseToken, productId, config) {
|
|
|
1165
1209
|
try {
|
|
1166
1210
|
accessToken = await getCachedAccessToken(config.serviceAccountKey);
|
|
1167
1211
|
} catch (err2) {
|
|
1168
|
-
log.warn("[onesub/google] Could not get access token for consume
|
|
1212
|
+
log.warn("[onesub/google] Could not get access token for consume", { productId, err: err2 });
|
|
1169
1213
|
return;
|
|
1170
1214
|
}
|
|
1171
1215
|
const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(config.packageName)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:consume`;
|
|
@@ -1176,10 +1220,14 @@ async function consumeGoogleProductReceipt(purchaseToken, productId, config) {
|
|
|
1176
1220
|
});
|
|
1177
1221
|
if (!resp.ok) {
|
|
1178
1222
|
const body = await resp.text();
|
|
1179
|
-
log.warn(
|
|
1223
|
+
log.warn("[onesub/google] Consume API error \u2014 auto-refund risk", {
|
|
1224
|
+
httpStatus: resp.status,
|
|
1225
|
+
productId,
|
|
1226
|
+
responseBody: body
|
|
1227
|
+
});
|
|
1180
1228
|
}
|
|
1181
1229
|
} catch (err2) {
|
|
1182
|
-
log.warn("[onesub/google] Consume network error \u2014 auto-refund risk
|
|
1230
|
+
log.warn("[onesub/google] Consume network error \u2014 auto-refund risk", { productId, err: err2 });
|
|
1183
1231
|
}
|
|
1184
1232
|
}
|
|
1185
1233
|
function deriveStatusV2(state) {
|
|
@@ -1205,11 +1253,11 @@ function deriveStatusV2(state) {
|
|
|
1205
1253
|
async function validateGoogleReceipt(receipt, productId, config) {
|
|
1206
1254
|
if (config.mockMode) return mockValidateGoogleSubscription(receipt, productId);
|
|
1207
1255
|
if (!config.serviceAccountKey) {
|
|
1208
|
-
log.warn("[onesub/google] No serviceAccountKey provided \u2014 cannot call Play API");
|
|
1256
|
+
log.warn("[onesub/google] No serviceAccountKey provided \u2014 cannot call Play API", { productId });
|
|
1209
1257
|
return null;
|
|
1210
1258
|
}
|
|
1211
1259
|
if (!config.packageName) {
|
|
1212
|
-
log.warn("[onesub/google] No packageName provided \u2014 cannot call Play API");
|
|
1260
|
+
log.warn("[onesub/google] No packageName provided \u2014 cannot call Play API", { productId });
|
|
1213
1261
|
return null;
|
|
1214
1262
|
}
|
|
1215
1263
|
let purchase;
|
|
@@ -1217,25 +1265,27 @@ async function validateGoogleReceipt(receipt, productId, config) {
|
|
|
1217
1265
|
const token = await getCachedAccessToken(config.serviceAccountKey);
|
|
1218
1266
|
purchase = await fetchSubscriptionPurchaseV2(config.packageName, receipt, token);
|
|
1219
1267
|
} catch (err2) {
|
|
1220
|
-
log.error("[onesub/google] Receipt validation failed
|
|
1268
|
+
log.error("[onesub/google] Receipt validation failed", {
|
|
1269
|
+
productId,
|
|
1270
|
+
packageName: config.packageName,
|
|
1271
|
+
err: err2
|
|
1272
|
+
});
|
|
1221
1273
|
return null;
|
|
1222
1274
|
}
|
|
1223
1275
|
const status = deriveStatusV2(purchase.subscriptionState);
|
|
1224
1276
|
if (!status) {
|
|
1225
|
-
log.warn(
|
|
1226
|
-
|
|
1227
|
-
purchase.subscriptionState
|
|
1228
|
-
);
|
|
1277
|
+
log.warn("[onesub/google] Unrecognised or pending subscriptionState \u2014 rejecting", {
|
|
1278
|
+
productId,
|
|
1279
|
+
subscriptionState: purchase.subscriptionState
|
|
1280
|
+
});
|
|
1229
1281
|
return null;
|
|
1230
1282
|
}
|
|
1231
1283
|
const lineItem = purchase.lineItems?.find((item) => item.productId === productId);
|
|
1232
1284
|
if (!lineItem) {
|
|
1233
|
-
log.warn(
|
|
1234
|
-
"[onesub/google] productId not found in subscription lineItems:",
|
|
1285
|
+
log.warn("[onesub/google] productId not found in subscription lineItems", {
|
|
1235
1286
|
productId,
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
);
|
|
1287
|
+
availableProductIds: purchase.lineItems?.map((i) => i.productId) ?? []
|
|
1288
|
+
});
|
|
1239
1289
|
return null;
|
|
1240
1290
|
}
|
|
1241
1291
|
const expiresAt = lineItem.expiryTime ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1267,11 +1317,11 @@ async function validateGoogleReceipt(receipt, productId, config) {
|
|
|
1267
1317
|
async function validateGoogleProductReceipt(purchaseToken, productId, config, type = "non_consumable") {
|
|
1268
1318
|
if (config.mockMode) return mockValidateGoogleProduct(purchaseToken, productId);
|
|
1269
1319
|
if (!config.serviceAccountKey) {
|
|
1270
|
-
log.warn("[onesub/google] No serviceAccountKey \u2014 cannot validate product receipt");
|
|
1320
|
+
log.warn("[onesub/google] No serviceAccountKey \u2014 cannot validate product receipt", { productId });
|
|
1271
1321
|
return null;
|
|
1272
1322
|
}
|
|
1273
1323
|
if (!config.packageName) {
|
|
1274
|
-
log.warn("[onesub/google] No packageName \u2014 cannot validate product receipt");
|
|
1324
|
+
log.warn("[onesub/google] No packageName \u2014 cannot validate product receipt", { productId });
|
|
1275
1325
|
return null;
|
|
1276
1326
|
}
|
|
1277
1327
|
let purchase;
|
|
@@ -1279,27 +1329,44 @@ async function validateGoogleProductReceipt(purchaseToken, productId, config, ty
|
|
|
1279
1329
|
const token = await getCachedAccessToken(config.serviceAccountKey);
|
|
1280
1330
|
purchase = await fetchProductPurchase(config.packageName, productId, purchaseToken, token);
|
|
1281
1331
|
} catch (err2) {
|
|
1282
|
-
log.error("[onesub/google] Product receipt validation failed
|
|
1332
|
+
log.error("[onesub/google] Product receipt validation failed", {
|
|
1333
|
+
productId,
|
|
1334
|
+
packageName: config.packageName,
|
|
1335
|
+
type,
|
|
1336
|
+
err: err2
|
|
1337
|
+
});
|
|
1283
1338
|
return null;
|
|
1284
1339
|
}
|
|
1285
1340
|
if (purchase.purchaseState !== 0) {
|
|
1286
|
-
log.warn("[onesub/google] Purchase not completed
|
|
1341
|
+
log.warn("[onesub/google] Purchase not completed", {
|
|
1342
|
+
productId,
|
|
1343
|
+
purchaseState: purchase.purchaseState,
|
|
1344
|
+
orderId: purchase.orderId
|
|
1345
|
+
});
|
|
1287
1346
|
return null;
|
|
1288
1347
|
}
|
|
1289
1348
|
if (type === "consumable" && purchase.consumptionState === 1) {
|
|
1290
|
-
log.warn("[onesub/google] Consumable already consumed \u2014 possible replay attack"
|
|
1349
|
+
log.warn("[onesub/google] Consumable already consumed \u2014 possible replay attack", {
|
|
1350
|
+
productId,
|
|
1351
|
+
orderId: purchase.orderId
|
|
1352
|
+
});
|
|
1291
1353
|
return null;
|
|
1292
1354
|
}
|
|
1293
1355
|
if (purchase.purchaseTimeMillis) {
|
|
1294
1356
|
const purchaseTime = parseInt(purchase.purchaseTimeMillis, 10);
|
|
1295
1357
|
const maxAgeMs = (config.productReceiptMaxAgeHours ?? 72) * 60 * 60 * 1e3;
|
|
1296
1358
|
if (Date.now() - purchaseTime > maxAgeMs) {
|
|
1297
|
-
log.warn(
|
|
1359
|
+
log.warn("[onesub/google] Product receipt too old", {
|
|
1360
|
+
productId,
|
|
1361
|
+
orderId: purchase.orderId,
|
|
1362
|
+
maxAgeHours: config.productReceiptMaxAgeHours ?? 72,
|
|
1363
|
+
purchaseDate: new Date(purchaseTime).toISOString()
|
|
1364
|
+
});
|
|
1298
1365
|
return null;
|
|
1299
1366
|
}
|
|
1300
1367
|
}
|
|
1301
1368
|
if (!purchase.orderId) {
|
|
1302
|
-
log.warn("[onesub/google] No orderId in product purchase");
|
|
1369
|
+
log.warn("[onesub/google] No orderId in product purchase", { productId });
|
|
1303
1370
|
return null;
|
|
1304
1371
|
}
|
|
1305
1372
|
return {
|
|
@@ -1356,7 +1423,11 @@ function decodeGoogleOneTimeProductNotification(payload) {
|
|
|
1356
1423
|
if (!notification.oneTimeProductNotification) return null;
|
|
1357
1424
|
const { notificationType, purchaseToken, sku } = notification.oneTimeProductNotification;
|
|
1358
1425
|
if (notificationType !== 1 && notificationType !== 2) {
|
|
1359
|
-
log.warn("[onesub/google] Unknown oneTimeProductNotification type
|
|
1426
|
+
log.warn("[onesub/google] Unknown oneTimeProductNotification type", {
|
|
1427
|
+
notificationType,
|
|
1428
|
+
productId: sku,
|
|
1429
|
+
packageName: notification.packageName
|
|
1430
|
+
});
|
|
1360
1431
|
return null;
|
|
1361
1432
|
}
|
|
1362
1433
|
return { notificationType, purchaseToken, sku, packageName: notification.packageName };
|