@mpgd/game-services 0.28.0 → 0.29.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 (44) hide show
  1. package/README.md +18 -3
  2. package/dist/account-deletion.d.ts +306 -0
  3. package/dist/account-deletion.js +491 -0
  4. package/dist/app-store-recovery.d.ts +6 -0
  5. package/dist/app-store-recovery.js +26 -16
  6. package/dist/app-store-server-notifications.d.ts +158 -0
  7. package/dist/app-store-server-notifications.js +270 -0
  8. package/dist/app-store-server.d.ts +8 -0
  9. package/dist/app-store-server.js +135 -37
  10. package/dist/app-store-verifier.d.ts +34 -0
  11. package/dist/app-store-verifier.js +161 -31
  12. package/dist/commerce-ledger.d.ts +55 -0
  13. package/dist/commerce-ledger.js +198 -0
  14. package/dist/commerce-reconciler.js +5 -0
  15. package/dist/evidence-verification.d.ts +19 -0
  16. package/dist/google-play-publisher-client.d.ts +2 -1
  17. package/dist/google-play-publisher-client.js +80 -0
  18. package/dist/google-play-purchase.d.ts +7 -0
  19. package/dist/google-play-purchase.js +32 -7
  20. package/dist/google-play-store-server.d.ts +160 -0
  21. package/dist/google-play-store-server.js +297 -0
  22. package/dist/guest-session.d.ts +112 -2
  23. package/dist/guest-session.js +196 -1
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +4 -0
  26. package/dist/notifications/delivery-provider.d.ts +7 -0
  27. package/dist/notifications/fcm.d.ts +30 -3
  28. package/dist/notifications/fcm.js +72 -13
  29. package/dist/notifications/service.js +33 -5
  30. package/dist/notifications/store-conformance.js +7 -1
  31. package/dist/notifications/store.d.ts +9 -0
  32. package/dist/notifications/store.js +41 -0
  33. package/dist/operation-results.d.ts +7 -0
  34. package/dist/purchase-sharing.d.ts +41 -0
  35. package/dist/purchase-sharing.js +37 -0
  36. package/dist/runtime.d.ts +6 -0
  37. package/dist/runtime.js +13 -0
  38. package/dist/server.d.ts +60 -1
  39. package/dist/server.js +536 -42
  40. package/dist/types.d.ts +30 -0
  41. package/dist/types.js +22 -0
  42. package/dist/verified-leaderboard.d.ts +13 -0
  43. package/dist/verified-leaderboard.js +31 -0
  44. package/package.json +17 -5
@@ -75,13 +75,50 @@ export interface GuestSessionBackend {
75
75
  readonly externalProof: string;
76
76
  readonly idempotencyKey: string;
77
77
  }): Promise<AccountBindingOutcome>;
78
+ /**
79
+ * A short-lived, single-use account deletion confirmation for the refresh
80
+ * token's player, which the game-services deletion route requires besides
81
+ * the access token (`accountDeletionConfirmationHeader`). It must
82
+ * authenticate the refresh token like `refresh`, without rotating it, and
83
+ * throw `GuestSessionRejectedError` for one refused for good. Optional: a
84
+ * backend without it cannot delete accounts through the starter.
85
+ */
86
+ requestAccountDeletionConfirmation?(input: {
87
+ readonly refreshToken: string;
88
+ }): Promise<AccountDeletionConfirmationGrant>;
78
89
  }
79
- export type GuestSessionCoordinatorErrorCode = 'GUEST_SESSION_CLOSED' | 'GUEST_SESSION_NOT_READY' | 'GUEST_SESSION_INVALID_INPUT' | 'GUEST_SESSION_INVALID_RESPONSE' | 'GUEST_SESSION_OWNERSHIP_CONFLICT' | 'GUEST_SESSION_BACKEND_FAILED' | 'GUEST_SESSION_CREDENTIAL_LOAD_FAILED' | 'GUEST_SESSION_CREDENTIAL_SAVE_FAILED' | 'GUEST_SESSION_LOGOUT_UNCERTAIN';
90
+ /** A deletion confirmation, as the identity service answers it. */
91
+ export interface AccountDeletionConfirmationGrant {
92
+ /** Opaque; sent once, in `accountDeletionConfirmationHeader`. */
93
+ readonly confirmation: string;
94
+ /** When it stops being accepted (ISO 8601). */
95
+ readonly expiresAt: string;
96
+ }
97
+ export type GuestSessionCoordinatorErrorCode = 'GUEST_SESSION_CLOSED' | 'GUEST_SESSION_NOT_READY' | 'GUEST_SESSION_INVALID_INPUT' | 'GUEST_SESSION_INVALID_RESPONSE' | 'GUEST_SESSION_OWNERSHIP_CONFLICT' | 'GUEST_SESSION_BACKEND_FAILED'
98
+ /**
99
+ * The identity service refused the refresh token: revoked (logout,
100
+ * replay), expired after inactivity, or unknown. That session cannot come
101
+ * back: the coordinator stops using it (`current()` is `undefined`, and
102
+ * `start()`, `refresh()` and `getHeaders()` fail with
103
+ * `GUEST_SESSION_NOT_READY` without calling the backend). Log out (which
104
+ * removes the stored token) and start a new coordinator for a new guest.
105
+ */
106
+ | 'GUEST_SESSION_REJECTED' | 'GUEST_SESSION_CREDENTIAL_LOAD_FAILED' | 'GUEST_SESSION_CREDENTIAL_SAVE_FAILED' | 'GUEST_SESSION_LOGOUT_UNCERTAIN'
107
+ /** The backend has no `requestAccountDeletionConfirmation`. */
108
+ | 'GUEST_SESSION_UNSUPPORTED';
80
109
  /** Error messages intentionally contain no token, proof, or installation ID. */
