@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.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/dist/paddle-prices.iife.js +1 -0
  4. package/dist/pithy.manifest.json +68 -0
  5. package/docs/lemon-squeezy.md +167 -0
  6. package/docs/paddle.md +486 -0
  7. package/package.json +73 -0
  8. package/pithy.manifest.json +68 -0
  9. package/src/admin/coverage.ts +107 -0
  10. package/src/admin/read.ts +360 -0
  11. package/src/audit/actions.ts +214 -0
  12. package/src/capability.ts +377 -0
  13. package/src/client/api.ts +734 -0
  14. package/src/client/checkout.ts +252 -0
  15. package/src/client/fixtures/price-preview-de.json +54 -0
  16. package/src/client/fixtures/price-preview-gb.json +54 -0
  17. package/src/client/fixtures/price-preview-jp-yen.json +54 -0
  18. package/src/client/fixtures/price-preview-us-country-only.json +54 -0
  19. package/src/client/fixtures/price-preview-us-new-york.json +54 -0
  20. package/src/client/fixtures/pricePreview.ts +67 -0
  21. package/src/client/hooks.ts +590 -0
  22. package/src/client/paddle.ts +733 -0
  23. package/src/client/paddleCache.ts +265 -0
  24. package/src/client/paddlePrices.iife.ts +43 -0
  25. package/src/client/paddlePrices.ts +143 -0
  26. package/src/client/paddlePricesTag.ts +332 -0
  27. package/src/client/projection.ts +133 -0
  28. package/src/client/test-utils/cacheStore.ts +41 -0
  29. package/src/client/wholeUnits.ts +147 -0
  30. package/src/cloudflare-test.d.ts +16 -0
  31. package/src/config/config.ts +563 -0
  32. package/src/data/discount.ts +238 -0
  33. package/src/data/entitlement.ts +67 -0
  34. package/src/data/money.ts +75 -0
  35. package/src/data/pause.ts +148 -0
  36. package/src/data/providerAccount.ts +47 -0
  37. package/src/data/purchase.ts +131 -0
  38. package/src/data/rail.ts +74 -0
  39. package/src/data/reconcileRun.ts +208 -0
  40. package/src/data/renderMoney.ts +180 -0
  41. package/src/data/status.ts +67 -0
  42. package/src/data/subject.ts +148 -0
  43. package/src/data/subscription.ts +675 -0
  44. package/src/data/syncCursor.ts +44 -0
  45. package/src/data/tables.ts +61 -0
  46. package/src/data/webhookEvent.ts +196 -0
  47. package/src/entitlement/manual.ts +209 -0
  48. package/src/entitlement/resolver.ts +125 -0
  49. package/src/entitlement/subjectSeam.ts +171 -0
  50. package/src/error/errors.ts +421 -0
  51. package/src/grants/apply.ts +207 -0
  52. package/src/grants/clawback.ts +128 -0
  53. package/src/grants/ledgerSeam.ts +141 -0
  54. package/src/http/dispatch.ts +48 -0
  55. package/src/http/guards.ts +58 -0
  56. package/src/http/manifestConfig.ts +56 -0
  57. package/src/http/responses.ts +1147 -0
  58. package/src/http/routes.ts +2911 -0
  59. package/src/http/schemas.ts +577 -0
  60. package/src/http/scopes.ts +260 -0
  61. package/src/http/view.ts +188 -0
  62. package/src/http/webhookGuard.ts +341 -0
  63. package/src/index.ts +231 -0
  64. package/src/migrations/0001_purchases.ts +312 -0
  65. package/src/pricing/location.ts +175 -0
  66. package/src/pricing/visitor.ts +82 -0
  67. package/src/projection/event.ts +101 -0
  68. package/src/projection/orphans.ts +226 -0
  69. package/src/projection/owner.ts +247 -0
  70. package/src/projection/resolve.ts +57 -0
  71. package/src/projection/writer.ts +554 -0
  72. package/src/provision/resolvePaymentsConfig.ts +91 -0
  73. package/src/rails/apple/certs.ts +66 -0
  74. package/src/rails/apple/der.ts +249 -0
  75. package/src/rails/apple/fixtures/chain.ts +412 -0
  76. package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
  77. package/src/rails/apple/fixtures/did-renew.json +48 -0
  78. package/src/rails/apple/fixtures/one-time-charge.json +32 -0
  79. package/src/rails/apple/fixtures/refund.json +33 -0
  80. package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
  81. package/src/rails/apple/fixtures/test.json +14 -0
  82. package/src/rails/apple/http.ts +128 -0
  83. package/src/rails/apple/jws.ts +123 -0
  84. package/src/rails/apple/notification.ts +357 -0
  85. package/src/rails/apple/rail.ts +114 -0
  86. package/src/rails/apple/serverApi.ts +412 -0
  87. package/src/rails/apple/verify.ts +93 -0
  88. package/src/rails/apple/x509.ts +424 -0
  89. package/src/rails/contract.ts +939 -0
  90. package/src/rails/google/fixtures/play-product-purchased.json +14 -0
  91. package/src/rails/google/fixtures/play-subscription-active.json +23 -0
  92. package/src/rails/google/fixtures/push.ts +152 -0
  93. package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
  94. package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
  95. package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
  96. package/src/rails/google/fixtures/rtdn-test.json +8 -0
  97. package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
  98. package/src/rails/google/http.ts +124 -0
  99. package/src/rails/google/jwt.ts +127 -0
  100. package/src/rails/google/oidc.ts +308 -0
  101. package/src/rails/google/playApi.ts +598 -0
  102. package/src/rails/google/rail.ts +174 -0
  103. package/src/rails/google/rtdn.ts +331 -0
  104. package/src/rails/google/verify.ts +127 -0
  105. package/src/rails/lemonSqueezy/api.ts +192 -0
  106. package/src/rails/lemonSqueezy/checkout.ts +186 -0
  107. package/src/rails/lemonSqueezy/discounts.ts +166 -0
  108. package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
  109. package/src/rails/lemonSqueezy/objects.ts +511 -0
  110. package/src/rails/lemonSqueezy/portal.ts +82 -0
  111. package/src/rails/lemonSqueezy/pricing.ts +124 -0
  112. package/src/rails/lemonSqueezy/rail.ts +137 -0
  113. package/src/rails/lemonSqueezy/read.ts +72 -0
  114. package/src/rails/lemonSqueezy/refresh.ts +86 -0
  115. package/src/rails/lemonSqueezy/signature.ts +119 -0
  116. package/src/rails/lemonSqueezy/verify.ts +45 -0
  117. package/src/rails/lemonSqueezy/webhook.ts +326 -0
  118. package/src/rails/paddle/adjustments.ts +94 -0
  119. package/src/rails/paddle/api.ts +266 -0
  120. package/src/rails/paddle/checkout.ts +240 -0
  121. package/src/rails/paddle/discounts.ts +176 -0
  122. package/src/rails/paddle/events.ts +285 -0
  123. package/src/rails/paddle/fixtures/browserForged.ts +54 -0
  124. package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
  125. package/src/rails/paddle/objects.ts +883 -0
  126. package/src/rails/paddle/portal.ts +130 -0
  127. package/src/rails/paddle/rail.ts +219 -0
  128. package/src/rails/paddle/read.ts +108 -0
  129. package/src/rails/paddle/recorded.ts +83 -0
  130. package/src/rails/paddle/refresh.ts +153 -0
  131. package/src/rails/paddle/refund.ts +297 -0
  132. package/src/rails/paddle/signature.ts +225 -0
  133. package/src/rails/paddle/subscription.ts +793 -0
  134. package/src/rails/paddle/verify.ts +120 -0
  135. package/src/rails/paddle/webhook.ts +405 -0
  136. package/src/rails/providers.ts +168 -0
  137. package/src/rails/stripe/api.ts +253 -0
  138. package/src/rails/stripe/checkout.ts +175 -0
  139. package/src/rails/stripe/discounts.ts +169 -0
  140. package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
  141. package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
  142. package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
  143. package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
  144. package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
  145. package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
  146. package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
  147. package/src/rails/stripe/fixtures/events.ts +111 -0
  148. package/src/rails/stripe/objects.ts +642 -0
  149. package/src/rails/stripe/portal.ts +45 -0
  150. package/src/rails/stripe/pricing.ts +123 -0
  151. package/src/rails/stripe/rail.ts +139 -0
  152. package/src/rails/stripe/refresh.ts +79 -0
  153. package/src/rails/stripe/signature.ts +127 -0
  154. package/src/rails/stripe/verify.ts +105 -0
  155. package/src/rails/stripe/webhook.ts +79 -0
  156. package/src/secret/registry.ts +275 -0
  157. package/src/seeds/example.ts +181 -0
  158. package/src/version.generated.ts +16 -0
  159. package/src/workflows/paddleSweep.ts +717 -0
  160. package/src/workflows/railAccess.ts +87 -0
  161. package/src/workflows/reconcile.ts +648 -0
  162. package/src/workflows/report.ts +64 -0
  163. package/src/workflows/retryPolicy.ts +40 -0
  164. package/src/workflows/specs.ts +143 -0
  165. package/src/workflows/worker.ts +166 -0
  166. package/src/workflows/workerConfig.ts +73 -0
  167. 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,8 @@
1
+ {
2
+ "version": "1.0",
3
+ "packageName": "com.acme.app",
4
+ "eventTimeMillis": "1768435200000",
5
+ "testNotification": {
6
+ "version": "1.0"
7
+ }
8
+ }
@@ -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
+ }