@mpgd/game-services 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/admob-ssv.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Point, verify as verifySecp256k1 } from '@noble/secp256k1';
2
+ import { decodeBase64, decodeBase64Url } from './base64.js';
2
3
  export const admobSsvCustomDataSchema = 'mpgd.admob.ssv.binding.v1';
3
4
  /** Maximum field length accepted by the shipped game-services SSV receiver. */
4
5
  export const admobSsvMaximumBindingFieldLength = 256;
@@ -60,7 +61,11 @@ export function decodeAdMobSsvCustomData(input) {
60
61
  }
61
62
  export async function importAdMobSsvPublicKey(base64Spki, subtle = readGlobalSubtleCrypto()) {
62
63
  requireNonEmptyString(base64Spki, 'base64Spki');
63
- const spki = decodeBase64(base64Spki);
64
+ // ASCII whitespace is dropped, as atob's forgiving decode always did.
65
+ const spki = decodeBase64(base64Spki.replace(/[\t\n\f\r ]/gu, ''));
66
+ if (spki === undefined) {
67
+ throw new Error('base64Spki must be base64.');
68
+ }
64
69
  const parsed = parseAdMobSsvSubjectPublicKeyInfo(spki);
65
70
  if (parsed === undefined) {
66
71
  throw new Error('base64Spki must contain a supported EC SubjectPublicKeyInfo key.');
@@ -509,31 +514,6 @@ function readGlobalSubtleCrypto() {
509
514
  }
510
515
  return subtle;
511
516
  }
512
- function decodeBase64(input) {
513
- const decoded = atob(input);
514
- const output = new Uint8Array(decoded.length);
515
- for (let index = 0; index < decoded.length; index += 1) {
516
- output[index] = decoded.charCodeAt(index);
517
- }
518
- return output;
519
- }
520
- function decodeBase64Url(input) {
521
- if (!/^[A-Za-z0-9_-]+$/.test(input)) {
522
- return undefined;
523
- }
524
- const remainder = input.length % 4;
525
- if (remainder === 1) {
526
- return undefined;
527
- }
528
- const base64 = input.replaceAll('-', '+').replaceAll('_', '/')
529
- + '='.repeat((4 - remainder) % 4);
530
- try {
531
- return decodeBase64(base64);
532
- }
533
- catch {
534
- return undefined;
535
- }
536
- }
537
517
  function readParameter(parameters, name) {
538
518
  return parameters.get(name) ?? '';
539
519
  }
@@ -1,5 +1,5 @@
1
1
  import type { PlatformEvidenceEnvelope } from '@mpgd/platform';
2
- import { type AppStoreEnvironment, type AppStoreInAppPurchaseType, type AppStoreTransactionPayload } from './app-store-verifier.js';
2
+ import { type AppStoreEnvironment, type AppStoreInAppPurchaseType, type AppStoreRevocationRegistry, type AppStoreTransactionPayload } from './app-store-verifier.js';
3
3
  /** The placeholder JWS the signed-transaction evidence fixture carries; never a real signature. */
4
4
  export declare const appStoreConformanceSignedTransaction = "conformance.signed-transaction.jws";
5
5
  export interface CreateAppStoreSignedTransactionEvidenceFixtureInput {
@@ -42,3 +42,39 @@ export interface CreateAppStoreTransactionConformanceFixtureInput {
42
42
  * credentials, authorization tokens, private keys, or live transaction IDs.
43
43
  */
44
44
  export declare function createAppStoreTransactionConformanceFixture(input?: CreateAppStoreTransactionConformanceFixtureInput): AppStoreTransactionPayload;
45
+ /** The rules `runAppStoreRevocationRegistryConformance` checks, in order. */
46
+ export declare const appStoreRevocationRegistryConformanceChecks: readonly ['an unrecorded grant identity is absent', 'the first record is kept and read back unchanged', 'a redelivery keeps the record it repeats', 'an older revocation arriving late keeps the later one', 'a later revocation replaces the earlier one', 'an unparseable revokedAt replaces a parseable one and is never replaced', 'grant identities are independent', 'environments are independent for the same verification id'];
47
+ /** Checked only when `reopen` is given. */
48
+ export declare const appStoreRevocationRegistryDurabilityCheck = "records survive a new registry instance over the same storage";
49
+ export interface RunAppStoreRevocationRegistryConformanceInput {
50
+ readonly registry: AppStoreRevocationRegistry;
51
+ /**
52
+ * Another registry instance over the same storage, as a new Worker isolate
53
+ * or process would build it. When given, every rule is also read back
54
+ * through it, so a registry that only remembers in memory fails.
55
+ */
56
+ readonly reopen?: () => AppStoreRevocationRegistry;
57
+ /**
58
+ * Keeps this run's grant identities apart from earlier runs on a storage
59
+ * that persists between them; a random id by default.
60
+ */
61
+ readonly runId?: string;
62
+ readonly signal?: AbortSignal;
63
+ }
64
+ export interface AppStoreRevocationRegistryConformanceReport {
65
+ readonly checks: readonly string[];
66
+ readonly passed: true;
67
+ }
68
+ /**
69
+ * Runs the `AppStoreRevocationRegistry` contract against an implementation:
70
+ * the latest `revokedAt` per grant identity wins, a redelivery or an older
71
+ * notification arriving late answers `alreadyRecorded: true` and changes
72
+ * nothing, an unparseable `revokedAt` counts as the latest, and records are
73
+ * read back field for field. `createInMemoryAppStoreRevocationRegistry`
74
+ * passes it; a durable registry should pass it with `reopen` as well. It
75
+ * writes records under fresh grant identities and deletes nothing, so run it
76
+ * on a test database: each run leaves a few rows, two of them with a
77
+ * `revokedAt` that does not parse (never replaced or pruned by age). It
78
+ * throws on the first rule that fails.
79
+ */
80
+ export declare function runAppStoreRevocationRegistryConformance(input: RunAppStoreRevocationRegistryConformanceInput): Promise<AppStoreRevocationRegistryConformanceReport>;
@@ -1,4 +1,4 @@
1
- import { createAppStoreSignedTransactionEvidence, } from './app-store-verifier.js';
1
+ import { createAppStoreSignedTransactionEvidence, createAppStoreVerificationId, } from './app-store-verifier.js';
2
2
  /** The placeholder JWS the signed-transaction evidence fixture carries; never a real signature. */
3
3
  export const appStoreConformanceSignedTransaction = 'conformance.signed-transaction.jws';
4
4
  /**
@@ -48,3 +48,201 @@ export function createAppStoreTransactionConformanceFixture(input = {}) {
48
48
  ...(input.isUpgraded === undefined ? {} : { isUpgraded: input.isUpgraded }),
49
49
  };
50
50
  }
51
+ /** The rules `runAppStoreRevocationRegistryConformance` checks, in order. */
52
+ export const appStoreRevocationRegistryConformanceChecks = [
53
+ 'an unrecorded grant identity is absent',
54
+ 'the first record is kept and read back unchanged',
55
+ 'a redelivery keeps the record it repeats',
56
+ 'an older revocation arriving late keeps the later one',
57
+ 'a later revocation replaces the earlier one',
58
+ 'an unparseable revokedAt replaces a parseable one and is never replaced',
59
+ 'grant identities are independent',
60
+ 'environments are independent for the same verification id',
61
+ ];
62
+ /** Checked only when `reopen` is given. */
63
+ export const appStoreRevocationRegistryDurabilityCheck = 'records survive a new registry instance over the same storage';
64
+ /**
65
+ * Runs the `AppStoreRevocationRegistry` contract against an implementation:
66
+ * the latest `revokedAt` per grant identity wins, a redelivery or an older
67
+ * notification arriving late answers `alreadyRecorded: true` and changes
68
+ * nothing, an unparseable `revokedAt` counts as the latest, and records are
69
+ * read back field for field. `createInMemoryAppStoreRevocationRegistry`
70
+ * passes it; a durable registry should pass it with `reopen` as well. It
71
+ * writes records under fresh grant identities and deletes nothing, so run it
72
+ * on a test database: each run leaves a few rows, two of them with a
73
+ * `revokedAt` that does not parse (never replaced or pruned by age). It
74
+ * throws on the first rule that fails.
75
+ */
76
+ export async function runAppStoreRevocationRegistryConformance(input) {
77
+ const signal = input.signal ?? new AbortController().signal;
78
+ const runId = input.runId ?? `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
79
+ const writer = input.registry;
80
+ const readers = input.reopen === undefined ? [writer] : [writer, input.reopen()];
81
+ let sequence = 0;
82
+ const recordFor = (name, overrides = {}) => {
83
+ const transactionId = `conformance-${runId}-${name}`;
84
+ return {
85
+ verificationId: createAppStoreVerificationId({
86
+ environment: 'Production',
87
+ bundleId: 'com.example.game',
88
+ transactionId,
89
+ }),
90
+ environment: 'Production',
91
+ bundleId: 'com.example.game',
92
+ transactionId,
93
+ originalTransactionId: transactionId,
94
+ productId: 'com.example.game.coins100',
95
+ reason: 'refund',
96
+ revokedAt: '2026-10-20T00:00:00.000Z',
97
+ notificationType: 'REFUND',
98
+ notificationUUID: `conformance-${runId}-${String(++sequence)}`,
99
+ ...overrides,
100
+ };
101
+ };
102
+ const record = async (value) => (await writer.recordRevocation({ record: value, signal })).alreadyRecorded;
103
+ const expectKept = async (expected, rule,
104
+ /** Where to look when it is not `expected`'s own key (an absent record). */
105
+ at) => {
106
+ for (const [index, reader] of readers.entries()) {
107
+ const kept = await reader.findRevocation({
108
+ verificationId: at?.verificationId ?? expected?.verificationId ?? recordFor('absent').verificationId,
109
+ environment: at?.environment ?? expected?.environment ?? 'Production',
110
+ bundleId: expected?.bundleId ?? 'com.example.game',
111
+ transactionId: expected?.transactionId ?? `conformance-${runId}-absent`,
112
+ originalTransactionId: expected?.originalTransactionId ?? `conformance-${runId}-absent`,
113
+ signal,
114
+ });
115
+ assertRevocationConformance(sameRevocationRecord(kept, expected), `${rule}${index === 0 ? '' : ' (read through reopen)'}: expected ${describeRecord(expected)}, `
116
+ + `read ${describeRecord(kept)}`);
117
+ }
118
+ };
119
+ const [absentRule, firstRule, redeliveryRule, olderRule, laterRule, unparseableRule, independentRule, environmentRule,] = appStoreRevocationRegistryConformanceChecks;
120
+ await expectKept(undefined, absentRule);
121
+ const first = recordFor('first', {
122
+ originalTransactionId: `conformance-${runId}-first-original`,
123
+ productId: 'com.example.game.remove_ads',
124
+ reason: 'voided',
125
+ revokedAt: '2026-10-20T01:02:03.456Z',
126
+ notificationType: 'REVOKE',
127
+ });
128
+ assertRevocationConformance(!(await record(first)), `${firstRule}: alreadyRecorded was true`);
129
+ await expectKept(first, firstRule);
130
+ const redelivered = { ...first, notificationUUID: `${first.notificationUUID}-redelivered` };
131
+ // The same instant written another way is the same revocation.
132
+ const sameInstant = {
133
+ ...first,
134
+ revokedAt: '2026-10-20T10:02:03.456+09:00',
135
+ notificationUUID: `${first.notificationUUID}-same-instant`,
136
+ };
137
+ assertRevocationConformance(await record(first), `${redeliveryRule}: the same record answered false`);
138
+ assertRevocationConformance(await record(redelivered), `${redeliveryRule}: an equal revokedAt answered false`);
139
+ assertRevocationConformance(await record(sameInstant), `${redeliveryRule}: the same instant in another offset answered false`);
140
+ await expectKept(first, redeliveryRule);
141
+ const later = recordFor('order', { revokedAt: '2026-10-20T02:00:00.000Z' });
142
+ assertRevocationConformance(!(await record(later)), `${olderRule}: the first record answered true`);
143
+ const older = { ...later, revokedAt: '2026-10-20T01:00:00.000Z', notificationUUID: 'older' };
144
+ assertRevocationConformance(await record(older), `${olderRule}: answered false`);
145
+ await expectKept(later, olderRule);
146
+ // Refunded, reversed (nothing recorded), and revoked again.
147
+ const again = {
148
+ ...later,
149
+ reason: 'voided',
150
+ revokedAt: '2026-10-21T00:00:00.000Z',
151
+ notificationType: 'REVOKE',
152
+ notificationUUID: 'revoked-again',
153
+ };
154
+ assertRevocationConformance(!(await record(again)), `${laterRule}: answered true`);
155
+ await expectKept(again, laterRule);
156
+ // Compared as instants, not as text.
157
+ const offsetLater = {
158
+ ...again,
159
+ revokedAt: '2026-10-21T09:30:00+09:00',
160
+ notificationUUID: 'offset',
161
+ };
162
+ assertRevocationConformance(!(await record(offsetLater)), `${laterRule}: a later offset time answered true`);
163
+ await expectKept(offsetLater, laterRule);
164
+ const lexicallyLater = {
165
+ ...again,
166
+ revokedAt: '2026-10-21T10:00:00+11:00',
167
+ notificationUUID: 'lexically-later',
168
+ };
169
+ assertRevocationConformance(await record(lexicallyLater), `${laterRule}: an earlier instant that sorts later as text answered false`);
170
+ await expectKept(offsetLater, laterRule);
171
+ const parseable = recordFor('unparseable', { revokedAt: '2026-10-20T00:00:00.000Z' });
172
+ assertRevocationConformance(!(await record(parseable)), `${unparseableRule}: the first record answered true`);
173
+ const unparseable = { ...parseable, revokedAt: 'unparseable', notificationUUID: 'unparseable' };
174
+ assertRevocationConformance(!(await record(unparseable)), `${unparseableRule}: answered true`);
175
+ await expectKept(unparseable, unparseableRule);
176
+ for (const revokedAt of ['2099-01-01T00:00:00.000Z', 'also-unparseable']) {
177
+ assertRevocationConformance(await record({ ...parseable, revokedAt, notificationUUID: revokedAt }), `${unparseableRule}: ${revokedAt} replaced an unparseable record`);
178
+ }
179
+ await expectKept(unparseable, unparseableRule);
180
+ const unparseableFirst = recordFor('unparseable-first', { revokedAt: 'unparseable' });
181
+ assertRevocationConformance(!(await record(unparseableFirst)), `${unparseableRule}: a first unparseable record answered true`);
182
+ assertRevocationConformance(await record({ ...unparseableFirst, revokedAt: '2099-01-01T00:00:00.000Z', notificationUUID: 'later' }), `${unparseableRule}: a parseable record replaced a first unparseable one`);
183
+ await expectKept(unparseableFirst, unparseableRule);
184
+ const sandbox = recordFor('first', {
185
+ environment: 'Sandbox',
186
+ verificationId: createAppStoreVerificationId({
187
+ environment: 'Sandbox',
188
+ bundleId: 'com.example.game',
189
+ transactionId: first.transactionId,
190
+ }),
191
+ revokedAt: '2026-10-19T00:00:00.000Z',
192
+ });
193
+ assertRevocationConformance(!(await record(sandbox)), `${independentRule}: another identity answered alreadyRecorded`);
194
+ await expectKept(sandbox, independentRule);
195
+ await expectKept(first, independentRule);
196
+ // The same verification id string in both environments: two records. The
197
+ // Sandbox one is earlier, so a registry keyed by the id alone keeps the
198
+ // Production record and answers it for a Sandbox lookup.
199
+ const production = recordFor('environment', { revokedAt: '2026-10-20T12:00:00.000Z' });
200
+ const sandboxTwin = {
201
+ ...production,
202
+ environment: 'Sandbox',
203
+ revokedAt: '2026-10-20T06:00:00.000Z',
204
+ notificationUUID: `${production.notificationUUID}-sandbox`,
205
+ };
206
+ const productionOnly = recordFor('production-only');
207
+ assertRevocationConformance(!(await record(production)), `${environmentRule}: Production answered true`);
208
+ await expectKept(undefined, environmentRule, { ...production, environment: 'Sandbox' });
209
+ assertRevocationConformance(!(await record(sandboxTwin)), `${environmentRule}: a Sandbox record answered alreadyRecorded for a Production one`);
210
+ await expectKept(sandboxTwin, environmentRule);
211
+ await expectKept(production, environmentRule);
212
+ assertRevocationConformance(!(await record(productionOnly)), `${environmentRule}: answered true`);
213
+ await expectKept(undefined, environmentRule, { ...productionOnly, environment: 'Sandbox' });
214
+ return {
215
+ checks: input.reopen === undefined
216
+ ? [...appStoreRevocationRegistryConformanceChecks]
217
+ : [...appStoreRevocationRegistryConformanceChecks, appStoreRevocationRegistryDurabilityCheck],
218
+ passed: true,
219
+ };
220
+ }
221
+ const revocationRecordKeys = [
222
+ 'verificationId',
223
+ 'environment',
224
+ 'bundleId',
225
+ 'transactionId',
226
+ 'originalTransactionId',
227
+ 'productId',
228
+ 'reason',
229
+ 'revokedAt',
230
+ 'notificationType',
231
+ 'notificationUUID',
232
+ ];
233
+ function sameRevocationRecord(actual, expected) {
234
+ if (actual === undefined || expected === undefined) {
235
+ return actual === expected;
236
+ }
237
+ return revocationRecordKeys.every((key) => actual[key] === expected[key]);
238
+ }
239
+ function describeRecord(record) {
240
+ return record === undefined
241
+ ? 'none'
242
+ : `${record.revokedAt} (${record.notificationType} ${record.notificationUUID})`;
243
+ }
244
+ function assertRevocationConformance(condition, message) {
245
+ if (!condition) {
246
+ throw new Error(`App Store revocation registry conformance failed: ${message}.`);
247
+ }
248
+ }
@@ -183,7 +183,8 @@ export interface AppStoreRevocationRecord {
183
183
  }
184
184
  /**
185
185
  * Reads recorded revocations for the offline-only verifier. Answer the record
186
- * for `verificationId`, or `undefined` when none is recorded. Throw
186
+ * for `environment` and `verificationId`, or `undefined` when none is
187
+ * recorded: a record of the other environment never answers. Throw
187
188
  * `AppStoreDependencyUnavailableError` only for a transient storage outage
188
189
  * (the purchase stays `pending`); any other error propagates.
189
190
  */
@@ -198,8 +199,13 @@ export interface AppStoreRevocationLookup {
198
199
  }): Promise<AppStoreRevocationRecord | undefined>;
199
200
  }
200
201
  /**
201
- * Durably records a revocation, keeping per `verificationId` the record with
202
- * the latest `revokedAt`: a purchase can be refunded, reversed, and revoked
202
+ * Durably records a revocation, keeping per `environment` and
203
+ * `verificationId` the record with the latest `revokedAt` (a Production
204
+ * deployment that grants sandbox purchases records both environments, and
205
+ * Apple documents a transaction id as unique without scoping it to one
206
+ * environment, so the environment is part of the key;
207
+ * https://developer.apple.com/documentation/appstoreserverapi/transactionid):
208
+ * a purchase can be refunded, reversed, and revoked
203
209
  * again, and the lookup must refuse every JWS signed before the latest
204
210
  * revocation. A record whose `revokedAt` does not parse counts as the
205
211
  * latest. A record no later than the kept one (a redelivery, or an older
@@ -221,9 +227,56 @@ export interface AppStoreRevocationRegistry extends AppStoreRevocationLookup, Ap
221
227
  * A process-local `AppStoreRevocationRegistry` for tests and single-process
222
228
  * hosts. It forgets everything on restart and is not shared between
223
229
  * instances (a Worker isolate is one), so a deployment that verifies
224
- * purchases offline needs a durable registry of its own.
230
+ * purchases offline needs a durable registry of its own: the reference
231
+ * game-services Worker keeps one in D1, and
232
+ * `runAppStoreRevocationRegistryConformance` (in
233
+ * `@mpgd/game-services/app-store-verifier-conformance`) checks another
234
+ * implementation against the same rules.
225
235
  */
226
236
  export declare function createInMemoryAppStoreRevocationRegistry(): AppStoreRevocationRegistry;
237
+ /** What `findRevocation` asks, without the `AbortSignal` (it does not cross a Worker service binding). */
238
+ export type AppStoreRevocationLookupRequest = Omit<Parameters<AppStoreRevocationLookup['findRevocation']>[0], 'signal'>;
239
+ /**
240
+ * A revocation lookup's answer as data, for a lookup served by another
241
+ * process: errors lose their class across a Worker service binding, so an
242
+ * outage is `{ status: 'unavailable' }` rather than a thrown
243
+ * `AppStoreDependencyUnavailableError`.
244
+ */
245
+ export type AppStoreRevocationLookupAnswer = {
246
+ readonly status: 'found';
247
+ readonly record: AppStoreRevocationRecord;
248
+ } | {
249
+ readonly status: 'absent';
250
+ } | {
251
+ readonly status: 'unavailable';
252
+ };
253
+ /**
254
+ * The registry of another Worker, reached through a service binding: the
255
+ * reference game-services Worker serves it from D1 on its
256
+ * `GameServicesAppStoreRevocations` entrypoint, next to the App Store Server
257
+ * Notifications route that fills it.
258
+ */
259
+ export interface AppStoreRevocationLookupBinding {
260
+ findAppStoreRevocation(input: AppStoreRevocationLookupRequest): Promise<AppStoreRevocationLookupAnswer>;
261
+ }
262
+ /**
263
+ * Answers one lookup as data (`AppStoreRevocationLookupAnswer`): the serving
264
+ * side of `createAppStoreRevocationLookupOverBinding`. An
265
+ * `AppStoreDependencyUnavailableError` becomes `unavailable`; any other
266
+ * error propagates.
267
+ */
268
+ export declare function answerAppStoreRevocationLookup(lookup: AppStoreRevocationLookup, input: AppStoreRevocationLookupRequest, signal?: AbortSignal): Promise<AppStoreRevocationLookupAnswer>;
269
+ /**
270
+ * An `AppStoreRevocationLookup` over another Worker's registry
271
+ * (`AppStoreRevocationLookupBinding`), for an `offline-only` purchase
272
+ * verifier that runs apart from the notification route recording the
273
+ * revocations. It fails closed: an `unavailable` answer, a binding that
274
+ * throws, an answer of another shape or for another grant identity or environment, and an
275
+ * abort of the verification are all `AppStoreDependencyUnavailableError`, so
276
+ * the purchase stays `pending` (`APP_STORE_REVOCATION_LOOKUP_UNAVAILABLE`)
277
+ * instead of granting without the check.
278
+ */
279
+ export declare function createAppStoreRevocationLookupOverBinding(binding: AppStoreRevocationLookupBinding): AppStoreRevocationLookup;
227
280
  /**
228
281
  * Evidence schema of a commerce v2 App Store purchase (RFC B3): the payload's
229
282
  * `signedTransaction` is the StoreKit-verified JWS the client received. The
@@ -21,21 +21,28 @@ class AppStoreResponseTooLargeError extends Error {
21
21
  * A process-local `AppStoreRevocationRegistry` for tests and single-process
22
22
  * hosts. It forgets everything on restart and is not shared between
23
23
  * instances (a Worker isolate is one), so a deployment that verifies
24
- * purchases offline needs a durable registry of its own.
24
+ * purchases offline needs a durable registry of its own: the reference
25
+ * game-services Worker keeps one in D1, and
26
+ * `runAppStoreRevocationRegistryConformance` (in
27
+ * `@mpgd/game-services/app-store-verifier-conformance`) checks another
28
+ * implementation against the same rules.
25
29
  */
26
30
  export function createInMemoryAppStoreRevocationRegistry() {
27
31
  const records = new Map();
32
+ // An environment is one of two fixed words, so the separator cannot collide.
33
+ const keyOf = (environment, verificationId) => `${environment}:${verificationId}`;
28
34
  return {
29
35
  async findRevocation(input) {
30
- return records.get(input.verificationId);
36
+ return records.get(keyOf(input.environment, input.verificationId));
31
37
  },
32
38
  async recordRevocation(input) {
33
39
  assertNonEmptyString(input.record.verificationId, 'record.verificationId');
34
- const kept = records.get(input.record.verificationId);
40
+ const key = keyOf(input.record.environment, input.record.verificationId);
41
+ const kept = records.get(key);
35
42
  if (kept !== undefined && !isLaterRevocation(input.record, kept)) {
36
43
  return { alreadyRecorded: true };
37
44
  }
38
- records.set(input.record.verificationId, { ...input.record });
45
+ records.set(key, { ...input.record });
39
46
  return { alreadyRecorded: false };
40
47
  },
41
48
  };
@@ -53,6 +60,101 @@ function isLaterRevocation(record, kept) {
53
60
  }
54
61
  return !Number.isFinite(recordMs) || recordMs > keptMs;
55
62
  }
63
+ /**
64
+ * Answers one lookup as data (`AppStoreRevocationLookupAnswer`): the serving
65
+ * side of `createAppStoreRevocationLookupOverBinding`. An
66
+ * `AppStoreDependencyUnavailableError` becomes `unavailable`; any other
67
+ * error propagates.
68
+ */
69
+ export async function answerAppStoreRevocationLookup(lookup, input, signal = new AbortController().signal) {
70
+ assertRecord(input, 'input');
71
+ assertNonEmptyString(input.verificationId, 'input.verificationId');
72
+ try {
73
+ const record = await lookup.findRevocation({
74
+ verificationId: input.verificationId,
75
+ environment: input.environment,
76
+ bundleId: input.bundleId,
77
+ transactionId: input.transactionId,
78
+ originalTransactionId: input.originalTransactionId,
79
+ signal,
80
+ });
81
+ return record === undefined ? { status: 'absent' } : { status: 'found', record };
82
+ }
83
+ catch (error) {
84
+ if (error instanceof AppStoreDependencyUnavailableError) {
85
+ return { status: 'unavailable' };
86
+ }
87
+ throw error;
88
+ }
89
+ }
90
+ /**
91
+ * An `AppStoreRevocationLookup` over another Worker's registry
92
+ * (`AppStoreRevocationLookupBinding`), for an `offline-only` purchase
93
+ * verifier that runs apart from the notification route recording the
94
+ * revocations. It fails closed: an `unavailable` answer, a binding that
95
+ * throws, an answer of another shape or for another grant identity or environment, and an
96
+ * abort of the verification are all `AppStoreDependencyUnavailableError`, so
97
+ * the purchase stays `pending` (`APP_STORE_REVOCATION_LOOKUP_UNAVAILABLE`)
98
+ * instead of granting without the check.
99
+ */
100
+ export function createAppStoreRevocationLookupOverBinding(binding) {
101
+ assertRecord(binding, 'binding');
102
+ if (typeof binding.findAppStoreRevocation !== 'function') {
103
+ throw new Error('binding must implement findAppStoreRevocation.');
104
+ }
105
+ return {
106
+ async findRevocation({ signal, ...request }) {
107
+ if (signal.aborted) {
108
+ // Never call the binding for a verification already given up on.
109
+ throw new AppStoreDependencyUnavailableError('The App Store revocation lookup was aborted.', signal.reason);
110
+ }
111
+ const answer = await raceAbort(Promise.resolve().then(() => binding.findAppStoreRevocation({
112
+ verificationId: request.verificationId,
113
+ environment: request.environment,
114
+ bundleId: request.bundleId,
115
+ transactionId: request.transactionId,
116
+ originalTransactionId: request.originalTransactionId,
117
+ })), signal).catch((error) => {
118
+ throw new AppStoreDependencyUnavailableError('The App Store revocation registry binding did not answer.', error);
119
+ });
120
+ if (typeof answer === 'object' && answer !== null && !Array.isArray(answer)) {
121
+ if (answer.status === 'absent') {
122
+ return undefined;
123
+ }
124
+ if (answer.status === 'found'
125
+ && typeof answer.record === 'object'
126
+ && answer.record !== null
127
+ && answer.record.verificationId === request.verificationId
128
+ && answer.record.environment === request.environment
129
+ && typeof answer.record.revokedAt === 'string') {
130
+ return answer.record;
131
+ }
132
+ }
133
+ throw new AppStoreDependencyUnavailableError('The App Store revocation registry is unavailable.');
134
+ },
135
+ };
136
+ }
137
+ /**
138
+ * `promise`, or a rejection once `signal` aborts (the binding itself cannot
139
+ * be cancelled). `promise` keeps its handlers either way, so its own later
140
+ * rejection is never unhandled.
141
+ */
142
+ function raceAbort(promise, signal) {
143
+ return new Promise((resolve, reject) => {
144
+ if (signal.aborted) {
145
+ reject(signal.reason);
146
+ }
147
+ const onAbort = () => reject(signal.reason);
148
+ signal.addEventListener('abort', onAbort, { once: true });
149
+ promise.then((value) => {
150
+ signal.removeEventListener('abort', onAbort);
151
+ resolve(value);
152
+ }, (error) => {
153
+ signal.removeEventListener('abort', onAbort);
154
+ reject(error);
155
+ });
156
+ });
157
+ }
56
158
  /**
57
159
  * Evidence schema of a commerce v2 App Store purchase (RFC B3): the payload's
58
160
  * `signedTransaction` is the StoreKit-verified JWS the client received. The
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Strict base64 decoders shared inside the package (not exported). Both
3
+ * answer undefined instead of throwing for anything that is not canonical
4
+ * input: no whitespace, no characters outside the alphabet, no impossible
5
+ * length, and at most two trailing `=` (standard alphabet only).
6
+ */
7
+ /** Standard base64 (`+`, `/`), padding optional. Undefined when empty or invalid. */
8
+ export declare function decodeBase64(input: string): Uint8Array<ArrayBuffer> | undefined;
9
+ /** Unpadded base64url (`-`, `_`), as JWS segments use it. Undefined when empty or invalid. */
10
+ export declare function decodeBase64Url(input: string): Uint8Array<ArrayBuffer> | undefined;
package/dist/base64.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Strict base64 decoders shared inside the package (not exported). Both
3
+ * answer undefined instead of throwing for anything that is not canonical
4
+ * input: no whitespace, no characters outside the alphabet, no impossible
5
+ * length, and at most two trailing `=` (standard alphabet only).
6
+ */
7
+ /** Standard base64 (`+`, `/`), padding optional. Undefined when empty or invalid. */
8
+ export function decodeBase64(input) {
9
+ const unpadded = /^([A-Za-z0-9+/]+)={0,2}$/u.exec(input)?.[1];
10
+ if (unpadded === undefined || unpadded.length % 4 === 1) {
11
+ return undefined;
12
+ }
13
+ return decodeUnpadded(unpadded);
14
+ }
15
+ /** Unpadded base64url (`-`, `_`), as JWS segments use it. Undefined when empty or invalid. */
16
+ export function decodeBase64Url(input) {
17
+ if (!/^[A-Za-z0-9_-]+$/u.test(input) || input.length % 4 === 1) {
18
+ return undefined;
19
+ }
20
+ return decodeUnpadded(input.replaceAll('-', '+').replaceAll('_', '/'));
21
+ }
22
+ function decodeUnpadded(base64) {
23
+ let binary;
24
+ try {
25
+ binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='));
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ const bytes = new Uint8Array(binary.length);
31
+ for (let index = 0; index < binary.length; index += 1) {
32
+ bytes[index] = binary.charCodeAt(index);
33
+ }
34
+ return bytes;
35
+ }
@@ -6,7 +6,9 @@ const publisherOrigin = 'https://androidpublisher.googleapis.com';
6
6
  export function createGooglePlayPublisherClient(options) {
7
7
  const requestFetch = options.fetch ?? fetch;
8
8
  async function request(method, path, signal) {
9
- const accessToken = await options.getAccessToken(signal);
9
+ // A token source that ignores the signal (a service binding) is still
10
+ // bounded by it: the request fails when the caller gives up.
11
+ const accessToken = await untilAborted(Promise.resolve().then(() => options.getAccessToken(signal)), signal);
10
12
  if (accessToken.length === 0 || /[\s\x00-\x1f\x7f]/u.test(accessToken)) {
11
13
  throw new TypeError('Google Play Publisher access token is invalid.');
12
14
  }
@@ -193,3 +195,24 @@ async function readBoundedJson(response) {
193
195
  }
194
196
  return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
195
197
  }
198
+ /** The promise, or an AbortError as soon as the signal aborts, whichever comes first. */
199
+ function untilAborted(promise, signal) {
200
+ if (signal.aborted) {
201
+ void promise.catch(() => undefined);
202
+ return Promise.reject(new DOMException('Google Play Publisher request was cancelled.', 'AbortError'));
203
+ }
204
+ return new Promise((resolve, reject) => {
205
+ const abort = () => {
206
+ void promise.catch(() => undefined);
207
+ reject(new DOMException('Google Play Publisher request was cancelled.', 'AbortError'));
208
+ };
209
+ signal.addEventListener('abort', abort, { once: true });
210
+ promise.then((value) => {
211
+ signal.removeEventListener('abort', abort);
212
+ resolve(value);
213
+ }, (error) => {
214
+ signal.removeEventListener('abort', abort);
215
+ reject(error);
216
+ });
217
+ });
218
+ }