81
110
  export declare class GuestSessionCoordinatorError extends Error {
82
111
  readonly code: GuestSessionCoordinatorErrorCode;
83
112
  constructor(code: GuestSessionCoordinatorErrorCode);
84
113
  }
114
+ /**
115
+ * A `GuestSessionBackend` throws this when the identity service refused the
116
+ * refresh token for good (revoked, expired, replayed, or unknown), as
117
+ * opposed to an outage. The coordinator reports it as `GUEST_SESSION_REJECTED`.
118
+ */
119
+ export declare class GuestSessionRejectedError extends Error {
120
+ constructor();
121
+ }
85
122
  export interface GuestSessionCoordinator {
86
123
  start(): Promise<ServerGuestSessionView>;
87
124
  /** The active session's view, or `undefined` before `start()` settles, after a failure, and after logout. */
@@ -103,6 +140,18 @@ export interface GuestSessionCoordinator {
103
140
  readonly status: 'bound' | 'already-bound' | 'conflict';
104
141
  }>;
105
142
  getHeaders(): Readonly<Record<'authorization', string>>;
143
+ /**
144
+ * Asks the backend for an account deletion confirmation with the active
145
+ * session's refresh token (queued with refreshes, so it never presents a
146
+ * token a rotation in flight replaces). Fails with
147
+ * `GUEST_SESSION_REJECTED` when the service refused the session for good,
148
+ * now or in an earlier refresh (as `refresh` does, the coordinator then
149
+ * stops using it), `GUEST_SESSION_NOT_READY` without an active session for
150
+ * any other reason (not started, a failed credential save),
151
+ * `GUEST_SESSION_CLOSED` after logout, and `GUEST_SESSION_UNSUPPORTED`
152
+ * when the backend cannot issue one.
153
+ */
154
+ requestAccountDeletionConfirmation(): Promise<string>;
106
155
  logout(): Promise<void>;
107
156
  }
108
157
  /**
@@ -165,7 +214,7 @@ export interface GuestServerSession {
165
214
  /**
166
215
  * The `Authorization` header. An expired access token is refreshed first,
167
216
  * and a failed refresh throws its error (`GUEST_SESSION_BACKEND_FAILED`,
168
- * `GUEST_SESSION_OWNERSHIP_CONFLICT`, ...). A token that expires within
217
+ * `GUEST_SESSION_REJECTED`, `GUEST_SESSION_OWNERSHIP_CONFLICT`, ...). A token that expires within
169
218
  * `refreshAheadMs` is answered at once while a refresh runs in the
170
219
  * background: once per token, and at most once per
171
220
  * `guestServerSessionRefreshAheadIntervalMs`, so a backend whose tokens
@@ -173,6 +222,13 @@ export interface GuestServerSession {
173
222
  * background refresh is retried when the token has expired.
174
223
  */
175
224
  getHeaders(): Promise<Readonly<Record<'authorization', string>>>;
225
+ /**
226
+ * An account deletion confirmation for the current session
227
+ * (`GuestSessionCoordinator.requestAccountDeletionConfirmation`), which the
228
+ * game-services deletion route requires. Optional, so another session
229
+ * holder (a development session) may leave it out.
230
+ */
231
+ requestAccountDeletionConfirmation?(): Promise<string>;
176
232
  }
177
233
  /** How long before its access token expires `createGuestServerSession` refreshes a session: one minute. */
178
234
  export declare const defaultGuestServerSessionRefreshAheadMs = 60000;
@@ -190,3 +246,57 @@ export declare function createGuestServerSession(coordinator: GuestSessionCoordi
190
246
  readonly refreshAheadMs?: number;
191
247
  readonly now?: () => number;
192
248
  }): GuestServerSession;
