@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,412 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Test material: a real X.509 certificate chain and real JWS signatures, minted at test time.
|
|
6
|
+
*
|
|
7
|
+
* Verifying Apple's signature is the security boundary on the webhook route, so its tests must exercise
|
|
8
|
+
* genuine cryptography — a stubbed verifier proves nothing about the one thing that matters. No test may
|
|
9
|
+
* reach a live store either. So the tests mint their own chain here with WebCrypto and sign real
|
|
10
|
+
* certificates with it: leaf ← intermediate ← root, the shape Apple's `x5c` carries, with the root pinned
|
|
11
|
+
* into the verifier the way `APPLE_ROOT_CERTIFICATES` is pinned in production.
|
|
12
|
+
*
|
|
13
|
+
* That makes the negative cases real too. A chain rooted elsewhere is a second minted root the verifier was
|
|
14
|
+
* never given. An expired leaf is a leaf minted with a past `notAfter`. A tampered body is a byte changed
|
|
15
|
+
* after signing. None of them is a mock refusing on command.
|
|
16
|
+
*
|
|
17
|
+
* This file is a DER **encoder** and the only one in the package — the shipped code reads certificates and
|
|
18
|
+
* never writes them, and that asymmetry is deliberate. It lives under `fixtures/` for the same reason the
|
|
19
|
+
* recorded notification payloads do: it is test input, not product.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The curves the minter and Apple's own chain use: P-256 for a leaf, P-384 for a CA. */
|
|
23
|
+
export type TestCurve = "P-256" | "P-384";
|
|
24
|
+
|
|
25
|
+
/** One minted certificate: its DER, its key pair, and the name it was issued to. */
|
|
26
|
+
interface MintedCertificate {
|
|
27
|
+
der: Uint8Array;
|
|
28
|
+
publicKey: CryptoKey;
|
|
29
|
+
privateKey: CryptoKey;
|
|
30
|
+
/** The DER of the subject Name, which the certificate it issues carries as its issuer. */
|
|
31
|
+
subject: Uint8Array;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A minted chain, in the order a JWS `x5c` header carries it. */
|
|
35
|
+
export interface MintedChain {
|
|
36
|
+
/** base64 DER, leaf first, root last. */
|
|
37
|
+
x5c: string[];
|
|
38
|
+
/** The leaf's private key — what signs a JWS. */
|
|
39
|
+
leafKey: CryptoKey;
|
|
40
|
+
/** base64 DER of the root, for pinning into the verifier under test. */
|
|
41
|
+
root: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How a caller bends the chain: shift a validity window, or change a curve. */
|
|
45
|
+
export interface MintChainOptions {
|
|
46
|
+
/** The leaf's validity window. Defaults to a year either side of now. */
|
|
47
|
+
leafNotBefore?: Date;
|
|
48
|
+
leafNotAfter?: Date;
|
|
49
|
+
/** The intermediate's validity window. Defaults to the same. */
|
|
50
|
+
intermediateNotBefore?: Date;
|
|
51
|
+
intermediateNotAfter?: Date;
|
|
52
|
+
/** The leaf's curve. P-256 is what ES256 requires; P-384 is how algorithm confusion is tested. */
|
|
53
|
+
leafCurve?: TestCurve;
|
|
54
|
+
/** A distinguishing name, so two chains minted in one test are told apart. */
|
|
55
|
+
label?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Mint the intermediate without `basicConstraints` CA:TRUE — as an ordinary leaf certificate would be.
|
|
58
|
+
* This is the shape of the attack the CA check exists to refuse: any certificate Apple issued, used to
|
|
59
|
+
* sign a certificate of the attacker's own.
|
|
60
|
+
*/
|
|
61
|
+
intermediateNotCa?: boolean;
|
|
62
|
+
/** Mint the intermediate with `keyUsage` present but `keyCertSign` cleared. */
|
|
63
|
+
intermediateWithoutKeyCertSign?: boolean;
|
|
64
|
+
/** `pathLenConstraint` on the intermediate, so a chain deeper than it permits can be minted. */
|
|
65
|
+
intermediatePathLen?: number;
|
|
66
|
+
/** Omit Apple's receipt-signer marker from the leaf. */
|
|
67
|
+
leafWithoutSignerOid?: boolean;
|
|
68
|
+
/** Omit Apple's WWDR marker from the intermediate. */
|
|
69
|
+
intermediateWithoutWwdrOid?: boolean;
|
|
70
|
+
/** Mint a fourth certificate between the leaf and the intermediate, for the depth cases. */
|
|
71
|
+
extraIntermediate?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const OID_COMMON_NAME = "2.5.4.3";
|
|
75
|
+
const OID_ECDSA_SHA256 = "1.2.840.10045.4.3.2";
|
|
76
|
+
const OID_ECDSA_SHA384 = "1.2.840.10045.4.3.3";
|
|
77
|
+
const OID_BASIC_CONSTRAINTS = "2.5.29.19";
|
|
78
|
+
const OID_KEY_USAGE = "2.5.29.15";
|
|
79
|
+
/** Apple's published marker OIDs — the same two `x509.ts` requires. */
|
|
80
|
+
const OID_APPLE_RECEIPT_SIGNER = "1.2.840.113635.100.6.11.1";
|
|
81
|
+
const OID_APPLE_WWDR = "1.2.840.113635.100.6.2.1";
|
|
82
|
+
|
|
83
|
+
/** Tag-length-value. Long-form length whenever the contents need it, exactly as DER requires. */
|
|
84
|
+
function tlv(tag: number, contents: Uint8Array): Uint8Array {
|
|
85
|
+
if (contents.length < 0x80) return new Uint8Array([tag, contents.length, ...contents]);
|
|
86
|
+
const lengthBytes: number[] = [];
|
|
87
|
+
let remaining = contents.length;
|
|
88
|
+
while (remaining > 0) {
|
|
89
|
+
lengthBytes.unshift(remaining & 0xff);
|
|
90
|
+
remaining = Math.floor(remaining / 256);
|
|
91
|
+
}
|
|
92
|
+
return new Uint8Array([tag, 0x80 | lengthBytes.length, ...lengthBytes, ...contents]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function concat(...parts: Uint8Array[]): Uint8Array {
|
|
96
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
97
|
+
const out = new Uint8Array(total);
|
|
98
|
+
let offset = 0;
|
|
99
|
+
for (const part of parts) {
|
|
100
|
+
out.set(part, offset);
|
|
101
|
+
offset += part.length;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const sequence = (...parts: Uint8Array[]): Uint8Array => tlv(0x30, concat(...parts));
|
|
107
|
+
const set = (...parts: Uint8Array[]): Uint8Array => tlv(0x31, concat(...parts));
|
|
108
|
+
const utf8String = (text: string): Uint8Array => tlv(0x0c, new TextEncoder().encode(text));
|
|
109
|
+
const bitString = (bytes: Uint8Array): Uint8Array => tlv(0x03, concat(new Uint8Array([0x00]), bytes));
|
|
110
|
+
const explicit = (index: number, contents: Uint8Array): Uint8Array => tlv(0xa0 | index, contents);
|
|
111
|
+
const octetString = (bytes: Uint8Array): Uint8Array => tlv(0x04, bytes);
|
|
112
|
+
/** DER admits only 0x00 and 0xff for a BOOLEAN, and the reader under test enforces exactly that. */
|
|
113
|
+
const boolean = (value: boolean): Uint8Array => tlv(0x01, new Uint8Array([value ? 0xff : 0x00]));
|
|
114
|
+
|
|
115
|
+
/** `Extension ::= SEQUENCE { extnID, critical DEFAULT FALSE, extnValue OCTET STRING }`. */
|
|
116
|
+
function extension(id: string, value: Uint8Array, critical = false): Uint8Array {
|
|
117
|
+
const parts = critical ? [oid(id), boolean(true), octetString(value)] : [oid(id), octetString(value)];
|
|
118
|
+
return sequence(...parts);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `BasicConstraints ::= SEQUENCE { cA BOOLEAN DEFAULT FALSE, pathLenConstraint INTEGER OPTIONAL }`. */
|
|
122
|
+
function basicConstraints(ca: boolean, pathLen?: number): Uint8Array {
|
|
123
|
+
const members: Uint8Array[] = [];
|
|
124
|
+
// `cA` at its default is encoded by omission, which is the shape an ordinary leaf certificate has.
|
|
125
|
+
if (ca) members.push(boolean(true));
|
|
126
|
+
if (pathLen !== undefined) members.push(integer(pathLen));
|
|
127
|
+
return extension(OID_BASIC_CONSTRAINTS, sequence(...members), true);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `keyUsage`, as the bits a CA needs. `digitalSignature` is bit 0 and `keyCertSign` bit 5, so a CA's byte is
|
|
132
|
+
* `0b00000100` with two unused trailing bits and a leaf's is `0b10000000` with seven.
|
|
133
|
+
*/
|
|
134
|
+
function keyUsage(keyCertSign: boolean): Uint8Array {
|
|
135
|
+
const bytes = keyCertSign ? new Uint8Array([0x02, 0x04]) : new Uint8Array([0x07, 0x80]);
|
|
136
|
+
return extension(OID_KEY_USAGE, tlv(0x03, bytes), true);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A positive INTEGER, with DER's leading zero when the high bit would read as a sign. */
|
|
140
|
+
function integer(value: number): Uint8Array {
|
|
141
|
+
const bytes: number[] = [];
|
|
142
|
+
let remaining = value;
|
|
143
|
+
do {
|
|
144
|
+
bytes.unshift(remaining & 0xff);
|
|
145
|
+
remaining = Math.floor(remaining / 256);
|
|
146
|
+
} while (remaining > 0);
|
|
147
|
+
if ((bytes[0] as number) & 0x80) bytes.unshift(0x00);
|
|
148
|
+
return tlv(0x02, new Uint8Array(bytes));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function oid(dotted: string): Uint8Array {
|
|
152
|
+
const arcs = dotted.split(".").map(Number);
|
|
153
|
+
const bytes: number[] = [40 * (arcs[0] as number) + (arcs[1] as number)];
|
|
154
|
+
for (const arc of arcs.slice(2)) {
|
|
155
|
+
const base128: number[] = [arc & 0x7f];
|
|
156
|
+
let remaining = Math.floor(arc / 128);
|
|
157
|
+
while (remaining > 0) {
|
|
158
|
+
base128.unshift((remaining & 0x7f) | 0x80);
|
|
159
|
+
remaining = Math.floor(remaining / 128);
|
|
160
|
+
}
|
|
161
|
+
bytes.push(...base128);
|
|
162
|
+
}
|
|
163
|
+
return tlv(0x06, new Uint8Array(bytes));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** UTCTime, `YYMMDDHHMMSSZ` — the encoding X.509 uses for any year before 2050. */
|
|
167
|
+
function utcTime(when: Date): Uint8Array {
|
|
168
|
+
const iso = when.toISOString();
|
|
169
|
+
const text = `${iso.slice(2, 4)}${iso.slice(5, 7)}${iso.slice(8, 10)}${iso.slice(11, 13)}${iso.slice(14, 16)}${iso.slice(17, 19)}Z`;
|
|
170
|
+
return tlv(0x17, new TextEncoder().encode(text));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A Name carrying one common name — enough to link a chain, which matches issuer against subject. */
|
|
174
|
+
const name = (commonName: string): Uint8Array => sequence(set(sequence(oid(OID_COMMON_NAME), utf8String(commonName))));
|
|
175
|
+
|
|
176
|
+
/** WebCrypto's fixed-width `r || s` as the `SEQUENCE { INTEGER r, INTEGER s }` X.509 carries. */
|
|
177
|
+
function rawSignatureToDer(raw: Uint8Array): Uint8Array {
|
|
178
|
+
const half = raw.length / 2;
|
|
179
|
+
const parts = [raw.subarray(0, half), raw.subarray(half)].map((coordinate) => {
|
|
180
|
+
let value = coordinate;
|
|
181
|
+
while (value.length > 1 && value[0] === 0) value = value.subarray(1);
|
|
182
|
+
const bytes = (value[0] as number) & 0x80 ? concat(new Uint8Array([0x00]), value) : value;
|
|
183
|
+
return tlv(0x02, bytes);
|
|
184
|
+
});
|
|
185
|
+
return sequence(...parts);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
189
|
+
let binary = "";
|
|
190
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
191
|
+
return btoa(binary);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** base64url without padding — how every segment of a JWS is encoded. */
|
|
195
|
+
export function base64Url(bytes: Uint8Array): string {
|
|
196
|
+
return bytesToBase64(bytes).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function generateKeyPair(curve: TestCurve): Promise<CryptoKeyPair> {
|
|
200
|
+
// `generateKey`'s return type is the union of both its overloads under the Workers types, and an EC
|
|
201
|
+
// algorithm always yields a pair. Narrowed once here rather than at each of the three call sites.
|
|
202
|
+
return (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: curve }, true, [
|
|
203
|
+
"sign",
|
|
204
|
+
"verify",
|
|
205
|
+
])) as CryptoKeyPair;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Mint one certificate. `issuer` is undefined for a self-signed root, in which case the certificate names
|
|
210
|
+
* and signs itself the way a real root does.
|
|
211
|
+
*/
|
|
212
|
+
async function mintCertificate(options: {
|
|
213
|
+
commonName: string;
|
|
214
|
+
curve: TestCurve;
|
|
215
|
+
notBefore: Date;
|
|
216
|
+
notAfter: Date;
|
|
217
|
+
issuer?: MintedCertificate;
|
|
218
|
+
issuerHash: "SHA-256" | "SHA-384";
|
|
219
|
+
serial: number;
|
|
220
|
+
/** The extensions to put in `[3] EXPLICIT Extensions`. Omitted entirely when empty, as a v1 would be. */
|
|
221
|
+
extensions?: Uint8Array[];
|
|
222
|
+
}): Promise<MintedCertificate> {
|
|
223
|
+
const { publicKey, privateKey } = await generateKeyPair(options.curve);
|
|
224
|
+
// `exportKey`'s type is the union over every format; `spki` is always an ArrayBuffer.
|
|
225
|
+
const spki = new Uint8Array((await crypto.subtle.exportKey("spki", publicKey)) as ArrayBuffer);
|
|
226
|
+
const subject = name(options.commonName);
|
|
227
|
+
const algorithm = sequence(oid(options.issuerHash === "SHA-384" ? OID_ECDSA_SHA384 : OID_ECDSA_SHA256));
|
|
228
|
+
const extensions = options.extensions ?? [];
|
|
229
|
+
|
|
230
|
+
const tbs = sequence(
|
|
231
|
+
explicit(0, integer(2)),
|
|
232
|
+
integer(options.serial),
|
|
233
|
+
algorithm,
|
|
234
|
+
options.issuer?.subject ?? subject,
|
|
235
|
+
sequence(utcTime(options.notBefore), utcTime(options.notAfter)),
|
|
236
|
+
subject,
|
|
237
|
+
spki,
|
|
238
|
+
...(extensions.length > 0 ? [explicit(3, sequence(...extensions))] : []),
|
|
239
|
+
);
|
|
240
|
+
const signingKey = options.issuer?.privateKey ?? privateKey;
|
|
241
|
+
const raw = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: options.issuerHash }, signingKey, tbs));
|
|
242
|
+
const der = sequence(tbs, algorithm, bitString(rawSignatureToDer(raw)));
|
|
243
|
+
return { der, publicKey, privateKey, subject };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Mint a three-certificate chain shaped like Apple's: P-256 leaf ← P-384 intermediate ← P-384 root.
|
|
248
|
+
*
|
|
249
|
+
* The default mint is a chain the verifier accepts, which means it carries what a real Apple chain carries:
|
|
250
|
+
* `basicConstraints` CA:TRUE on the root and intermediate, `keyUsage` with `keyCertSign` on both, and Apple's
|
|
251
|
+
* two marker OIDs. Every `MintChainOptions` flag that removes one of those exists so a test can prove the
|
|
252
|
+
* corresponding refusal is real rather than assumed.
|
|
253
|
+
*/
|
|
254
|
+
export async function mintChain(options: MintChainOptions = {}): Promise<MintedChain> {
|
|
255
|
+
const year = 365 * 24 * 60 * 60 * 1000;
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
const label = options.label ?? "pithy";
|
|
258
|
+
|
|
259
|
+
const root = await mintCertificate({
|
|
260
|
+
commonName: `${label} test root`,
|
|
261
|
+
curve: "P-384",
|
|
262
|
+
notBefore: new Date(now - year),
|
|
263
|
+
notAfter: new Date(now + year),
|
|
264
|
+
issuerHash: "SHA-384",
|
|
265
|
+
serial: 1,
|
|
266
|
+
extensions: [basicConstraints(true), keyUsage(true)],
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const intermediateExtensions: Uint8Array[] = [];
|
|
270
|
+
// A non-CA intermediate is minted the way an ordinary leaf is: `basicConstraints` absent altogether, which
|
|
271
|
+
// is `cA` at its ASN.1 default of false. That is exactly the certificate an attacker would already hold.
|
|
272
|
+
if (!options.intermediateNotCa) {
|
|
273
|
+
intermediateExtensions.push(basicConstraints(true, options.intermediatePathLen));
|
|
274
|
+
}
|
|
275
|
+
intermediateExtensions.push(keyUsage(!options.intermediateWithoutKeyCertSign));
|
|
276
|
+
if (!options.intermediateWithoutWwdrOid) {
|
|
277
|
+
intermediateExtensions.push(extension(OID_APPLE_WWDR, sequence()));
|
|
278
|
+
}
|
|
279
|
+
const intermediate = await mintCertificate({
|
|
280
|
+
commonName: `${label} test intermediate`,
|
|
281
|
+
curve: "P-384",
|
|
282
|
+
notBefore: options.intermediateNotBefore ?? new Date(now - year),
|
|
283
|
+
notAfter: options.intermediateNotAfter ?? new Date(now + year),
|
|
284
|
+
issuer: root,
|
|
285
|
+
issuerHash: "SHA-384",
|
|
286
|
+
serial: 2,
|
|
287
|
+
extensions: intermediateExtensions,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// A second CA between the leaf and the intermediate, so a `pathLenConstraint` of 0 has something to refuse.
|
|
291
|
+
const extra = options.extraIntermediate
|
|
292
|
+
? await mintCertificate({
|
|
293
|
+
commonName: `${label} test sub-intermediate`,
|
|
294
|
+
curve: "P-384",
|
|
295
|
+
notBefore: new Date(now - year),
|
|
296
|
+
notAfter: new Date(now + year),
|
|
297
|
+
issuer: intermediate,
|
|
298
|
+
issuerHash: "SHA-384",
|
|
299
|
+
serial: 4,
|
|
300
|
+
extensions: [basicConstraints(true), keyUsage(true), extension(OID_APPLE_WWDR, sequence())],
|
|
301
|
+
})
|
|
302
|
+
: undefined;
|
|
303
|
+
|
|
304
|
+
const leafExtensions: Uint8Array[] = [keyUsage(false)];
|
|
305
|
+
if (!options.leafWithoutSignerOid) leafExtensions.push(extension(OID_APPLE_RECEIPT_SIGNER, sequence()));
|
|
306
|
+
const leaf = await mintCertificate({
|
|
307
|
+
commonName: `${label} test leaf`,
|
|
308
|
+
curve: options.leafCurve ?? "P-256",
|
|
309
|
+
notBefore: options.leafNotBefore ?? new Date(now - year),
|
|
310
|
+
notAfter: options.leafNotAfter ?? new Date(now + year),
|
|
311
|
+
issuer: extra ?? intermediate,
|
|
312
|
+
issuerHash: "SHA-384",
|
|
313
|
+
serial: 3,
|
|
314
|
+
extensions: leafExtensions,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const chain = extra ? [leaf.der, extra.der, intermediate.der, root.der] : [leaf.der, intermediate.der, root.der];
|
|
318
|
+
return {
|
|
319
|
+
x5c: chain.map(bytesToBase64),
|
|
320
|
+
leafKey: leaf.privateKey,
|
|
321
|
+
root: bytesToBase64(root.der),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Sign a compact JWS the way Apple does: an `ES256` header carrying the chain in `x5c`, the payload, and a
|
|
327
|
+
* P-256 signature over `header.payload`.
|
|
328
|
+
*
|
|
329
|
+
* `header` overrides let a test present a header Apple never would — `alg: "none"`, a missing `x5c`, an
|
|
330
|
+
* `HS256` claim over an EC signature — which is how algorithm confusion is proved rejected rather than
|
|
331
|
+
* assumed impossible.
|
|
332
|
+
*/
|
|
333
|
+
export async function signJws(
|
|
334
|
+
payload: unknown,
|
|
335
|
+
chain: MintedChain,
|
|
336
|
+
header: Record<string, unknown> = {},
|
|
337
|
+
hash: "SHA-256" | "SHA-384" = "SHA-256",
|
|
338
|
+
): Promise<string> {
|
|
339
|
+
const encoder = new TextEncoder();
|
|
340
|
+
const head = base64Url(encoder.encode(JSON.stringify({ alg: "ES256", x5c: chain.x5c, ...header })));
|
|
341
|
+
const body = base64Url(encoder.encode(JSON.stringify(payload)));
|
|
342
|
+
const signature = new Uint8Array(
|
|
343
|
+
await crypto.subtle.sign({ name: "ECDSA", hash }, chain.leafKey, encoder.encode(`${head}.${body}`)),
|
|
344
|
+
);
|
|
345
|
+
return `${head}.${body}.${base64Url(signature)}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** A compact JWS with its payload swapped for another, keeping the original signature. The tamper case. */
|
|
349
|
+
export function tamperPayload(jws: string, payload: unknown): string {
|
|
350
|
+
const [head, , signature] = jws.split(".");
|
|
351
|
+
return `${head}.${base64Url(new TextEncoder().encode(JSON.stringify(payload)))}.${signature}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A recorded notification, as the `*.json` files beside this module store one.
|
|
356
|
+
*
|
|
357
|
+
* The files hold **decoded** payloads, and this is why: Apple nests three independently-signed JWS inside
|
|
358
|
+
* one notification, and a recorded signature can only ever be verified against Apple's real chain, which no
|
|
359
|
+
* test may reach. So the recording keeps the payloads — redacted, but faithful to Apple's published
|
|
360
|
+
* `responseBodyV2DecodedPayload`, `JWSTransactionDecodedPayload`, and `JWSRenewalInfoDecodedPayload` shapes
|
|
361
|
+
* — and {@link signNotification} re-signs them with the minted chain. The shapes are the fixture; the
|
|
362
|
+
* signatures are the test's own.
|
|
363
|
+
*/
|
|
364
|
+
export interface NotificationFixture {
|
|
365
|
+
/** The notification payload, without the nested JWS fields — those are produced from the two below. */
|
|
366
|
+
notification: Record<string, unknown> & { data?: Record<string, unknown> };
|
|
367
|
+
/** The transaction payload, signed into `data.signedTransactionInfo`. Absent for a state-free type. */
|
|
368
|
+
transaction?: Record<string, unknown>;
|
|
369
|
+
/** The renewal payload, signed into `data.signedRenewalInfo`. Absent for a one-time purchase. */
|
|
370
|
+
renewalInfo?: Record<string, unknown>;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Sign a recorded notification into the nested-JWS form Apple actually delivers. */
|
|
374
|
+
export async function signNotification(fixture: NotificationFixture, chain: MintedChain): Promise<string> {
|
|
375
|
+
const data = fixture.notification.data;
|
|
376
|
+
const signedData = data
|
|
377
|
+
? {
|
|
378
|
+
...data,
|
|
379
|
+
...(fixture.transaction ? { signedTransactionInfo: await signJws(fixture.transaction, chain) } : {}),
|
|
380
|
+
...(fixture.renewalInfo ? { signedRenewalInfo: await signJws(fixture.renewalInfo, chain) } : {}),
|
|
381
|
+
}
|
|
382
|
+
: undefined;
|
|
383
|
+
return signJws({ ...fixture.notification, ...(signedData ? { data: signedData } : {}) }, chain);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** A minted App Store Connect API key: the PKCS#8 PEM a `.p8` holds, and the public half to verify against. */
|
|
387
|
+
export interface MintedAppStoreKey {
|
|
388
|
+
/** The private key in the exact PEM armour App Store Connect issues. */
|
|
389
|
+
pem: string;
|
|
390
|
+
/** The public half, so a test can verify a minted token rather than trust its shape. */
|
|
391
|
+
publicKey: CryptoKey;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Mint an App Store Connect API key.
|
|
396
|
+
*
|
|
397
|
+
* A real P-256 pair exported as PKCS#8 PEM, because that is exactly what a downloaded `.p8` is — so a suite
|
|
398
|
+
* that mints a token with it and verifies it against the public half has proved Apple would have accepted
|
|
399
|
+
* the token, rather than proved the string has three dots in it.
|
|
400
|
+
*/
|
|
401
|
+
export async function mintAppStoreKey(): Promise<MintedAppStoreKey> {
|
|
402
|
+
// `generateKey`'s type is the union over every algorithm; an EC key is always a pair.
|
|
403
|
+
const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
|
|
404
|
+
"sign",
|
|
405
|
+
"verify",
|
|
406
|
+
])) as CryptoKeyPair;
|
|
407
|
+
const pkcs8 = new Uint8Array((await crypto.subtle.exportKey("pkcs8", pair.privateKey)) as ArrayBuffer);
|
|
408
|
+
let binary = "";
|
|
409
|
+
for (const byte of pkcs8) binary += String.fromCharCode(byte);
|
|
410
|
+
const body = (btoa(binary).match(/.{1,64}/g) ?? []).join("\n");
|
|
411
|
+
return { pem: `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----\n`, publicKey: pair.publicKey };
|
|
412
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"notification": {
|
|
3
|
+
"notificationType": "DID_FAIL_TO_RENEW",
|
|
4
|
+
"subtype": "GRACE_PERIOD",
|
|
5
|
+
"notificationUUID": "0a7b3f11-5d64-42c8-8e19-9f0c2b6d5a47",
|
|
6
|
+
"version": "2.0",
|
|
7
|
+
"signedDate": 1769904000000,
|
|
8
|
+
"data": {
|
|
9
|
+
"appAppleId": 1234567890,
|
|
10
|
+
"bundleId": "com.acme.app",
|
|
11
|
+
"bundleVersion": "412",
|
|
12
|
+
"environment": "Production",
|
|
13
|
+
"status": 4
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"transaction": {
|
|
17
|
+
"transactionId": "2000000731004811",
|
|
18
|
+
"originalTransactionId": "2000000617339002",
|
|
19
|
+
"bundleId": "com.acme.app",
|
|
20
|
+
"productId": "com.acme.pro.monthly",
|
|
21
|
+
"purchaseDate": 1767225600000,
|
|
22
|
+
"originalPurchaseDate": 1759276800000,
|
|
23
|
+
"expiresDate": 1769904000000,
|
|
24
|
+
"quantity": 1,
|
|
25
|
+
"type": "Auto-Renewable Subscription",
|
|
26
|
+
"appAccountToken": "3f6c1d20-8a4b-4f77-9c31-b0e5d2a91746",
|
|
27
|
+
"inAppOwnershipType": "PURCHASED",
|
|
28
|
+
"signedDate": 1769904000000,
|
|
29
|
+
"environment": "Production",
|
|
30
|
+
"price": 4990,
|
|
31
|
+
"currency": "USD"
|
|
32
|
+
},
|
|
33
|
+
"renewalInfo": {
|
|
34
|
+
"originalTransactionId": "2000000617339002",
|
|
35
|
+
"autoRenewProductId": "com.acme.pro.monthly",
|
|
36
|
+
"productId": "com.acme.pro.monthly",
|
|
37
|
+
"autoRenewStatus": 1,
|
|
38
|
+
"isInBillingRetryPeriod": true,
|
|
39
|
+
"gracePeriodExpiresDate": 1770422400000,
|
|
40
|
+
"signedDate": 1769904000000,
|
|
41
|
+
"environment": "Production"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"notification": {
|
|
3
|
+
"notificationType": "DID_RENEW",
|
|
4
|
+
"subtype": "BILLING_RECOVERY",
|
|
5
|
+
"notificationUUID": "b8f1a7d6-3c92-4d51-9e0b-7a2f5c1d4e33",
|
|
6
|
+
"version": "2.0",
|
|
7
|
+
"signedDate": 1767225600000,
|
|
8
|
+
"data": {
|
|
9
|
+
"appAppleId": 1234567890,
|
|
10
|
+
"bundleId": "com.acme.app",
|
|
11
|
+
"bundleVersion": "412",
|
|
12
|
+
"environment": "Production",
|
|
13
|
+
"status": 1
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"transaction": {
|
|
17
|
+
"transactionId": "2000000731004811",
|
|
18
|
+
"originalTransactionId": "2000000617339002",
|
|
19
|
+
"webOrderLineItemId": "2000000091822640",
|
|
20
|
+
"bundleId": "com.acme.app",
|
|
21
|
+
"productId": "com.acme.pro.monthly",
|
|
22
|
+
"subscriptionGroupIdentifier": "20845513",
|
|
23
|
+
"purchaseDate": 1767225600000,
|
|
24
|
+
"originalPurchaseDate": 1759276800000,
|
|
25
|
+
"expiresDate": 1769904000000,
|
|
26
|
+
"quantity": 1,
|
|
27
|
+
"type": "Auto-Renewable Subscription",
|
|
28
|
+
"appAccountToken": "3f6c1d20-8a4b-4f77-9c31-b0e5d2a91746",
|
|
29
|
+
"inAppOwnershipType": "PURCHASED",
|
|
30
|
+
"signedDate": 1767225600000,
|
|
31
|
+
"environment": "Production",
|
|
32
|
+
"transactionReason": "RENEWAL",
|
|
33
|
+
"storefront": "USA",
|
|
34
|
+
"storefrontId": "143441",
|
|
35
|
+
"price": 4990,
|
|
36
|
+
"currency": "USD"
|
|
37
|
+
},
|
|
38
|
+
"renewalInfo": {
|
|
39
|
+
"originalTransactionId": "2000000617339002",
|
|
40
|
+
"autoRenewProductId": "com.acme.pro.monthly",
|
|
41
|
+
"productId": "com.acme.pro.monthly",
|
|
42
|
+
"autoRenewStatus": 1,
|
|
43
|
+
"renewalDate": 1769904000000,
|
|
44
|
+
"recentSubscriptionStartDate": 1759276800000,
|
|
45
|
+
"signedDate": 1767225600000,
|
|
46
|
+
"environment": "Production"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"notification": {
|
|
3
|
+
"notificationType": "ONE_TIME_CHARGE",
|
|
4
|
+
"notificationUUID": "2b5e8c14-0d3a-4f96-b71e-8c4d9a2f6031",
|
|
5
|
+
"version": "2.0",
|
|
6
|
+
"signedDate": 1770163200000,
|
|
7
|
+
"data": {
|
|
8
|
+
"appAppleId": 1234567890,
|
|
9
|
+
"bundleId": "com.acme.app",
|
|
10
|
+
"bundleVersion": "412",
|
|
11
|
+
"environment": "Production"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"transaction": {
|
|
15
|
+
"transactionId": "2000000744120933",
|
|
16
|
+
"bundleId": "com.acme.app",
|
|
17
|
+
"productId": "com.acme.coins100",
|
|
18
|
+
"purchaseDate": 1770163200000,
|
|
19
|
+
"originalPurchaseDate": 1770163200000,
|
|
20
|
+
"quantity": 1,
|
|
21
|
+
"type": "Consumable",
|
|
22
|
+
"appAccountToken": "3f6c1d20-8a4b-4f77-9c31-b0e5d2a91746",
|
|
23
|
+
"inAppOwnershipType": "PURCHASED",
|
|
24
|
+
"signedDate": 1770163200000,
|
|
25
|
+
"environment": "Production",
|
|
26
|
+
"transactionReason": "PURCHASE",
|
|
27
|
+
"storefront": "JPN",
|
|
28
|
+
"storefrontId": "143462",
|
|
29
|
+
"price": 500000,
|
|
30
|
+
"currency": "JPY"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"notification": {
|
|
3
|
+
"notificationType": "REFUND",
|
|
4
|
+
"notificationUUID": "f4c9d0e2-7b18-4a35-92cd-6e3f1a8b0d52",
|
|
5
|
+
"version": "2.0",
|
|
6
|
+
"signedDate": 1770076800000,
|
|
7
|
+
"data": {
|
|
8
|
+
"appAppleId": 1234567890,
|
|
9
|
+
"bundleId": "com.acme.app",
|
|
10
|
+
"bundleVersion": "412",
|
|
11
|
+
"environment": "Production"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"transaction": {
|
|
15
|
+
"transactionId": "2000000731004811",
|
|
16
|
+
"originalTransactionId": "2000000617339002",
|
|
17
|
+
"bundleId": "com.acme.app",
|
|
18
|
+
"productId": "com.acme.pro.monthly",
|
|
19
|
+
"purchaseDate": 1767225600000,
|
|
20
|
+
"originalPurchaseDate": 1759276800000,
|
|
21
|
+
"expiresDate": 1769904000000,
|
|
22
|
+
"revocationDate": 1770076800000,
|
|
23
|
+
"revocationReason": 0,
|
|
24
|
+
"quantity": 1,
|
|
25
|
+
"type": "Auto-Renewable Subscription",
|
|
26
|
+
"appAccountToken": "3f6c1d20-8a4b-4f77-9c31-b0e5d2a91746",
|
|
27
|
+
"inAppOwnershipType": "PURCHASED",
|
|
28
|
+
"signedDate": 1770076800000,
|
|
29
|
+
"environment": "Production",
|
|
30
|
+
"price": 4990,
|
|
31
|
+
"currency": "USD"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"notification": {
|
|
3
|
+
"notificationType": "SUBSCRIBED",
|
|
4
|
+
"subtype": "INITIAL_BUY",
|
|
5
|
+
"notificationUUID": "6d2e4c8a-91b7-4a03-bf52-1c8d7e0a3f91",
|
|
6
|
+
"version": "2.0",
|
|
7
|
+
"signedDate": 1759276800000,
|
|
8
|
+
"data": {
|
|
9
|
+
"appAppleId": 1234567890,
|
|
10
|
+
"bundleId": "com.acme.app",
|
|
11
|
+
"bundleVersion": "412",
|
|
12
|
+
"environment": "Sandbox",
|
|
13
|
+
"status": 1
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"transaction": {
|
|
17
|
+
"transactionId": "2000000617339002",
|
|
18
|
+
"originalTransactionId": "2000000617339002",
|
|
19
|
+
"webOrderLineItemId": "2000000082014377",
|
|
20
|
+
"bundleId": "com.acme.app",
|
|
21
|
+
"productId": "com.acme.pro.monthly",
|
|
22
|
+
"subscriptionGroupIdentifier": "20845513",
|
|
23
|
+
"purchaseDate": 1759276800000,
|
|
24
|
+
"originalPurchaseDate": 1759276800000,
|
|
25
|
+
"expiresDate": 1761955200000,
|
|
26
|
+
"quantity": 1,
|
|
27
|
+
"type": "Auto-Renewable Subscription",
|
|
28
|
+
"appAccountToken": "3f6c1d20-8a4b-4f77-9c31-b0e5d2a91746",
|
|
29
|
+
"inAppOwnershipType": "PURCHASED",
|
|
30
|
+
"signedDate": 1759276800000,
|
|
31
|
+
"environment": "Sandbox",
|
|
32
|
+
"transactionReason": "PURCHASE",
|
|
33
|
+
"storefront": "USA",
|
|
34
|
+
"storefrontId": "143441",
|
|
35
|
+
"price": 4990,
|
|
36
|
+
"currency": "USD"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"notification": {
|
|
3
|
+
"notificationType": "TEST",
|
|
4
|
+
"notificationUUID": "8e1d6b73-4f20-4c89-a05e-3b7c9d1f2a64",
|
|
5
|
+
"version": "2.0",
|
|
6
|
+
"signedDate": 1770249600000,
|
|
7
|
+
"data": {
|
|
8
|
+
"appAppleId": 1234567890,
|
|
9
|
+
"bundleId": "com.acme.app",
|
|
10
|
+
"bundleVersion": "412",
|
|
11
|
+
"environment": "Production"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|