@pithy-sh/payments 0.1.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/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/paddle-prices.iife.js +1 -0
- package/dist/pithy.manifest.json +68 -0
- package/docs/lemon-squeezy.md +167 -0
- package/docs/paddle.md +486 -0
- package/package.json +73 -0
- package/pithy.manifest.json +68 -0
- package/src/admin/coverage.ts +107 -0
- package/src/admin/read.ts +360 -0
- package/src/audit/actions.ts +214 -0
- package/src/capability.ts +377 -0
- package/src/client/api.ts +734 -0
- package/src/client/checkout.ts +252 -0
- package/src/client/fixtures/price-preview-de.json +54 -0
- package/src/client/fixtures/price-preview-gb.json +54 -0
- package/src/client/fixtures/price-preview-jp-yen.json +54 -0
- package/src/client/fixtures/price-preview-us-country-only.json +54 -0
- package/src/client/fixtures/price-preview-us-new-york.json +54 -0
- package/src/client/fixtures/pricePreview.ts +67 -0
- package/src/client/hooks.ts +590 -0
- package/src/client/paddle.ts +733 -0
- package/src/client/paddleCache.ts +265 -0
- package/src/client/paddlePrices.iife.ts +43 -0
- package/src/client/paddlePrices.ts +143 -0
- package/src/client/paddlePricesTag.ts +332 -0
- package/src/client/projection.ts +133 -0
- package/src/client/test-utils/cacheStore.ts +41 -0
- package/src/client/wholeUnits.ts +147 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/config/config.ts +563 -0
- package/src/data/discount.ts +238 -0
- package/src/data/entitlement.ts +67 -0
- package/src/data/money.ts +75 -0
- package/src/data/pause.ts +148 -0
- package/src/data/providerAccount.ts +47 -0
- package/src/data/purchase.ts +131 -0
- package/src/data/rail.ts +74 -0
- package/src/data/reconcileRun.ts +208 -0
- package/src/data/renderMoney.ts +180 -0
- package/src/data/status.ts +67 -0
- package/src/data/subject.ts +148 -0
- package/src/data/subscription.ts +675 -0
- package/src/data/syncCursor.ts +44 -0
- package/src/data/tables.ts +61 -0
- package/src/data/webhookEvent.ts +196 -0
- package/src/entitlement/manual.ts +209 -0
- package/src/entitlement/resolver.ts +125 -0
- package/src/entitlement/subjectSeam.ts +171 -0
- package/src/error/errors.ts +421 -0
- package/src/grants/apply.ts +207 -0
- package/src/grants/clawback.ts +128 -0
- package/src/grants/ledgerSeam.ts +141 -0
- package/src/http/dispatch.ts +48 -0
- package/src/http/guards.ts +58 -0
- package/src/http/manifestConfig.ts +56 -0
- package/src/http/responses.ts +1147 -0
- package/src/http/routes.ts +2911 -0
- package/src/http/schemas.ts +577 -0
- package/src/http/scopes.ts +260 -0
- package/src/http/view.ts +188 -0
- package/src/http/webhookGuard.ts +341 -0
- package/src/index.ts +231 -0
- package/src/migrations/0001_purchases.ts +312 -0
- package/src/pricing/location.ts +175 -0
- package/src/pricing/visitor.ts +82 -0
- package/src/projection/event.ts +101 -0
- package/src/projection/orphans.ts +226 -0
- package/src/projection/owner.ts +247 -0
- package/src/projection/resolve.ts +57 -0
- package/src/projection/writer.ts +554 -0
- package/src/provision/resolvePaymentsConfig.ts +91 -0
- package/src/rails/apple/certs.ts +66 -0
- package/src/rails/apple/der.ts +249 -0
- package/src/rails/apple/fixtures/chain.ts +412 -0
- package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
- package/src/rails/apple/fixtures/did-renew.json +48 -0
- package/src/rails/apple/fixtures/one-time-charge.json +32 -0
- package/src/rails/apple/fixtures/refund.json +33 -0
- package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
- package/src/rails/apple/fixtures/test.json +14 -0
- package/src/rails/apple/http.ts +128 -0
- package/src/rails/apple/jws.ts +123 -0
- package/src/rails/apple/notification.ts +357 -0
- package/src/rails/apple/rail.ts +114 -0
- package/src/rails/apple/serverApi.ts +412 -0
- package/src/rails/apple/verify.ts +93 -0
- package/src/rails/apple/x509.ts +424 -0
- package/src/rails/contract.ts +939 -0
- package/src/rails/google/fixtures/play-product-purchased.json +14 -0
- package/src/rails/google/fixtures/play-subscription-active.json +23 -0
- package/src/rails/google/fixtures/push.ts +152 -0
- package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
- package/src/rails/google/fixtures/rtdn-test.json +8 -0
- package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
- package/src/rails/google/http.ts +124 -0
- package/src/rails/google/jwt.ts +127 -0
- package/src/rails/google/oidc.ts +308 -0
- package/src/rails/google/playApi.ts +598 -0
- package/src/rails/google/rail.ts +174 -0
- package/src/rails/google/rtdn.ts +331 -0
- package/src/rails/google/verify.ts +127 -0
- package/src/rails/lemonSqueezy/api.ts +192 -0
- package/src/rails/lemonSqueezy/checkout.ts +186 -0
- package/src/rails/lemonSqueezy/discounts.ts +166 -0
- package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
- package/src/rails/lemonSqueezy/objects.ts +511 -0
- package/src/rails/lemonSqueezy/portal.ts +82 -0
- package/src/rails/lemonSqueezy/pricing.ts +124 -0
- package/src/rails/lemonSqueezy/rail.ts +137 -0
- package/src/rails/lemonSqueezy/read.ts +72 -0
- package/src/rails/lemonSqueezy/refresh.ts +86 -0
- package/src/rails/lemonSqueezy/signature.ts +119 -0
- package/src/rails/lemonSqueezy/verify.ts +45 -0
- package/src/rails/lemonSqueezy/webhook.ts +326 -0
- package/src/rails/paddle/adjustments.ts +94 -0
- package/src/rails/paddle/api.ts +266 -0
- package/src/rails/paddle/checkout.ts +240 -0
- package/src/rails/paddle/discounts.ts +176 -0
- package/src/rails/paddle/events.ts +285 -0
- package/src/rails/paddle/fixtures/browserForged.ts +54 -0
- package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
- package/src/rails/paddle/objects.ts +883 -0
- package/src/rails/paddle/portal.ts +130 -0
- package/src/rails/paddle/rail.ts +219 -0
- package/src/rails/paddle/read.ts +108 -0
- package/src/rails/paddle/recorded.ts +83 -0
- package/src/rails/paddle/refresh.ts +153 -0
- package/src/rails/paddle/refund.ts +297 -0
- package/src/rails/paddle/signature.ts +225 -0
- package/src/rails/paddle/subscription.ts +793 -0
- package/src/rails/paddle/verify.ts +120 -0
- package/src/rails/paddle/webhook.ts +405 -0
- package/src/rails/providers.ts +168 -0
- package/src/rails/stripe/api.ts +253 -0
- package/src/rails/stripe/checkout.ts +175 -0
- package/src/rails/stripe/discounts.ts +169 -0
- package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
- package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
- package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
- package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
- package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
- package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
- package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
- package/src/rails/stripe/fixtures/events.ts +111 -0
- package/src/rails/stripe/objects.ts +642 -0
- package/src/rails/stripe/portal.ts +45 -0
- package/src/rails/stripe/pricing.ts +123 -0
- package/src/rails/stripe/rail.ts +139 -0
- package/src/rails/stripe/refresh.ts +79 -0
- package/src/rails/stripe/signature.ts +127 -0
- package/src/rails/stripe/verify.ts +105 -0
- package/src/rails/stripe/webhook.ts +79 -0
- package/src/secret/registry.ts +275 -0
- package/src/seeds/example.ts +181 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/paddleSweep.ts +717 -0
- package/src/workflows/railAccess.ts +87 -0
- package/src/workflows/reconcile.ts +648 -0
- package/src/workflows/report.ts +64 -0
- package/src/workflows/retryPolicy.ts +40 -0
- package/src/workflows/specs.ts +143 -0
- package/src/workflows/worker.ts +166 -0
- package/src/workflows/workerConfig.ts +73 -0
- package/src/workflows/wrangler.jsonc +55 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "androidpublisher#productPurchase",
|
|
3
|
+
"purchaseTimeMillis": "1768435200000",
|
|
4
|
+
"purchaseState": 0,
|
|
5
|
+
"consumptionState": 0,
|
|
6
|
+
"developerPayload": "",
|
|
7
|
+
"orderId": "GPA.3311-8452-9910-77304",
|
|
8
|
+
"acknowledgementState": 1,
|
|
9
|
+
"obfuscatedExternalAccountId": "b7e1c94f2a6d4c0e",
|
|
10
|
+
"regionCode": "US",
|
|
11
|
+
"quantity": 1,
|
|
12
|
+
"productId": "coins_100",
|
|
13
|
+
"purchaseToken": "ppgmhnokdjkfbicoloakhnli.AO-J1OyR4wKcTz8mHbXe1uQfLp"
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "androidpublisher#subscriptionPurchaseV2",
|
|
3
|
+
"regionCode": "US",
|
|
4
|
+
"startTime": "2026-01-01T00:00:00Z",
|
|
5
|
+
"subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
|
|
6
|
+
"latestOrderId": "GPA.3311-8452-9910-77301..0",
|
|
7
|
+
"acknowledgementState": "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
|
|
8
|
+
"externalAccountIdentifiers": {
|
|
9
|
+
"obfuscatedExternalAccountId": "b7e1c94f2a6d4c0e"
|
|
10
|
+
},
|
|
11
|
+
"lineItems": [
|
|
12
|
+
{
|
|
13
|
+
"productId": "pro_monthly",
|
|
14
|
+
"expiryTime": "2026-02-01T00:00:00Z",
|
|
15
|
+
"autoRenewingPlan": {
|
|
16
|
+
"autoRenewEnabled": true
|
|
17
|
+
},
|
|
18
|
+
"offerDetails": {
|
|
19
|
+
"basePlanId": "monthly"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { GoogleJwk } from "../oidc";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Test material for Google's rail: a real RSA key, real RS256 signatures, and the Pub/Sub push envelope
|
|
8
|
+
* Google wraps a Real-time Developer Notification in.
|
|
9
|
+
*
|
|
10
|
+
* The OIDC token on a push is the authenticity boundary for the Google webhook, so its tests have to exercise
|
|
11
|
+
* genuine cryptography — a stubbed verifier proves only that a mock refuses when told to. And no test may
|
|
12
|
+
* reach Google. So the suites mint their own signing key here, publish it as a JWK the verifier is given, and
|
|
13
|
+
* sign real tokens with it: the same arrangement as the Apple rail's minted certificate chain, one layer
|
|
14
|
+
* simpler because Google publishes bare keys rather than a chain.
|
|
15
|
+
*
|
|
16
|
+
* That makes the negative cases real too. A token signed by the wrong key is a second minted key. An expired
|
|
17
|
+
* token is a real signature over a past `exp`. An algorithm-confused token is a genuine RSA signature under a
|
|
18
|
+
* header that lies about it. None of them is a refusal on command.
|
|
19
|
+
*
|
|
20
|
+
* This module signs and encodes; the shipped code only verifies and decodes. It lives under `fixtures/` for
|
|
21
|
+
* that reason, next to the recorded notification payloads.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** A minted signing key: the JWK a verifier is given, and the private half a test signs with. */
|
|
25
|
+
export interface MintedOidcKey {
|
|
26
|
+
/** The key id, matched against a token header's `kid`. */
|
|
27
|
+
kid: string;
|
|
28
|
+
/** The public key as a JWK, in the shape Google's own key endpoint publishes. */
|
|
29
|
+
jwk: GoogleJwk;
|
|
30
|
+
/** The private half. What signs a token. */
|
|
31
|
+
privateKey: CryptoKey;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** RS256, which is the only algorithm Google signs a Pub/Sub push token with. */
|
|
35
|
+
const RS256 = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" } as const;
|
|
36
|
+
|
|
37
|
+
/** Mint one RSA key pair and publish its public half as a JWK. */
|
|
38
|
+
export async function mintOidcKey(kid = "pithy-test-1"): Promise<MintedOidcKey> {
|
|
39
|
+
// `generateKey`'s return type is the union of both its overloads; an RSA algorithm always yields a pair.
|
|
40
|
+
const pair = (await crypto.subtle.generateKey(
|
|
41
|
+
{ ...RS256, modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]) },
|
|
42
|
+
true,
|
|
43
|
+
["sign", "verify"],
|
|
44
|
+
)) as CryptoKeyPair;
|
|
45
|
+
// `exportKey`'s type is the union over every format; `jwk` always yields a JsonWebKey.
|
|
46
|
+
const exported = (await crypto.subtle.exportKey("jwk", pair.publicKey)) as JsonWebKey;
|
|
47
|
+
if (exported.kty === undefined || exported.n === undefined || exported.e === undefined) {
|
|
48
|
+
// Cannot happen for an RSA key, and a fixture that silently minted a key with an empty modulus would make
|
|
49
|
+
// every signature test pass against nothing.
|
|
50
|
+
throw new Error("WebCrypto exported an RSA public key with no kty, n, or e.");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
kid,
|
|
54
|
+
// `kty`, `n`, and `e` come from the export; `kid`, `alg`, and `use` are what a key endpoint adds.
|
|
55
|
+
jwk: { ...exported, kty: exported.kty, n: exported.n, e: exported.e, kid, alg: "RS256", use: "sig" },
|
|
56
|
+
privateKey: pair.privateKey,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A minted service-account key: the PEM a credential bundle carries, and the public half to check it with. */
|
|
61
|
+
export interface MintedServiceAccountKey {
|
|
62
|
+
/** The PKCS#8 PEM, exactly as the `private_key` field of a downloaded Google key file holds it. */
|
|
63
|
+
pem: string;
|
|
64
|
+
/** The public half, so a test can verify the assertion the shipped code signed rather than trusting it. */
|
|
65
|
+
publicKey: CryptoKey;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Mint an RSA key pair and armour the private half as the PKCS#8 PEM a Google service-account key file carries.
|
|
70
|
+
*
|
|
71
|
+
* The public half comes back so a suite can **verify** the assertion the shipped signer produced. That is the
|
|
72
|
+
* difference between asserting we posted something to the token endpoint and asserting we posted a signature
|
|
73
|
+
* Google would accept.
|
|
74
|
+
*/
|
|
75
|
+
export async function mintServiceAccountKey(): Promise<MintedServiceAccountKey> {
|
|
76
|
+
const pair = (await crypto.subtle.generateKey(
|
|
77
|
+
{ ...RS256, modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]) },
|
|
78
|
+
true,
|
|
79
|
+
["sign", "verify"],
|
|
80
|
+
)) as CryptoKeyPair;
|
|
81
|
+
const pkcs8 = new Uint8Array((await crypto.subtle.exportKey("pkcs8", pair.privateKey)) as ArrayBuffer);
|
|
82
|
+
let binary = "";
|
|
83
|
+
for (const byte of pkcs8) binary += String.fromCharCode(byte);
|
|
84
|
+
const body = (btoa(binary).match(/.{1,64}/g) ?? []).join("\n");
|
|
85
|
+
return {
|
|
86
|
+
pem: `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----\n`,
|
|
87
|
+
publicKey: pair.publicKey,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** base64url without padding — how every segment of a JWS is encoded. */
|
|
92
|
+
function base64Url(bytes: Uint8Array): string {
|
|
93
|
+
let binary = "";
|
|
94
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
95
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Sign a compact JWT the way Google signs a push token: an `RS256` header naming the key, the claims, and an
|
|
100
|
+
* RSASSA-PKCS1-v1_5 signature over `header.payload`.
|
|
101
|
+
*
|
|
102
|
+
* `header` overrides let a test present a header Google never would — `alg: "none"`, `alg: "HS256"`, a `kid`
|
|
103
|
+
* nobody published — which is how algorithm confusion is proved rejected rather than assumed impossible.
|
|
104
|
+
*/
|
|
105
|
+
export async function signOidcToken(
|
|
106
|
+
claims: unknown,
|
|
107
|
+
key: MintedOidcKey,
|
|
108
|
+
header: Record<string, unknown> = {},
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
const encoder = new TextEncoder();
|
|
111
|
+
const head = base64Url(encoder.encode(JSON.stringify({ alg: "RS256", typ: "JWT", kid: key.kid, ...header })));
|
|
112
|
+
const body = base64Url(encoder.encode(JSON.stringify(claims)));
|
|
113
|
+
const signature = new Uint8Array(
|
|
114
|
+
await crypto.subtle.sign(RS256.name, key.privateKey, encoder.encode(`${head}.${body}`)),
|
|
115
|
+
);
|
|
116
|
+
return `${head}.${body}.${base64Url(signature)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A compact JWT with its claims swapped for others, keeping the original signature. The tamper case. */
|
|
120
|
+
export function tamperClaims(token: string, claims: unknown): string {
|
|
121
|
+
const [head, , signature] = token.split(".");
|
|
122
|
+
return `${head}.${base64Url(new TextEncoder().encode(JSON.stringify(claims)))}.${signature}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What a test varies about the envelope around a notification. */
|
|
126
|
+
export interface PushEnvelopeOptions {
|
|
127
|
+
/** Pub/Sub's own message id — the dedupe key, stable across redeliveries of one message. */
|
|
128
|
+
messageId?: string;
|
|
129
|
+
/** When Pub/Sub published it, RFC 3339. */
|
|
130
|
+
publishTime?: string;
|
|
131
|
+
/** The push subscription's resource name. Informational; nothing is decided from it. */
|
|
132
|
+
subscription?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Wrap a decoded Developer Notification in the Pub/Sub push envelope Google POSTs.
|
|
137
|
+
*
|
|
138
|
+
* `data` is **standard** base64 with padding, which is what Pub/Sub emits — deliberately not base64url, so the
|
|
139
|
+
* decoder under test is exercised against the alphabet it will really receive.
|
|
140
|
+
*/
|
|
141
|
+
export function pushBody(notification: unknown, options: PushEnvelopeOptions = {}): string {
|
|
142
|
+
let binary = "";
|
|
143
|
+
for (const byte of new TextEncoder().encode(JSON.stringify(notification))) binary += String.fromCharCode(byte);
|
|
144
|
+
return JSON.stringify({
|
|
145
|
+
message: {
|
|
146
|
+
data: btoa(binary),
|
|
147
|
+
messageId: options.messageId ?? "6714080000000001",
|
|
148
|
+
publishTime: options.publishTime ?? "2026-01-15T00:00:00.000Z",
|
|
149
|
+
},
|
|
150
|
+
subscription: options.subscription ?? "projects/acme-42/subscriptions/pithy-payments-rtdn",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0",
|
|
3
|
+
"packageName": "com.acme.app",
|
|
4
|
+
"eventTimeMillis": "1768435200000",
|
|
5
|
+
"oneTimeProductNotification": {
|
|
6
|
+
"version": "1.0",
|
|
7
|
+
"notificationType": 1,
|
|
8
|
+
"purchaseToken": "ppgmhnokdjkfbicoloakhnli.AO-J1OyR4wKcTz8mHbXe1uQfLp",
|
|
9
|
+
"sku": "coins_100"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0",
|
|
3
|
+
"packageName": "com.acme.app",
|
|
4
|
+
"eventTimeMillis": "1768435200000",
|
|
5
|
+
"subscriptionNotification": {
|
|
6
|
+
"version": "1.0",
|
|
7
|
+
"notificationType": 2,
|
|
8
|
+
"purchaseToken": "gjdmnbkpaifcmlkgomhnpjbi.AO-J1OwXn3rM5pQe7vT2yLb0dK",
|
|
9
|
+
"subscriptionId": "pro_monthly"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0",
|
|
3
|
+
"packageName": "com.acme.app",
|
|
4
|
+
"eventTimeMillis": "1768521600000",
|
|
5
|
+
"subscriptionNotification": {
|
|
6
|
+
"version": "1.0",
|
|
7
|
+
"notificationType": 12,
|
|
8
|
+
"purchaseToken": "gjdmnbkpaifcmlkgomhnpjbi.AO-J1OwXn3rM5pQe7vT2yLb0dK",
|
|
9
|
+
"subscriptionId": "pro_monthly"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0",
|
|
3
|
+
"packageName": "com.acme.app",
|
|
4
|
+
"eventTimeMillis": "1768608000000",
|
|
5
|
+
"voidedPurchaseNotification": {
|
|
6
|
+
"purchaseToken": "ppgmhnokdjkfbicoloakhnli.AO-J1OyR4wKcTz8mHbXe1uQfLp",
|
|
7
|
+
"orderId": "GPA.3311-8452-9910-77304",
|
|
8
|
+
"productType": 2,
|
|
9
|
+
"refundType": 1
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PaymentsProviderUnavailableError } from "../../error/errors";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one door out to Google, and the mapping from how it answered to what that means.
|
|
8
|
+
*
|
|
9
|
+
* **Why the Google rail reaches the network at all.** Apple hands us a signed transaction that verifies
|
|
10
|
+
* offline; Play hands us a purchase token, which is a pointer and nothing more. So Google's rail has two
|
|
11
|
+
* unavoidable round-trips — the JWKS that verifies a push token, and the Play Developer API that turns a
|
|
12
|
+
* pointer into a state — and every failure mode of those round-trips has to land somewhere deliberate.
|
|
13
|
+
*
|
|
14
|
+
* **Every failure but one is `payments/provider_unavailable` (503).** A timeout, a 5xx, a rate limit, a
|
|
15
|
+
* revoked service-account grant, an HTML error page from a proxy: from payments' side these are one
|
|
16
|
+
* statement — *we could not learn the state* — and the correct response to not knowing is to say so and let
|
|
17
|
+
* the caller retry. A silent skip would be the alternative, and a silent skip on a renewal notification
|
|
18
|
+
* revokes a paying subscriber. The webhook route rethrows a 503 so Pub/Sub redelivers, and the
|
|
19
|
+
* reconciliation Workflow repairs whatever redelivery never fixes.
|
|
20
|
+
*
|
|
21
|
+
* **The exception is a 404 the caller asked for.** Play has no "what kind of purchase is this token" call, so
|
|
22
|
+
* a 404 from the subscription endpoint is how a one-time purchase identifies itself. That is a fact about the
|
|
23
|
+
* purchase rather than a failure to reach the store, and only a caller that knows it is probing may treat it
|
|
24
|
+
* as one — hence `absentOn404` rather than a blanket rule.
|
|
25
|
+
*
|
|
26
|
+
* **No refusal carries the URL or the response body.** A Play request URL ends in the purchase token, and
|
|
27
|
+
* Google's error bodies quote the request. `detail` reaches an operator's logs, and a purchase token is a
|
|
28
|
+
* bearer artifact — the same rule as a receipt.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** One outbound request, narrowed to what Google's two endpoints need. */
|
|
32
|
+
export interface GoogleHttpRequest {
|
|
33
|
+
/** The HTTP method. Defaults to GET. */
|
|
34
|
+
method?: string;
|
|
35
|
+
/** Request headers — the bearer token, or the token endpoint's form content type. */
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
/** The request body, already encoded. */
|
|
38
|
+
body?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The response shape this module reads. Structural, so a test's transport need not be a whole `Response`. */
|
|
42
|
+
export interface GoogleHttpResponse {
|
|
43
|
+
/** Whether the status is 2xx. */
|
|
44
|
+
ok: boolean;
|
|
45
|
+
/** The status code, which is what decides the mapping. */
|
|
46
|
+
status: number;
|
|
47
|
+
/** The body as text. Read as text rather than JSON so a non-JSON answer is a diagnosis, not a throw. */
|
|
48
|
+
text(): Promise<string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The HTTP seam for Google's endpoints.
|
|
53
|
+
*
|
|
54
|
+
* Injectable for exactly one reason: **no test may reach a live store**, and a rail whose network call could
|
|
55
|
+
* not be substituted would have to be tested through a stub of itself, which proves nothing. One explicit
|
|
56
|
+
* parameter is better than reassigning a global — a global stub leaks between suites and hides which module
|
|
57
|
+
* was actually exercised.
|
|
58
|
+
*/
|
|
59
|
+
export type GoogleHttpFetch = (url: string, init?: GoogleHttpRequest) => Promise<GoogleHttpResponse>;
|
|
60
|
+
|
|
61
|
+
/** The default transport: the runtime's own `fetch`. */
|
|
62
|
+
export const googleHttpFetch: GoogleHttpFetch = (url, init) =>
|
|
63
|
+
fetch(url, init) as unknown as Promise<GoogleHttpResponse>;
|
|
64
|
+
|
|
65
|
+
/** What one request is for, and how its answers should be read. */
|
|
66
|
+
export interface GoogleJsonOptions {
|
|
67
|
+
/** What is being fetched, in a `detail` line: "the subscription", "an access token". Never the URL. */
|
|
68
|
+
what: string;
|
|
69
|
+
/** The HTTP method. Defaults to GET. */
|
|
70
|
+
method?: string;
|
|
71
|
+
/** Request headers. */
|
|
72
|
+
headers?: Record<string, string>;
|
|
73
|
+
/** The request body, already encoded. */
|
|
74
|
+
body?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Whether a 404 means "no such purchase" rather than a failure. Set only by a caller that is deliberately
|
|
77
|
+
* probing — Play's subscription endpoint answering 404 is how a one-time purchase is recognized.
|
|
78
|
+
*/
|
|
79
|
+
absentOn404?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One request to Google, and its parsed JSON body — or `undefined` when a 404 was a legitimate answer.
|
|
84
|
+
*
|
|
85
|
+
* The body comes back as `unknown`. Reaching an endpoint proves who answered, never what they said, so every
|
|
86
|
+
* caller Zod-parses its own shape.
|
|
87
|
+
*/
|
|
88
|
+
export async function googleJson(
|
|
89
|
+
transport: GoogleHttpFetch,
|
|
90
|
+
url: string,
|
|
91
|
+
options: GoogleJsonOptions,
|
|
92
|
+
): Promise<unknown | undefined> {
|
|
93
|
+
let response: GoogleHttpResponse;
|
|
94
|
+
try {
|
|
95
|
+
response = await transport(url, { method: options.method, headers: options.headers, body: options.body });
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
throw unavailable(`Google did not answer when asked for ${options.what}.`, cause);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (response.status === 404 && options.absentOn404) return undefined;
|
|
101
|
+
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
// 401 and 403 are the two an operator can actually fix, and they have one overwhelmingly common cause.
|
|
104
|
+
const hint =
|
|
105
|
+
response.status === 401 || response.status === 403
|
|
106
|
+
? " Check that the service account is linked in the Play Console under Users and permissions with financial-data access, and that the Google Play Android Developer API is enabled for its project."
|
|
107
|
+
: "";
|
|
108
|
+
throw unavailable(`Google answered ${response.status} when asked for ${options.what}.${hint}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const text = await response.text();
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(text) as unknown;
|
|
114
|
+
} catch (cause) {
|
|
115
|
+
// An HTML error page behind a proxy is the realistic shape of this. Reading it as an absent purchase would
|
|
116
|
+
// revoke somebody's subscription, so it is a failure to reach the store like any other.
|
|
117
|
+
throw unavailable(`Google answered with a non-JSON body when asked for ${options.what}.`, cause);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The 503, with the operator's context in `detail` and nothing about the request in either field. */
|
|
122
|
+
function unavailable(detail: string, cause?: unknown): PaymentsProviderUnavailableError {
|
|
123
|
+
return new PaymentsProviderUnavailableError({ detail }, cause === undefined ? undefined : { cause });
|
|
124
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PaymentsInvalidReceiptError, PaymentsRailNotConfiguredError } from "../../error/errors";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The JWT primitives Google's two token jobs share.
|
|
8
|
+
*
|
|
9
|
+
* Google's rail is the only one that both **verifies** a JWT and **signs** one. It verifies the OIDC token a
|
|
10
|
+
* Pub/Sub push carries, which is what proves a notification came from Google; it signs a service-account
|
|
11
|
+
* assertion, which is what buys the access token the Play Developer API demands. Both are compact JWS, so
|
|
12
|
+
* both need base64url in both directions — and the shipped Apple modules need it in one direction only,
|
|
13
|
+
* because Apple hands us signed data and never asks us to sign any.
|
|
14
|
+
*
|
|
15
|
+
* That is why these live here rather than being taken from `rails/apple/der.ts`: that module is an ASN.1
|
|
16
|
+
* reader whose refusals carry DER offsets, it has no encoder, and a Google module reaching into an Apple one
|
|
17
|
+
* would make the rails depend on each other rather than on the shared contract. Small, local, and named for
|
|
18
|
+
* the rail that uses it.
|
|
19
|
+
*
|
|
20
|
+
* **Every input here is hostile.** A push token arrives in an unauthenticated request — verifying it is the
|
|
21
|
+
* whole point — so a malformed segment is refused rather than coerced, and no refusal echoes the bytes it
|
|
22
|
+
* refused. A token is a bearer artifact and `detail` reaches logs.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Bytes from one base64url segment. `label` names the segment, so a refusal says which one failed. */
|
|
26
|
+
export function base64UrlDecode(value: string, label: string): Uint8Array {
|
|
27
|
+
// Zero bytes is never a valid segment. An empty signature is `alg: none` expressed structurally, and
|
|
28
|
+
// decoding it to an empty buffer would hand a verifier something to compare against nothing.
|
|
29
|
+
if (value.length === 0) throw malformed(`${label} is empty; an unsigned token is not accepted.`);
|
|
30
|
+
const standard = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
31
|
+
const padded = standard.padEnd(standard.length + ((4 - (standard.length % 4)) % 4), "=");
|
|
32
|
+
let binary: string;
|
|
33
|
+
try {
|
|
34
|
+
binary = atob(padded);
|
|
35
|
+
} catch (cause) {
|
|
36
|
+
throw malformed(`${label} is not valid base64url.`, cause);
|
|
37
|
+
}
|
|
38
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One base64url segment from bytes — the unpadded URL alphabet a JWS is assembled from. */
|
|
42
|
+
export function base64UrlEncode(bytes: Uint8Array): string {
|
|
43
|
+
let binary = "";
|
|
44
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
45
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The three segments of a compact JWT, as received. Kept as strings: a signature covers the exact bytes. */
|
|
49
|
+
export interface JwtSegments {
|
|
50
|
+
/** The base64url header, still encoded. */
|
|
51
|
+
head: string;
|
|
52
|
+
/** The base64url claim set, still encoded. */
|
|
53
|
+
body: string;
|
|
54
|
+
/** The base64url signature, still encoded. */
|
|
55
|
+
mac: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Split a compact JWT, refusing anything that is not three non-empty segments.
|
|
60
|
+
*
|
|
61
|
+
* The empty-segment check is the structural half of the algorithm pin: `alg: none` produces a header, a
|
|
62
|
+
* payload, and nothing after the final dot, so it is refused here before any header is even decoded.
|
|
63
|
+
*/
|
|
64
|
+
export function splitJwt(token: string): JwtSegments {
|
|
65
|
+
const segments = token.split(".");
|
|
66
|
+
if (segments.length !== 3) throw malformed(`the token has ${segments.length} segments, expected 3.`);
|
|
67
|
+
const [head, body, mac] = segments as [string, string, string];
|
|
68
|
+
if (head.length === 0 || body.length === 0 || mac.length === 0) {
|
|
69
|
+
throw malformed("a token segment is empty; an unsigned token is not accepted.");
|
|
70
|
+
}
|
|
71
|
+
return { head, body, mac };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One base64url JSON segment. `unknown` out — decoded bytes are not yet a known shape. */
|
|
75
|
+
export function decodeJwtJson(segment: string, label: string): unknown {
|
|
76
|
+
const text = new TextDecoder().decode(base64UrlDecode(segment, label));
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(text) as unknown;
|
|
79
|
+
} catch (cause) {
|
|
80
|
+
// Never echo the text. On the push path it is attacker-supplied, and `detail` reaches an operator's logs.
|
|
81
|
+
throw malformed(`${label} is not JSON.`, cause);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The PEM armour of a PKCS#8 private key — the only form `crypto.subtle.importKey("pkcs8", …)` reads. */
|
|
86
|
+
const PKCS8_HEADER = "-----BEGIN PRIVATE KEY-----";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* PKCS#8 DER from the PEM a Google service-account key file carries.
|
|
90
|
+
*
|
|
91
|
+
* Two accommodations, both earned. Escaped `\n` sequences are honored because the key lives inside the
|
|
92
|
+
* downloaded JSON as a single line and arrives that way whenever a credential is moved by hand — refusing it
|
|
93
|
+
* would read as a bug in us. And a PKCS#1 body (`BEGIN RSA PRIVATE KEY`) is named rather than passed on,
|
|
94
|
+
* because WebCrypto's answer to one is an opaque `DataError` with nothing an operator can act on.
|
|
95
|
+
*
|
|
96
|
+
* A bad key is `payments/rail_not_configured`: from a caller's side the rail genuinely is not available, and
|
|
97
|
+
* a credential that cannot be read is a provisioning failure rather than a store outage.
|
|
98
|
+
*/
|
|
99
|
+
export function pemPrivateKey(pem: string): Uint8Array {
|
|
100
|
+
const text = pem.replaceAll("\\n", "\n");
|
|
101
|
+
if (!text.includes(PKCS8_HEADER)) {
|
|
102
|
+
throw new PaymentsRailNotConfiguredError({
|
|
103
|
+
detail: text.includes("PRIVATE KEY")
|
|
104
|
+
? "Google: the service-account private key is not PKCS#8. Use the `private_key` field of the downloaded JSON key file, which begins `-----BEGIN PRIVATE KEY-----`."
|
|
105
|
+
: "Google: the service-account private key carries no PEM armour. Store the `private_key` field of the downloaded JSON key file verbatim.",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const body = text
|
|
109
|
+
.slice(text.indexOf(PKCS8_HEADER) + PKCS8_HEADER.length)
|
|
110
|
+
.replace(/-----END PRIVATE KEY-----[\s\S]*$/, "")
|
|
111
|
+
.replaceAll(/\s+/g, "");
|
|
112
|
+
try {
|
|
113
|
+
// The decoder maps `-`/`_` onto `+`/`/` and leaves the standard alphabet alone, so a PEM body — which is
|
|
114
|
+
// standard base64 — decodes through it unchanged. One decoder, one refusal path.
|
|
115
|
+
return base64UrlDecode(body, "the service-account private key");
|
|
116
|
+
} catch (cause) {
|
|
117
|
+
throw new PaymentsRailNotConfiguredError(
|
|
118
|
+
{ detail: "Google: the service-account private key's PEM body is not valid base64." },
|
|
119
|
+
{ cause },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A structural refusal. `Google:` prefixed so a log line says which rail failed to read something. */
|
|
125
|
+
function malformed(detail: string, cause?: unknown): PaymentsInvalidReceiptError {
|
|
126
|
+
return new PaymentsInvalidReceiptError({ detail: `Google: ${detail}` }, cause === undefined ? undefined : { cause });
|
|
127
|
+
}
|