249
+ /**
250
+ * The HTTP routes of a guest identity service, relative to its base URL:
251
+ * the contract between `createGuestSessionHttpBackend` and the reference
252
+ * identity Worker (`apps/game-identity-worker`). Every route takes a JSON
253
+ * `POST` body. `issueGuest` and `refresh` answer `200` with
254
+ * `ServerGuestSessionCredentials`; `revoke` answers `200` with `{}`, also for
255
+ * a token it does not know (RFC 7009 section 2.2); a refusal answers
256
+ * `{ error: GuestIdentityHttpErrorCode }`. `bindAccount` answers `200` with
257
+ * an `AccountBindingOutcome` (a `conflict` too), or a refusal; the reference
258
+ * Worker binds nothing yet and answers `ACCOUNT_BINDING_UNSUPPORTED`.
259
+ * `requestAccountDeletionConfirmation` takes `{ refreshToken }` and answers
260
+ * `200` with an `AccountDeletionConfirmationGrant`. A refresh token any
261
+ * route refuses for good is `GUEST_SESSION_REJECTED`.
262
+ */
263
+ export declare const guestIdentityHttpRoutes: Readonly<{
264
+ readonly issueGuest: '/identity/v1/guest';
265
+ readonly refresh: '/identity/v1/refresh';
266
+ readonly revoke: '/identity/v1/revoke';
267
+ readonly bindAccount: '/identity/v1/bind-account';
268
+ readonly requestAccountDeletionConfirmation: '/identity/v1/account-deletion-confirmation';
269
+ }>;
270
+ /**
271
+ * - `INVALID_REQUEST` (400): a body the route does not take.
272
+ * - `GUEST_SESSION_REJECTED` (401): the refresh token is revoked, expired,
273
+ * replayed, or unknown; the session cannot come back.
274
+ * - `RATE_LIMITED` (429): too many guests for this installation or address.
275
+ * - `ACCOUNT_BINDING_UNSUPPORTED` (501): the service binds no external account.
276
+ * - `UNAVAILABLE` (503): the service could not answer; retry later.
277
+ */
278
+ export type GuestIdentityHttpErrorCode = 'INVALID_REQUEST' | 'GUEST_SESSION_REJECTED' | 'RATE_LIMITED' | 'ACCOUNT_BINDING_UNSUPPORTED' | 'UNAVAILABLE';
279
+ /** A failed identity request. The message names the route and the code, never a token. */
280
+ export declare class GuestSessionHttpError extends Error {
281
+ readonly status: number;
282
+ readonly code: string;
283
+ constructor(route: string, status: number, code: string);
284
+ }
285
+ /** How long one identity request may take by default: 15 seconds. */
286
+ export declare const defaultGuestSessionHttpTimeoutMs = 15000;
287
+ /**
288
+ * A `GuestSessionBackend` over a guest identity service's HTTP routes
289
+ * (`guestIdentityHttpRoutes`). A `refresh` the service refuses with
290
+ * `GUEST_SESSION_REJECTED` throws `GuestSessionRejectedError` (the
291
+ * coordinator's `GUEST_SESSION_REJECTED`); any other failure throws
292
+ * `GuestSessionHttpError` or the fetch error, which the coordinator reports
293
+ * as `GUEST_SESSION_BACKEND_FAILED`. `baseUrl` must be `https:`, except on a
294
+ * local development host (`localhost`, `*.localhost`, `127.0.0.1`, `[::1]`,
295
+ * `10.0.2.2`). Requests never follow a redirect, so a token is only ever sent
296
+ * to `baseUrl`'s origin.
297
+ */
298
+ export declare function createGuestSessionHttpBackend(input: {
299
+ readonly baseUrl: string;
300
+ readonly fetch?: typeof globalThis.fetch;
301
+ readonly timeoutMs?: number;
302
+ }): GuestSessionBackend;
@@ -7,6 +7,17 @@ export class GuestSessionCoordinatorError extends Error {
7
7
  this.code = code;
8
8
  }
9
9
  }
10
+ /**
11
+ * A `GuestSessionBackend` throws this when the identity service refused the
12
+ * refresh token for good (revoked, expired, replayed, or unknown), as
13
+ * opposed to an outage. The coordinator reports it as `GUEST_SESSION_REJECTED`.
14
+ */
15
+ export class GuestSessionRejectedError extends Error {
16
+ constructor() {
17
+ super('The identity service rejected the guest session.');
18
+ this.name = 'GuestSessionRejectedError';
19
+ }
20
+ }
10
21
  const defaultCredentialKey = 'mpgd.guest.refresh';
11
22
  /**
12
23
  * Serializes token mutation and closes synchronously on logout. Native secure
@@ -49,6 +60,8 @@ export function createGuestSessionCoordinator(input) {
49
60
  let savedFamily;
50
61
  const listeners = new Set();
51
62
  let status = 'idle';
63
+ /** The identity service refused the session for good (as opposed to a failed save). */
64
+ let rejected = false;
52
65
  let tail = Promise.resolve();
53
66
  let startInFlight;
54
67
  let refreshInFlight;
