@mpgd/game-services 0.28.1 → 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.
- package/README.md +18 -3
- package/dist/account-deletion.d.ts +306 -0
- package/dist/account-deletion.js +491 -0
- package/dist/app-store-recovery.d.ts +6 -0
- package/dist/app-store-recovery.js +26 -16
- package/dist/app-store-server-notifications.d.ts +158 -0
- package/dist/app-store-server-notifications.js +270 -0
- package/dist/app-store-server.d.ts +8 -0
- package/dist/app-store-server.js +135 -37
- package/dist/app-store-verifier.d.ts +34 -0
- package/dist/app-store-verifier.js +161 -31
- package/dist/commerce-ledger.d.ts +55 -0
- package/dist/commerce-ledger.js +198 -0
- package/dist/commerce-reconciler.js +5 -0
- package/dist/evidence-verification.d.ts +19 -0
- package/dist/google-play-publisher-client.d.ts +2 -1
- package/dist/google-play-publisher-client.js +80 -0
- package/dist/google-play-purchase.d.ts +7 -0
- package/dist/google-play-purchase.js +32 -7
- package/dist/google-play-store-server.d.ts +160 -0
- package/dist/google-play-store-server.js +297 -0
- package/dist/guest-session.d.ts +112 -2
- package/dist/guest-session.js +196 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/notifications/delivery-provider.d.ts +7 -0
- package/dist/notifications/fcm.d.ts +30 -3
- package/dist/notifications/fcm.js +72 -13
- package/dist/notifications/service.js +33 -5
- package/dist/notifications/store-conformance.js +7 -1
- package/dist/notifications/store.d.ts +9 -0
- package/dist/notifications/store.js +41 -0
- package/dist/operation-results.d.ts +7 -0
- package/dist/purchase-sharing.d.ts +41 -0
- package/dist/purchase-sharing.js +37 -0
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +13 -0
- package/dist/server.d.ts +60 -1
- package/dist/server.js +536 -42
- package/dist/types.d.ts +30 -0
- package/dist/types.js +22 -0
- package/dist/verified-leaderboard.d.ts +13 -0
- package/dist/verified-leaderboard.js +31 -0
- package/package.json +17 -5
package/dist/guest-session.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/guest-session.js
CHANGED
|
@@ -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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
...
|
|
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
|
-
|
|
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) {
|