@@ -85,7 +98,16 @@ export function createGuestSessionCoordinator(input) {
85
98
  try {
86
99
  return await operation();
87
100
  }
88
- catch {
101
+ catch (error) {
102
+ if (error instanceof GuestSessionRejectedError) {
103
+ // The session can never come back: stop using it (current() is
104
+ // undefined, start() and getHeaders() fail fast) until logout.
105
+ if (status !== 'closed') {
106
+ status = 'failed';
107
+ }
108
+ rejected = true;
109
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_REJECTED');
110
+ }
89
111
  // Backend exceptions can contain URLs, proofs, or tokens. The public
90
112
  // coordinator surface intentionally exposes only a stable error code.
91
113
  throw new GuestSessionCoordinatorError('GUEST_SESSION_BACKEND_FAILED');
@@ -247,6 +269,30 @@ export function createGuestSessionCoordinator(input) {
247
269
  return { status: result.status };
248
270
  });
249
271
  },
272
+ async requestAccountDeletionConfirmation() {
273
+ assertOpen();
274
+ const request = input.backend.requestAccountDeletionConfirmation?.bind(input.backend);
275
+ if (request === undefined) {
276
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_UNSUPPORTED');
277
+ }
278
+ return enqueue(async () => {
279
+ assertOpen();
280
+ // Refused earlier (a refresh): say so, not merely "not ready", so the
281
+ // caller can tell a session that ended on the server from a failed save.
282
+ if (rejected) {
283
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_REJECTED');
284
+ }
285
+ const active = requireCurrent();
286
+ const grant = await fromBackend(() => request({ refreshToken: active.refreshToken }));
287
+ // Logged out meanwhile: no confirmation for a closed session.
288
+ assertOpen();
289
+ if (!isRecord(grant) || typeof grant.confirmation !== 'string' || grant.confirmation.trim() === ''
290
+ || typeof grant.expiresAt !== 'string' || !Number.isFinite(Date.parse(grant.expiresAt))) {
291
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_RESPONSE');
292
+ }
293
+ return grant.confirmation;
294
+ });
295
+ },
250
296
  current() {
251
297
  return status === 'active' && current !== undefined ? view(current) : undefined;
252
298
  },
@@ -396,6 +442,7 @@ export function createGuestServerSession(coordinator, options = {}) {
396
442
  listener(next);
397
443
  });
398
444
  },
445
+ requestAccountDeletionConfirmation: () => coordinator.requestAccountDeletionConfirmation(),
399
446
  async getHeaders() {
400
447
  const active = coordinator.current();
401
448
  if (active !== undefined) {
@@ -421,3 +468,151 @@ export function createGuestServerSession(coordinator, options = {}) {
421
468
  },
422
469
  };
423
470
  }
471
+ /**
472
+ * The HTTP routes of a guest identity service, relative to its base URL:
473
+ * the contract between `createGuestSessionHttpBackend` and the reference
474
+ * identity Worker (`apps/game-identity-worker`). Every route takes a JSON
475
+ * `POST` body. `issueGuest` and `refresh` answer `200` with
476
+ * `ServerGuestSessionCredentials`; `revoke` answers `200` with `{}`, also for
477
+ * a token it does not know (RFC 7009 section 2.2); a refusal answers
478
+ * `{ error: GuestIdentityHttpErrorCode }`. `bindAccount` answers `200` with
479
+ * an `AccountBindingOutcome` (a `conflict` too), or a refusal; the reference
480
+ * Worker binds nothing yet and answers `ACCOUNT_BINDING_UNSUPPORTED`.
481
+ * `requestAccountDeletionConfirmation` takes `{ refreshToken }` and answers
482
+ * `200` with an `AccountDeletionConfirmationGrant`. A refresh token any
483
+ * route refuses for good is `GUEST_SESSION_REJECTED`.
484
+ */
485
+ export const guestIdentityHttpRoutes = Object.freeze({
486
+ issueGuest: '/identity/v1/guest',
487
+ refresh: '/identity/v1/refresh',
488
+ revoke: '/identity/v1/revoke',
489
+ bindAccount: '/identity/v1/bind-account',
490
+ requestAccountDeletionConfirmation: '/identity/v1/account-deletion-confirmation',
491
+ });
492
+ /** A failed identity request. The message names the route and the code, never a token. */
493
+ export class GuestSessionHttpError extends Error {
494
+ status;
495
+ code;
496
+ constructor(route, status, code) {
497
+ super(`Guest identity request ${route} failed: ${status} ${code}.`);
498
+ this.name = 'GuestSessionHttpError';
499
+ this.status = status;
500
+ this.code = code;
501
+ }
502
+ }
503
+ /** How long one identity request may take by default: 15 seconds. */
504
+ export const defaultGuestSessionHttpTimeoutMs = 15_000;
505
+ /**
506
+ * A `GuestSessionBackend` over a guest identity service's HTTP routes
507
+ * (`guestIdentityHttpRoutes`). A `refresh` the service refuses with
508
+ * `GUEST_SESSION_REJECTED` throws `GuestSessionRejectedError` (the
509
+ * coordinator's `GUEST_SESSION_REJECTED`); any other failure throws
510
+ * `GuestSessionHttpError` or the fetch error, which the coordinator reports
511
+ * as `GUEST_SESSION_BACKEND_FAILED`. `baseUrl` must be `https:`, except on a
512
+ * local development host (`localhost`, `*.localhost`, `127.0.0.1`, `[::1]`,
513
+ * `10.0.2.2`). Requests never follow a redirect, so a token is only ever sent
514
+ * to `baseUrl`'s origin.
515
+ */
516
+ export function createGuestSessionHttpBackend(input) {
517
+ const base = parseGuestIdentityBaseUrl(input.baseUrl);
518
+ const timeoutMs = input.timeoutMs ?? defaultGuestSessionHttpTimeoutMs;
519
+ // setTimeout fires at once past a signed 32-bit delay.
520
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
521
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
522
+ }
523
+ const fetchImpl = input.fetch ?? ((request) => globalThis.fetch(request));
524
+ async function post(route, body) {
525
+ const controller = new AbortController();
526
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
527
+ try {
528
+ const response = await fetchImpl(new Request(new URL(route.slice(1), base), {
529
+ method: 'POST',
530
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
531
+ body: JSON.stringify(body),
532
+ redirect: 'manual',
533
+ signal: controller.signal,
534
+ }));
535
+ // A fetch that followed a redirect anyway (some polyfills ignore
536
+ // `manual`) answered from another origin: never trust that answer.
537
+ if (response.redirected) {
538
+ throw new GuestSessionHttpError(route, response.status, 'REDIRECTED');
539
+ }
540
+ let payload;
541
+ try {
542
+ payload = await response.json();
543
+ }
544
+ catch {
545
+ payload = undefined;
546
+ }
547
+ if (response.status !== 200) {
548
+ const code = isRecord(payload) && typeof payload.error === 'string' && /^[A-Z_]{1,64}$/u.test(payload.error)
549
+ ? payload.error
550
+ : 'UNKNOWN';
551
+ throw new GuestSessionHttpError(route, response.status, code);
552
+ }
553
+ // A 200 that is not a JSON object (a captive portal's page, a body cut
554
+ // off by the timeout) is a failure, so a revoke is never assumed done.
555
+ if (!isRecord(payload)) {
556
+ throw new GuestSessionHttpError(route, response.status, 'INVALID_BODY');
557
+ }
558
+ return payload;
559
+ }
560
+ finally {
561
+ clearTimeout(timer);
562
+ }
563
+ }
564
+ return {
565
+ async issueGuest({ installationId }) {
566
+ return await post(guestIdentityHttpRoutes.issueGuest, { installationId });
567
+ },
568
+ async refresh({ refreshToken }) {
569
+ return await withRejection(post(guestIdentityHttpRoutes.refresh, { refreshToken }));
570
+ },
571
+ async revoke({ refreshToken }) {
572
+ await post(guestIdentityHttpRoutes.revoke, { refreshToken });
573
+ },
574
+ async bindAccount({ refreshToken, externalProof, idempotencyKey }) {
575
+ return await withRejection(post(guestIdentityHttpRoutes.bindAccount, {
576
+ refreshToken,
577
+ externalProof,
578
+ idempotencyKey,
579
+ }));
580
+ },
581
+ async requestAccountDeletionConfirmation({ refreshToken }) {
582
+ return await withRejection(post(guestIdentityHttpRoutes.requestAccountDeletionConfirmation, { refreshToken }));
583
+ },
584
+ };
585
+ }
586
+ /** The service's `401 GUEST_SESSION_REJECTED` as `GuestSessionRejectedError`; other failures unchanged. */
587
+ async function withRejection(request) {
588
+ try {
589
+ return await request;
590
+ }
591
+ catch (error) {
592
+ if (error instanceof GuestSessionHttpError && error.status === 401 && error.code === 'GUEST_SESSION_REJECTED') {
593
+ throw new GuestSessionRejectedError();
594
+ }
595
+ throw error;
596
+ }
597
+ }
598
+ function parseGuestIdentityBaseUrl(value) {
599
+ let url;
600
+ try {
601
+ url = new URL(value);
602
+ }
603
+ catch {
604
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
605
+ }
606
+ const host = url.hostname.toLowerCase();
607
+ const local = host === 'localhost' || host.endsWith('.localhost') || host === '127.0.0.1'
608
+ || host === '[::1]' || host === '10.0.2.2';
609
+ if (!(url.protocol === 'https:' || (url.protocol === 'http:' && local))
610
+ || url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '') {
611
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
612
+ }
613
+ // Routes resolve under the base path, so `https://host/api` serves `/api/identity/v1/...`.
614
+ if (!url.pathname.endsWith('/')) {
615
+ url.pathname = `${url.pathname}/`;
616
+ }
617
+ return url;
618
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
+ export * from './account-deletion.js';
1
2
  export * from './admob-ssv.js';
2
3
  export * from './ad-reward-verifier-registry.js';
3
4
  export * from './app-store-verifier.js';
4
5
  export * from './app-store-recovery.js';
6
+ export * from './app-store-server-notifications.js';
5
7
  export * from './apps-in-toss-evidence-verification.js';
6
8
  export * from './apps-in-toss-partner-api.js';
7
9
  export * from './client.js';
@@ -17,6 +19,7 @@ export * from './evidence-verification.js';
17
19
  export * from './guest-session.js';
18
20
  export * from './google-play-purchase.js';
19
21
  export * from './google-play-publisher-client.js';
22
+ export * from './google-play-store-server.js';
20
23
  export * from './microsoft-store-identity.js';
21
24
  export * from './microsoft-store-purchase.js';
22
25
  export * from './monetization-operation-store.js';
@@ -24,6 +27,7 @@ export * from './monetization-recovery.js';
24
27
  export * from './notification-delivery.js';
25
28
  export * from './platform-order.js';
26
29
  export * from './progress-link.js';
30
+ export * from './purchase-sharing.js';
27
31
  export * from './runtime.js';
28
32
  export * from './server.js';
29
33
  export * from './types.js';
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
+ export * from './account-deletion.js';
1
2
  export * from './admob-ssv.js';
2
3
  export * from './ad-reward-verifier-registry.js';
3
4
  export * from './app-store-verifier.js';
4
5
  export * from './app-store-recovery.js';
6
+ export * from './app-store-server-notifications.js';
5
7
  export * from './apps-in-toss-evidence-verification.js';
6
8
  export * from './apps-in-toss-partner-api.js';
7
9
  export * from './client.js';
@@ -17,6 +19,7 @@ export * from './evidence-verification.js';
17
19
  export * from './guest-session.js';
18
20
  export * from './google-play-purchase.js';
19
21
  export * from './google-play-publisher-client.js';
22
+ export * from './google-play-store-server.js';
20
23
  export * from './microsoft-store-identity.js';
21
24
  export * from './microsoft-store-purchase.js';
22
25
  export * from './monetization-operation-store.js';
@@ -24,6 +27,7 @@ export * from './monetization-recovery.js';
24
27
  export * from './notification-delivery.js';
25
28
  export * from './platform-order.js';
26
29
  export * from './progress-link.js';
30
+ export * from './purchase-sharing.js';
27
31
  export * from './runtime.js';
28
32
  export * from './server.js';
29
33
  export * from './types.js';
@@ -22,6 +22,13 @@ export interface NotificationDeviceAddress {
22
22
  readonly kind: 'fcm-token' | 'fcm-installation';
23
23
  readonly value: string;
24
24
  readonly environment?: 'production' | 'sandbox';
25
+ /**
26
+ * The installation's shell renders advertising pushes itself, with the
27
+ * opt-out action (an Android shell with the kit's renderer). The backend
28
+ * keeps it only for a target whose pushes need it, and the FCM provider
29
+ * then sends advertising as a data message the shell renders.
30
+ */
31
+ readonly rendersAdvertising?: boolean;
25
32
  }
26
33
  /** Who one delivery goes to: a device endpoint, or a resolved platform identity. */
27
34
  export type NotificationRemoteRecipient = {
@@ -14,9 +14,11 @@ import type { NotificationProviderRegistration } from './registry.js';
14
14
  * Android posts to the mapping's `channelId`, else to `mpgd-service`,
15
15
  * `mpgd-requested`, or `mpgd-advertising` by purpose. An advertising send
16
16
  * names the iOS category `mpgd-advertising`, whose opt-out action the app
17
- * registers; an Android FCM notification message cannot carry actions, and
18
- * the Capacitor shell does not render advertising natively yet. An expiry
19
- * becomes `android.ttl` and the `apns-expiration` header. A replay of the
17
+ * registers. An Android FCM notification message cannot carry actions, so an
18
+ * advertising send to an endpoint whose registration says its shell renders
19
+ * advertising (`rendersAdvertising`) is a data message the shell renders
20
+ * with the action (`fcmShellRenderedDataKeys`). An expiry becomes
21
+ * `android.ttl` and the `apns-expiration` header. A replay of the
20
22
  * same send key sets `android.collapse_key` and `apns-collapse-id` so a
21
23
  * crash after HTTP 200, before the ledger write, does not show a second
22
24
  * notification.
@@ -35,6 +37,31 @@ export declare const fcmReservedDataKeys: readonly ["topic", "route"];
35
37
  /** The iOS notification category of advertising, which carries the opt-out action. */
36
38
  export declare const fcmAdvertisingCategory = "mpgd-advertising";
37
39
  /**
40
+ * The data keys of an advertising push the Android shell renders itself
41
+ * (RFC "Delivery providers"). An Android FCM notification message cannot
42
+ * carry actions, so such a push has no `notification` block: the app's
43
+ * messaging service receives it in the background too and posts it with the
44
+ * opt-out action. `render` is `fcmShellRenderedAdvertising`; `title` and
45
+ * `body` are the gate's rendered text, `channel` the Android channel,
46
+ * `actionTitle` and `optOutRoute` the opt-out action's title and route (the
47
+ * topic's declared opt-out route) when the content carries the action, and
48
+ * `expiresAt` the expiry in epoch milliseconds, after which the
49
+ * shell does not show it. The send's `topic` and `route` stay in their own
50
+ * keys, as on every push. A send's routing data may not use the `mpgd.`
51
+ * prefix.
52
+ */
53
+ export declare const fcmShellRenderedDataKeys: Readonly<{
54
+ readonly render: 'mpgd.render';
55
+ readonly title: 'mpgd.title';
56
+ readonly body: 'mpgd.body';
57
+ readonly channel: 'mpgd.channel';
58
+ readonly actionTitle: 'mpgd.actionTitle';
59
+ readonly optOutRoute: 'mpgd.optOutRoute';
60
+ readonly expiresAt: 'mpgd.expiresAt';
61
+ }>;
62
+ /** The `mpgd.render` value of an advertising push the shell renders. */
63
+ export declare const fcmShellRenderedAdvertising = "advertising";
64
+ /**
38
65
  * `android.collapse_key` and `apns-collapse-id` are at most 64 bytes. The
39
66
  * id is the SHA-256 of the send key, which is 64 hex characters.
40
67
  */
@@ -1,4 +1,4 @@
1
- import { referenceNotificationProviders } from '@mpgd/platform/notifications';
1
+ import { notificationAdvertisingOptOutActionId, referenceNotificationProviders, } from '@mpgd/platform/notifications';
2
2
  import { fcmInstallationIdRefusal, } from './delivery-provider.js';
3
3
  /**
4
4
  * The FCM remote delivery provider (notifications v2 RFC, "Delivery
@@ -14,9 +14,11 @@ import { fcmInstallationIdRefusal, } from './delivery-provider.js';
14
14
  * Android posts to the mapping's `channelId`, else to `mpgd-service`,
15
15
  * `mpgd-requested`, or `mpgd-advertising` by purpose. An advertising send
16
16
  * names the iOS category `mpgd-advertising`, whose opt-out action the app
17
- * registers; an Android FCM notification message cannot carry actions, and
18
- * the Capacitor shell does not render advertising natively yet. An expiry
19
- * becomes `android.ttl` and the `apns-expiration` header. A replay of the
17
+ * registers. An Android FCM notification message cannot carry actions, so an
18
+ * advertising send to an endpoint whose registration says its shell renders
19
+ * advertising (`rendersAdvertising`) is a data message the shell renders
20
+ * with the action (`fcmShellRenderedDataKeys`). An expiry becomes
21
+ * `android.ttl` and the `apns-expiration` header. A replay of the
20
22
  * same send key sets `android.collapse_key` and `apns-collapse-id` so a
21
23
  * crash after HTTP 200, before the ledger write, does not show a second
22
24
  * notification.
@@ -34,6 +36,31 @@ export const fcmAddressKinds = Object.freeze(['fcm-token']);
34
36
  export const fcmReservedDataKeys = Object.freeze(['topic', 'route']);
35
37
  /** The iOS notification category of advertising, which carries the opt-out action. */
36
38
  export const fcmAdvertisingCategory = 'mpgd-advertising';
39
+ /**
40
+ * The data keys of an advertising push the Android shell renders itself
41
+ * (RFC "Delivery providers"). An Android FCM notification message cannot
42
+ * carry actions, so such a push has no `notification` block: the app's
43
+ * messaging service receives it in the background too and posts it with the
44
+ * opt-out action. `render` is `fcmShellRenderedAdvertising`; `title` and
45
+ * `body` are the gate's rendered text, `channel` the Android channel,
46
+ * `actionTitle` and `optOutRoute` the opt-out action's title and route (the
47
+ * topic's declared opt-out route) when the content carries the action, and
48
+ * `expiresAt` the expiry in epoch milliseconds, after which the
49
+ * shell does not show it. The send's `topic` and `route` stay in their own
50
+ * keys, as on every push. A send's routing data may not use the `mpgd.`
51
+ * prefix.
52
+ */
53
+ export const fcmShellRenderedDataKeys = Object.freeze({
54
+ render: 'mpgd.render',
55
+ title: 'mpgd.title',
56
+ body: 'mpgd.body',
57
+ channel: 'mpgd.channel',
58
+ actionTitle: 'mpgd.actionTitle',
59
+ optOutRoute: 'mpgd.optOutRoute',
60
+ expiresAt: 'mpgd.expiresAt',
61
+ });
62
+ /** The `mpgd.render` value of an advertising push the shell renders. */
63
+ export const fcmShellRenderedAdvertising = 'advertising';
37
64
  /**
38
65
  * FCM's 4,096-byte limit, less room for what FCM and APNs add on the way
39
66
  * (the `aps` wrapper and FCM's own keys), so a message this check passes is
@@ -319,9 +346,38 @@ export function buildFcmMessage(request) {
319
346
  // instead of showing beside it. Collapse is per token, so two devices of
320
347
  // one send do not hide each other.
321
348
  const collapseId = fcmCollapseId(request.sendKey);
349
+ const ttl = expiresAt === undefined ? {} : { ttl: `${Math.floor((expiresAt - at) / 1000)}s` };
350
+ if (request.topic.purpose === 'advertising' && recipient.address.rendersAdvertising === true) {
351
+ // A data message without a `notification` block reaches the shell's
352
+ // messaging service in the background too, which renders it with the
353
+ // opt-out action. Normal priority, FCM's default for data and
354
+ // notification messages alike: FCM keeps high priority for urgent,
355
+ // time-sensitive messages, and advertising is not one. The shell's
356
+ // renderer checks the expiry again, since arrival decides.
357
+ const optOut = content.actions
358
+ ?.find((action) => action.id === notificationAdvertisingOptOutActionId);
359
+ const rendered = Object.create(null);
360
+ Object.assign(rendered, data, {
361
+ [fcmShellRenderedDataKeys.render]: fcmShellRenderedAdvertising,
362
+ [fcmShellRenderedDataKeys.title]: content.title,
363
+ [fcmShellRenderedDataKeys.body]: content.body,
364
+ [fcmShellRenderedDataKeys.channel]: channelId,
365
+ ...(optOut === undefined
366
+ ? {}
367
+ : {
368
+ [fcmShellRenderedDataKeys.actionTitle]: optOut.title,
369
+ [fcmShellRenderedDataKeys.optOutRoute]: optOut.route,
370
+ }),
371
+ ...(expiresAt === undefined ? {} : { [fcmShellRenderedDataKeys.expiresAt]: String(expiresAt) }),
372
+ });
373
+ return sizedMessage(recipient.address.value, {
374
+ data: { ...rendered },
375
+ android: { collapse_key: collapseId, ...ttl },
376
+ });
377
+ }
322
378
  const android = {
323
379
  collapse_key: collapseId,
324
- ...(expiresAt === undefined ? {} : { ttl: `${Math.floor((expiresAt - at) / 1000)}s` }),
380
+ ...ttl,
325
381
  notification: { channel_id: channelId },
326
382
  };
327
383
  const category = request.topic.purpose === 'advertising' ? fcmAdvertisingCategory : undefined;
@@ -332,12 +388,15 @@ export function buildFcmMessage(request) {
332
388
  },
333
389
  ...(category === undefined ? {} : { payload: { aps: { category } } }),
334
390
  };
335
- const payload = {
391
+ return sizedMessage(recipient.address.value, {
336
392
  notification,
337
393
  data: { ...data },
338
394
  android,
339
395
  apns,
340
- };
396
+ });
397
+ }
398
+ /** The message for `token`, unless its payload is over the size this provider sends. */
399
+ function sizedMessage(token, payload) {
341
400
  // Everything but the token counts toward the size FCM and APNs carry.
342
401
  const payloadBytes = new TextEncoder().encode(JSON.stringify(payload)).length;
343
402
  if (payloadBytes > maxPayloadBytes) {
@@ -347,10 +406,7 @@ export function buildFcmMessage(request) {
347
406
  code: 'INVALID_MESSAGE',
348
407
  };
349
408
  }
350
- return {
351
- status: 'ready',
352
- message: { token: recipient.address.value, ...payload },
353
- };
409
+ return { status: 'ready', message: { token, ...payload } };
354
410
  }
355
411
  /**
356
412
  * Maps an FCM error answer to the provider answer kinds (RFC "Delivery
@@ -518,7 +574,7 @@ function isFirebaseProjectId(id) {
518
574
  && id.length <= 100
519
575
  && new RegExp(`^(?:${label}(?:\\.${label})*:)?${label}$`, 'u').test(id);
520
576
  }
521
- /** FCM's reserved data keys (`from`, `message_type`, and any `google` or `gcm` prefix), `topic` and `route`, and keys a plain object cannot hold. */
577
+ /** FCM's reserved data keys (`from`, `message_type`, and any `google` or `gcm` prefix), `topic` and `route`, the shell renderer's `mpgd.` keys, and keys a plain object cannot hold. */
522
578
  function isReservedDataKey(key) {
523
579
  const lower = key.toLowerCase();
524
580
  return key === ''
@@ -532,7 +588,10 @@ function isReservedDataKey(key) {
532
588
  || key === 'aps'
533
589
  || key === 'fcm_options'
534
590
  || lower.startsWith('google')
535
- || lower.startsWith('gcm');
591
+ || lower.startsWith('gcm')
592
+ // The shell renderer's keys (`fcmShellRenderedDataKeys`) and the keys it
593
+ // adds to an open.
594
+ || lower.startsWith('mpgd.');
536
595
  }
537
596
  /** The `details` of an FCM error answer. */
538
597
  function errorDetailsOf(body) {