@mpgd/game-services 0.27.2 → 0.28.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 -0
- package/dist/development-session.d.ts +64 -0
- package/dist/development-session.js +79 -0
- package/dist/guest-session.d.ts +109 -1
- package/dist/guest-session.js +190 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/notifications/client.d.ts +4 -3
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -259,6 +259,24 @@ normalized to token-free coordinator codes, not forwarded with raw messages.
|
|
|
259
259
|
`getHeaders()` can be passed as the Game Services runtime's
|
|
260
260
|
`getHeaders` resolver. The public session view omits both bearer tokens.
|
|
261
261
|
|
|
262
|
+
Each view also carries `sessionFamilyId`, the stable session-family id: one
|
|
263
|
+
value for the session's whole life, whatever its `sessionId` does on refresh.
|
|
264
|
+
A backend should send it (`sessionFamilyId` in its session answers, the same
|
|
265
|
+
id its identity service answers to the notifications Worker). A backend that
|
|
266
|
+
omits it gets a derived id that changes only where the backend's family might
|
|
267
|
+
have: at a new guest, at a bound account whose `sessionId` changed, and at a
|
|
268
|
+
restore whose saved family (`<credentialKey>.family` in the same secure
|
|
269
|
+
storage, written before the token and removed at logout) cannot be read. Such
|
|
270
|
+
a backend must give a binding that starts a new family a new `sessionId`.
|
|
271
|
+
`current()` reads the active view and `onSession(listener)` hears each new one.
|
|
272
|
+
`createGuestServerSession(coordinator)` reads the coordinator as the
|
|
273
|
+
server-backed session the notifications client and the starter's
|
|
274
|
+
`setStarterServerSession` take: `current()` answers the server user as
|
|
275
|
+
`playerId` and the family id as `sessionId`, `onAuthenticated` hears a new
|
|
276
|
+
family (not a refresh), and `getHeaders()` refreshes an expired access token
|
|
277
|
+
before answering, and one that expires within a minute in the background
|
|
278
|
+
(once per token, at most every ten seconds).
|
|
279
|
+
|
|
262
280
|
Binding an external account never switches to a different server user on a
|
|
263
281
|
client callback. It does not merge purchases, currency, or progress. Use the
|
|
264
282
|
existing `progress-link` nonce/idempotency service separately for a
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local development notification sessions: an unauthenticated stand-in for
|
|
3
|
+
* the game's identity service, so a developer can try the notifications
|
|
4
|
+
* routes of a local game-services Worker before writing one.
|
|
5
|
+
*
|
|
6
|
+
* The Worker accepts `Authorization: Bearer mpgd-dev.<playerId>.<sessionId>.<installationId>`
|
|
7
|
+
* only with `MPGD_ALLOW_INSECURE_DEVELOPMENT_NOTIFICATION_SESSIONS = "true"`,
|
|
8
|
+
* without a `GAME_SERVICES_NOTIFICATIONS_AUTH` binding, never with the D1
|
|
9
|
+
* store, and only on a request to a local host (loopback, `*.localhost`, or
|
|
10
|
+
* the Android emulator's `10.0.2.2`). Anyone can write such a header, so it is no authentication:
|
|
11
|
+
* never use it outside local development.
|
|
12
|
+
*/
|
|
13
|
+
/** The scheme and prefix of a development session's `Authorization` header. */
|
|
14
|
+
export declare const developmentNotificationSessionPrefix = "Bearer mpgd-dev.";
|
|
15
|
+
/** The ids a development session token names. */
|
|
16
|
+
export interface DevelopmentNotificationSession {
|
|
17
|
+
readonly playerId: string;
|
|
18
|
+
readonly sessionId: string;
|
|
19
|
+
readonly installationId: string;
|
|
20
|
+
}
|
|
21
|
+
/** Whether `value` can be one id of a development session. */
|
|
22
|
+
export declare function isDevelopmentNotificationSessionId(value: unknown): value is string;
|
|
23
|
+
/**
|
|
24
|
+
* The `Authorization` header value for a development session. Throws when
|
|
25
|
+
* an id is not 1 to 64 letters, digits, `_`, or `-`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function formatDevelopmentNotificationSessionAuthorization(session: DevelopmentNotificationSession): string;
|
|
28
|
+
/**
|
|
29
|
+
* The session a development `Authorization` header names, or `undefined`
|
|
30
|
+
* for any other value: exactly `Bearer mpgd-dev.` and three ids separated
|
|
31
|
+
* by `.`, each 1 to 64 letters, digits, `_`, or `-`. It has no `expiresAt`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseDevelopmentNotificationSessionAuthorization(authorization: unknown): DevelopmentNotificationSession | undefined;
|
|
34
|
+
/** A development session as the game-services clients read a server-backed session. */
|
|
35
|
+
export interface DevelopmentServerSession {
|
|
36
|
+
/** The development session; it is signed in from creation and never changes. */
|
|
37
|
+
current(): {
|
|
38
|
+
readonly playerId: string;
|
|
39
|
+
readonly sessionId: string;
|
|
40
|
+
};
|
|
41
|
+
/** Never calls `listener`: the session is current from creation. Returns the unsubscribe. */
|
|
42
|
+
onAuthenticated(listener: (session: {
|
|
43
|
+
readonly playerId: string;
|
|
44
|
+
readonly sessionId: string;
|
|
45
|
+
}) => void): () => void;
|
|
46
|
+
/** `{ authorization: 'Bearer mpgd-dev.…' }`. */
|
|
47
|
+
getHeaders(): Readonly<Record<'authorization', string>>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A server-backed session for local development against a Worker that has
|
|
51
|
+
* `MPGD_ALLOW_INSECURE_DEVELOPMENT_NOTIFICATION_SESSIONS = "true"`. `mode`
|
|
52
|
+
* must be the build's real mode (`import.meta.env.MODE`): `production`
|
|
53
|
+
* throws, so a production build cannot create one. A build in another mode
|
|
54
|
+
* named like production (`prod`, `release`) is not refused here; gate the
|
|
55
|
+
* call on `import.meta.env.DEV` as the starter does. `sessionId` defaults to
|
|
56
|
+
* `dev-<installationId>` (its first 64 characters). Ids are 1 to 64
|
|
57
|
+
* letters, digits, `_`, or `-`.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createDevelopmentServerSession(input: {
|
|
60
|
+
readonly mode: string;
|
|
61
|
+
readonly playerId: string;
|
|
62
|
+
readonly installationId: string;
|
|
63
|
+
readonly sessionId?: string;
|
|
64
|
+
}): DevelopmentServerSession;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { resolveGameServicesAuthorityMode } from './runtime.js';
|
|
2
|
+
/**
|
|
3
|
+
* Local development notification sessions: an unauthenticated stand-in for
|
|
4
|
+
* the game's identity service, so a developer can try the notifications
|
|
5
|
+
* routes of a local game-services Worker before writing one.
|
|
6
|
+
*
|
|
7
|
+
* The Worker accepts `Authorization: Bearer mpgd-dev.<playerId>.<sessionId>.<installationId>`
|
|
8
|
+
* only with `MPGD_ALLOW_INSECURE_DEVELOPMENT_NOTIFICATION_SESSIONS = "true"`,
|
|
9
|
+
* without a `GAME_SERVICES_NOTIFICATIONS_AUTH` binding, never with the D1
|
|
10
|
+
* store, and only on a request to a local host (loopback, `*.localhost`, or
|
|
11
|
+
* the Android emulator's `10.0.2.2`). Anyone can write such a header, so it is no authentication:
|
|
12
|
+
* never use it outside local development.
|
|
13
|
+
*/
|
|
14
|
+
/** The scheme and prefix of a development session's `Authorization` header. */
|
|
15
|
+
export const developmentNotificationSessionPrefix = 'Bearer mpgd-dev.';
|
|
16
|
+
/** Each id: 1 to 64 letters, digits, `_`, or `-` (so `.` can separate them). */
|
|
17
|
+
const idPattern = /^[A-Za-z0-9_-]{1,64}$/u;
|
|
18
|
+
/** Whether `value` can be one id of a development session. */
|
|
19
|
+
export function isDevelopmentNotificationSessionId(value) {
|
|
20
|
+
return typeof value === 'string' && idPattern.test(value);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The `Authorization` header value for a development session. Throws when
|
|
24
|
+
* an id is not 1 to 64 letters, digits, `_`, or `-`.
|
|
25
|
+
*/
|
|
26
|
+
export function formatDevelopmentNotificationSessionAuthorization(session) {
|
|
27
|
+
const { playerId, sessionId, installationId } = session;
|
|
28
|
+
if (!isDevelopmentNotificationSessionId(playerId)
|
|
29
|
+
|| !isDevelopmentNotificationSessionId(sessionId)
|
|
30
|
+
|| !isDevelopmentNotificationSessionId(installationId)) {
|
|
31
|
+
throw new Error('A development notification session needs a playerId, sessionId, and installationId '
|
|
32
|
+
+ 'of 1 to 64 letters, digits, _ or -.');
|
|
33
|
+
}
|
|
34
|
+
return `${developmentNotificationSessionPrefix}${playerId}.${sessionId}.${installationId}`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The session a development `Authorization` header names, or `undefined`
|
|
38
|
+
* for any other value: exactly `Bearer mpgd-dev.` and three ids separated
|
|
39
|
+
* by `.`, each 1 to 64 letters, digits, `_`, or `-`. It has no `expiresAt`.
|
|
40
|
+
*/
|
|
41
|
+
export function parseDevelopmentNotificationSessionAuthorization(authorization) {
|
|
42
|
+
if (typeof authorization !== 'string' || !authorization.startsWith(developmentNotificationSessionPrefix)) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const parts = authorization.slice(developmentNotificationSessionPrefix.length).split('.');
|
|
46
|
+
if (parts.length !== 3 || !parts.every(isDevelopmentNotificationSessionId)) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const [playerId, sessionId, installationId] = parts;
|
|
50
|
+
return { playerId, sessionId, installationId };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A server-backed session for local development against a Worker that has
|
|
54
|
+
* `MPGD_ALLOW_INSECURE_DEVELOPMENT_NOTIFICATION_SESSIONS = "true"`. `mode`
|
|
55
|
+
* must be the build's real mode (`import.meta.env.MODE`): `production`
|
|
56
|
+
* throws, so a production build cannot create one. A build in another mode
|
|
57
|
+
* named like production (`prod`, `release`) is not refused here; gate the
|
|
58
|
+
* call on `import.meta.env.DEV` as the starter does. `sessionId` defaults to
|
|
59
|
+
* `dev-<installationId>` (its first 64 characters). Ids are 1 to 64
|
|
60
|
+
* letters, digits, `_`, or `-`.
|
|
61
|
+
*/
|
|
62
|
+
export function createDevelopmentServerSession(input) {
|
|
63
|
+
if (resolveGameServicesAuthorityMode(input.mode) === 'production') {
|
|
64
|
+
throw new Error('Development notification sessions are refused in a production build.');
|
|
65
|
+
}
|
|
66
|
+
const session = {
|
|
67
|
+
playerId: input.playerId,
|
|
68
|
+
// Cut to the id limit, so a 61- to 64-character installation id still has a default.
|
|
69
|
+
sessionId: input.sessionId ?? `dev-${input.installationId}`.slice(0, 64),
|
|
70
|
+
installationId: input.installationId,
|
|
71
|
+
};
|
|
72
|
+
const authorization = formatDevelopmentNotificationSessionAuthorization(session);
|
|
73
|
+
const current = Object.freeze({ playerId: session.playerId, sessionId: session.sessionId });
|
|
74
|
+
return {
|
|
75
|
+
current: () => current,
|
|
76
|
+
onAuthenticated: () => () => undefined,
|
|
77
|
+
getHeaders: () => ({ authorization }),
|
|
78
|
+
};
|
|
79
|
+
}
|
package/dist/guest-session.d.ts
CHANGED
|
@@ -20,13 +20,36 @@ export interface ServerGuestSessionCredentials {
|
|
|
20
20
|
readonly serverUserId: string;
|
|
21
21
|
/** May rotate on refresh or binding; serverUserId remains the ownership key. */
|
|
22
22
|
readonly sessionId: string;
|
|
23
|
+
/**
|
|
24
|
+
* The stable session-family id: one value for the session's whole life,
|
|
25
|
+
* across every refresh, which a new guest (and any new sign-in) replaces.
|
|
26
|
+
* It is the `sessionId` the identity service answers to the notifications
|
|
27
|
+
* Worker's `authenticateNotificationSession`. Optional on the wire (`null`
|
|
28
|
+
* reads as absent): when a backend omits it, the coordinator derives one
|
|
29
|
+
* (see `createGuestSessionCoordinator`). A backend that omits it and starts
|
|
30
|
+
* a new family at an account binding must give that binding a new
|
|
31
|
+
* `sessionId`.
|
|
32
|
+
*/
|
|
33
|
+
readonly sessionFamilyId?: string;
|
|
23
34
|
readonly identityLevel: 'guest' | 'authenticated';
|
|
24
35
|
readonly accessToken: string;
|
|
25
36
|
readonly refreshToken: string;
|
|
26
37
|
readonly accessExpiresAt: string;
|
|
27
38
|
}
|
|
28
39
|
/** Identity view: neither bearer token is returned to game code. */
|
|
29
|
-
export
|
|
40
|
+
export interface ServerGuestSessionView {
|
|
41
|
+
readonly serverUserId: string;
|
|
42
|
+
/** May rotate on refresh or binding. */
|
|
43
|
+
readonly sessionId: string;
|
|
44
|
+
/**
|
|
45
|
+
* The stable session-family id: the backend's, or the one the coordinator
|
|
46
|
+
* derived when the backend sent none. Use it, not `sessionId`, wherever a
|
|
47
|
+
* session must keep one id across refreshes (the notifications client).
|
|
48
|
+
*/
|
|
49
|
+
readonly sessionFamilyId: string;
|
|
50
|
+
readonly identityLevel: 'guest' | 'authenticated';
|
|
51
|
+
readonly accessExpiresAt: string;
|
|
52
|
+
}
|
|
30
53
|
export type AccountBindingOutcome = {
|
|
31
54
|
readonly status: 'bound' | 'already-bound';
|
|
32
55
|
readonly session: ServerGuestSessionCredentials;
|
|
@@ -61,6 +84,17 @@ export declare class GuestSessionCoordinatorError extends Error {
|
|
|
61
84
|
}
|
|
62
85
|
export interface GuestSessionCoordinator {
|
|
63
86
|
start(): Promise<ServerGuestSessionView>;
|
|
87
|
+
/** The active session's view, or `undefined` before `start()` settles, after a failure, and after logout. */
|
|
88
|
+
current(): ServerGuestSessionView | undefined;
|
|
89
|
+
/**
|
|
90
|
+
* Calls `listener` with the new view each time a session becomes active:
|
|
91
|
+
* `start()`, each `refresh()`, and a bound `bindAccount()`. A refresh in
|
|
92
|
+
* the same family repeats the family id. Returns the unsubscribe. A
|
|
93
|
+
* listener that throws does not fail the operation. Nothing is announced
|
|
94
|
+
* when a session stops being active (a failed save, logout): read
|
|
95
|
+
* `current()` for that.
|
|
96
|
+
*/
|
|
97
|
+
onSession(listener: (session: ServerGuestSessionView) => void): () => void;
|
|
64
98
|
refresh(): Promise<ServerGuestSessionView>;
|
|
65
99
|
bindAccount(input: {
|
|
66
100
|
readonly externalProof: string;
|
|
@@ -74,6 +108,28 @@ export interface GuestSessionCoordinator {
|
|
|
74
108
|
/**
|
|
75
109
|
* Serializes token mutation and closes synchronously on logout. Native secure
|
|
76
110
|
* storage failures never fall back to ordinary game storage or mint a new guest.
|
|
111
|
+
*
|
|
112
|
+
* The session-family id is the backend's `sessionFamilyId` whenever it sends
|
|
113
|
+
* one. A backend that omits it gets a derived id, which only ever changes in
|
|
114
|
+
* the safe direction (a new id where the backend's family might be new):
|
|
115
|
+
* - `issueGuest` starts a family at the new `sessionId`;
|
|
116
|
+
* - a refresh keeps the family, however the `sessionId` rotates;
|
|
117
|
+
* - a bound account keeps it while the `sessionId` stays, and starts one at
|
|
118
|
+
* the new `sessionId` when that changes (so such a backend must change
|
|
119
|
+
* the `sessionId` when a binding starts a new family);
|
|
120
|
+
* - a restore from secure storage keeps the family saved with the refresh
|
|
121
|
+
* token (`<credentialKey>.family`), or starts one at the restored
|
|
122
|
+
* `sessionId` when none can be read;
|
|
123
|
+
* - logout removes the saved family with the token.
|
|
124
|
+
* The derived id is not the identity service's id, and does not need to be: the
|
|
125
|
+
* notifications runtime only compares it with the id it registered under,
|
|
126
|
+
* and registers again when it changes. Saving the family is best effort:
|
|
127
|
+
* it is written before the token, so an interrupted save never pairs an
|
|
128
|
+
* older family with a newer token, and a family that cannot be saved is
|
|
129
|
+
* removed, so the next restore starts one. Only a store that fails both that
|
|
130
|
+
* save and that removal, and then saves the token, can leave an older family
|
|
131
|
+
* next to it; the token is saved regardless, since losing it would lose the
|
|
132
|
+
* guest.
|
|
77
133
|
*/
|
|
78
134
|
export declare function createGuestSessionCoordinator(input: {
|
|
79
135
|
readonly backend: GuestSessionBackend;
|
|
@@ -82,3 +138,55 @@ export declare function createGuestSessionCoordinator(input: {
|
|
|
82
138
|
readonly credentialKey?: string;
|
|
83
139
|
readonly now?: () => number;
|
|
84
140
|
}): GuestSessionCoordinator;
|
|
141
|
+
/**
|
|
142
|
+
* A guest session as the game-services clients read a server-backed
|
|
143
|
+
* session: the starter's `StarterServerSession`, and the notifications
|
|
144
|
+
* client's identity and header resolver.
|
|
145
|
+
*/
|
|
146
|
+
export interface GuestServerSession {
|
|
147
|
+
/**
|
|
148
|
+
* The active session, or `undefined` while none is: `playerId` is the
|
|
149
|
+
* server user, and `sessionId` is the session-family id, which stays the
|
|
150
|
+
* same across refreshes.
|
|
151
|
+
*/
|
|
152
|
+
current(): {
|
|
153
|
+
readonly playerId: string;
|
|
154
|
+
readonly sessionId: string;
|
|
155
|
+
} | undefined;
|
|
156
|
+
/**
|
|
157
|
+
* Calls `listener` when a session becomes current after the subscription:
|
|
158
|
+
* the first `start()`, or a binding that starts a new family. A refresh in
|
|
159
|
+
* the same family is not announced. Returns the unsubscribe.
|
|
160
|
+
*/
|
|
161
|
+
onAuthenticated(listener: (session: {
|
|
162
|
+
readonly playerId: string;
|
|
163
|
+
readonly sessionId: string;
|
|
164
|
+
}) => void): () => void;
|
|
165
|
+
/**
|
|
166
|
+
* The `Authorization` header. An expired access token is refreshed first,
|
|
167
|
+
* and a failed refresh throws its error (`GUEST_SESSION_BACKEND_FAILED`,
|
|
168
|
+
* `GUEST_SESSION_OWNERSHIP_CONFLICT`, ...). A token that expires within
|
|
169
|
+
* `refreshAheadMs` is answered at once while a refresh runs in the
|
|
170
|
+
* background: once per token, and at most once per
|
|
171
|
+
* `guestServerSessionRefreshAheadIntervalMs`, so a backend whose tokens
|
|
172
|
+
* live shorter than the lead is not refreshed on every request; a failed
|
|
173
|
+
* background refresh is retried when the token has expired.
|
|
174
|
+
*/
|
|
175
|
+
getHeaders(): Promise<Readonly<Record<'authorization', string>>>;
|
|
176
|
+
}
|
|
177
|
+
/** How long before its access token expires `createGuestServerSession` refreshes a session: one minute. */
|
|
178
|
+
export declare const defaultGuestServerSessionRefreshAheadMs = 60000;
|
|
179
|
+
/** The least time between two background refreshes of `createGuestServerSession`. */
|
|
180
|
+
export declare const guestServerSessionRefreshAheadIntervalMs = 10000;
|
|
181
|
+
/**
|
|
182
|
+
* Reads a `GuestSessionCoordinator` as a server-backed session, with the
|
|
183
|
+
* session-family id as the session id, which the notifications client and
|
|
184
|
+
* the Worker need (it must not change on refresh). The game still starts
|
|
185
|
+
* the coordinator, binds accounts, and logs out through it. If you gave
|
|
186
|
+
* `createGuestSessionCoordinator` a `now`, pass the same function here, so
|
|
187
|
+
* both read expiry on one clock.
|
|
188
|
+
*/
|
|
189
|
+
export declare function createGuestServerSession(coordinator: GuestSessionCoordinator, options?: {
|
|
190
|
+
readonly refreshAheadMs?: number;
|
|
191
|
+
readonly now?: () => number;
|
|
192
|
+
}): GuestServerSession;
|
package/dist/guest-session.js
CHANGED
|
@@ -11,6 +11,28 @@ const defaultCredentialKey = 'mpgd.guest.refresh';
|
|
|
11
11
|
/**
|
|
12
12
|
* Serializes token mutation and closes synchronously on logout. Native secure
|
|
13
13
|
* storage failures never fall back to ordinary game storage or mint a new guest.
|
|
14
|
+
*
|
|
15
|
+
* The session-family id is the backend's `sessionFamilyId` whenever it sends
|
|
16
|
+
* one. A backend that omits it gets a derived id, which only ever changes in
|
|
17
|
+
* the safe direction (a new id where the backend's family might be new):
|
|
18
|
+
* - `issueGuest` starts a family at the new `sessionId`;
|
|
19
|
+
* - a refresh keeps the family, however the `sessionId` rotates;
|
|
20
|
+
* - a bound account keeps it while the `sessionId` stays, and starts one at
|
|
21
|
+
* the new `sessionId` when that changes (so such a backend must change
|
|
22
|
+
* the `sessionId` when a binding starts a new family);
|
|
23
|
+
* - a restore from secure storage keeps the family saved with the refresh
|
|
24
|
+
* token (`<credentialKey>.family`), or starts one at the restored
|
|
25
|
+
* `sessionId` when none can be read;
|
|
26
|
+
* - logout removes the saved family with the token.
|
|
27
|
+
* The derived id is not the identity service's id, and does not need to be: the
|
|
28
|
+
* notifications runtime only compares it with the id it registered under,
|
|
29
|
+
* and registers again when it changes. Saving the family is best effort:
|
|
30
|
+
* it is written before the token, so an interrupted save never pairs an
|
|
31
|
+
* older family with a newer token, and a family that cannot be saved is
|
|
32
|
+
* removed, so the next restore starts one. Only a store that fails both that
|
|
33
|
+
* save and that removal, and then saves the token, can leave an older family
|
|
34
|
+
* next to it; the token is saved regardless, since losing it would lose the
|
|
35
|
+
* guest.
|
|
14
36
|
*/
|
|
15
37
|
export function createGuestSessionCoordinator(input) {
|
|
16
38
|
if (typeof input.installationId !== 'string' || input.installationId.trim() === '') {
|
|
@@ -20,8 +42,12 @@ export function createGuestSessionCoordinator(input) {
|
|
|
20
42
|
if (credentialKey.trim() === '') {
|
|
21
43
|
throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
|
|
22
44
|
}
|
|
45
|
+
const familyKey = `${credentialKey}.family`;
|
|
23
46
|
const now = input.now ?? Date.now;
|
|
24
47
|
let current;
|
|
48
|
+
/** The family id last written to secure storage, as far as this coordinator knows. */
|
|
49
|
+
let savedFamily;
|
|
50
|
+
const listeners = new Set();
|
|
25
51
|
let status = 'idle';
|
|
26
52
|
let tail = Promise.resolve();
|
|
27
53
|
let startInFlight;
|
|
@@ -65,7 +91,46 @@ export function createGuestSessionCoordinator(input) {
|
|
|
65
91
|
throw new GuestSessionCoordinatorError('GUEST_SESSION_BACKEND_FAILED');
|
|
66
92
|
}
|
|
67
93
|
}
|
|
94
|
+
/** The family saved with the stored refresh token, or `undefined` when none can be read. */
|
|
95
|
+
async function loadStoredFamily() {
|
|
96
|
+
try {
|
|
97
|
+
const stored = await input.credentials.load({ key: familyKey });
|
|
98
|
+
return typeof stored === 'string' && stored.trim() !== '' ? stored : undefined;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// A family that cannot be read starts a new one: at worst one more registration.
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Removes the saved family. A failed removal leaves the key: harmless at
|
|
107
|
+
* logout (no token is left to pair it with), and in `persist` the one case
|
|
108
|
+
* where an older family can stay next to a newer token.
|
|
109
|
+
*/
|
|
110
|
+
async function removeSavedFamily() {
|
|
111
|
+
savedFamily = undefined;
|
|
112
|
+
try {
|
|
113
|
+
await input.credentials.remove({ key: familyKey });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Nothing more can be done without the native store.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
68
119
|
async function persist(next) {
|
|
120
|
+
// The family goes before the token: a crash or a failure between the two
|
|
121
|
+
// writes leaves a newer family next to an older token (at worst one more
|
|
122
|
+
// registration after a restore), never an older family next to a newer
|
|
123
|
+
// token, which a restore would read for a family the backend may have
|
|
124
|
+
// replaced. A family that cannot be saved is removed for the same reason.
|
|
125
|
+
if (savedFamily !== next.sessionFamilyId) {
|
|
126
|
+
try {
|
|
127
|
+
await input.credentials.save({ key: familyKey, value: next.sessionFamilyId });
|
|
128
|
+
savedFamily = next.sessionFamilyId;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
await removeSavedFamily();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
69
134
|
try {
|
|
70
135
|
await input.credentials.save({ key: credentialKey, value: next.refreshToken });
|
|
71
136
|
}
|
|
@@ -83,6 +148,25 @@ export function createGuestSessionCoordinator(input) {
|
|
|
83
148
|
status = 'active';
|
|
84
149
|
}
|
|
85
150
|
}
|
|
151
|
+
/** Saves `next`, makes it the active session unless logout closed it, and announces it. */
|
|
152
|
+
async function activate(next) {
|
|
153
|
+
await persist(next);
|
|
154
|
+
assertOpen();
|
|
155
|
+
const active = view(next);
|
|
156
|
+
announce(active);
|
|
157
|
+
return active;
|
|
158
|
+
}
|
|
159
|
+
/** Tells the listeners, after an operation made `session` active. */
|
|
160
|
+
function announce(session) {
|
|
161
|
+
for (const listener of [...listeners]) {
|
|
162
|
+
try {
|
|
163
|
+
listener(session);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// A listener's failure is the game's; the session is active regardless.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
86
170
|
return {
|
|
87
171
|
async start() {
|
|
88
172
|
assertOpen();
|
|
@@ -98,12 +182,19 @@ export function createGuestSessionCoordinator(input) {
|
|
|
98
182
|
startInFlight = enqueue(async () => {
|
|
99
183
|
assertOpen();
|
|
100
184
|
const stored = await loadStoredRefreshToken();
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
185
|
+
let next;
|
|
186
|
+
if (stored === null) {
|
|
187
|
+
// A new guest is a new family; a family saved for an older token is
|
|
188
|
+
// not read. (Defensive: nothing has set savedFamily before a first start.)
|
|
189
|
+
savedFamily = undefined;
|
|
190
|
+
next = withFamily(validateSession(await fromBackend(() => input.backend.issueGuest({ installationId: input.installationId }))), undefined);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
const family = await loadStoredFamily();
|
|
194
|
+
savedFamily = family;
|
|
195
|
+
next = withFamily(validateSession(await fromBackend(() => input.backend.refresh({ refreshToken: stored }))), family);
|
|
196
|
+
}
|
|
197
|
+
return activate(next);
|
|
107
198
|
}).finally(() => { startInFlight = undefined; });
|
|
108
199
|
return startInFlight;
|
|
109
200
|
},
|
|
@@ -114,13 +205,11 @@ export function createGuestSessionCoordinator(input) {
|
|
|
114
205
|
}
|
|
115
206
|
refreshInFlight = enqueue(async () => {
|
|
116
207
|
const previous = requireCurrent();
|
|
117
|
-
const next = validateSession(await fromBackend(() => input.backend.refresh({
|
|
208
|
+
const next = withFamily(validateSession(await fromBackend(() => input.backend.refresh({
|
|
118
209
|
refreshToken: previous.refreshToken,
|
|
119
|
-
})));
|
|
210
|
+
}))), previous.sessionFamilyId);
|
|
120
211
|
assertSameOwner(previous, next);
|
|
121
|
-
|
|
122
|
-
assertOpen();
|
|
123
|
-
return view(next);
|
|
212
|
+
return activate(next);
|
|
124
213
|
}).finally(() => { refreshInFlight = undefined; });
|
|
125
214
|
return refreshInFlight;
|
|
126
215
|
},
|
|
@@ -147,16 +236,26 @@ export function createGuestSessionCoordinator(input) {
|
|
|
147
236
|
if (result.status !== 'bound' && result.status !== 'already-bound') {
|
|
148
237
|
throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_RESPONSE');
|
|
149
238
|
}
|
|
150
|
-
const
|
|
239
|
+
const bound = validateSession(result.session);
|
|
240
|
+
// The same session id is the same session; a new one may be a new family.
|
|
241
|
+
const next = withFamily(bound, bound.sessionId === previous.sessionId ? previous.sessionFamilyId : undefined);
|
|
151
242
|
assertSameOwner(previous, next);
|
|
152
243
|
if (next.identityLevel !== 'authenticated') {
|
|
153
244
|
throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_RESPONSE');
|
|
154
245
|
}
|
|
155
|
-
await
|
|
156
|
-
assertOpen();
|
|
246
|
+
await activate(next);
|
|
157
247
|
return { status: result.status };
|
|
158
248
|
});
|
|
159
249
|
},
|
|
250
|
+
current() {
|
|
251
|
+
return status === 'active' && current !== undefined ? view(current) : undefined;
|
|
252
|
+
},
|
|
253
|
+
onSession(listener) {
|
|
254
|
+
listeners.add(listener);
|
|
255
|
+
return () => {
|
|
256
|
+
listeners.delete(listener);
|
|
257
|
+
};
|
|
258
|
+
},
|
|
160
259
|
getHeaders() {
|
|
161
260
|
assertOpen();
|
|
162
261
|
const active = requireCurrent();
|
|
@@ -196,6 +295,8 @@ export function createGuestSessionCoordinator(input) {
|
|
|
196
295
|
catch {
|
|
197
296
|
throw new GuestSessionCoordinatorError('GUEST_SESSION_LOGOUT_UNCERTAIN');
|
|
198
297
|
}
|
|
298
|
+
// Without its token a saved family is never read (a new guest starts its own).
|
|
299
|
+
await removeSavedFamily();
|
|
199
300
|
current = undefined;
|
|
200
301
|
});
|
|
201
302
|
logoutInFlight = attempt;
|
|
@@ -211,16 +312,23 @@ function view(session) {
|
|
|
211
312
|
return {
|
|
212
313
|
serverUserId: session.serverUserId,
|
|
213
314
|
sessionId: session.sessionId,
|
|
315
|
+
sessionFamilyId: session.sessionFamilyId,
|
|
214
316
|
identityLevel: session.identityLevel,
|
|
215
317
|
accessExpiresAt: session.accessExpiresAt,
|
|
216
318
|
};
|
|
217
319
|
}
|
|
320
|
+
/** The backend's family id, else the family it continues, else a new one at its session id. */
|
|
321
|
+
function withFamily(session, continued) {
|
|
322
|
+
return { ...session, sessionFamilyId: session.sessionFamilyId ?? continued ?? session.sessionId };
|
|
323
|
+
}
|
|
218
324
|
// A server may already have consumed the previous refresh token. Persist a
|
|
219
325
|
// well-formed rotated token even when the device clock makes access look expired.
|
|
220
326
|
function validateSession(value) {
|
|
221
327
|
if (!isRecord(value)
|
|
222
328
|
|| typeof value.serverUserId !== 'string' || value.serverUserId.trim() === ''
|
|
223
329
|
|| typeof value.sessionId !== 'string' || value.sessionId.trim() === ''
|
|
330
|
+
|| (value.sessionFamilyId !== undefined && value.sessionFamilyId !== null
|
|
331
|
+
&& (typeof value.sessionFamilyId !== 'string' || value.sessionFamilyId.trim() === ''))
|
|
224
332
|
|| (value.identityLevel !== 'guest' && value.identityLevel !== 'authenticated')
|
|
225
333
|
|| typeof value.accessToken !== 'string' || value.accessToken.trim() === ''
|
|
226
334
|
|| typeof value.refreshToken !== 'string' || value.refreshToken.trim() === ''
|
|
@@ -231,6 +339,8 @@ function validateSession(value) {
|
|
|
231
339
|
return {
|
|
232
340
|
serverUserId: value.serverUserId,
|
|
233
341
|
sessionId: value.sessionId,
|
|
342
|
+
// `null` reads as absent, as many serializers write an omitted field.
|
|
343
|
+
...(typeof value.sessionFamilyId === 'string' ? { sessionFamilyId: value.sessionFamilyId } : {}),
|
|
234
344
|
identityLevel: value.identityLevel,
|
|
235
345
|
accessToken: value.accessToken,
|
|
236
346
|
refreshToken: value.refreshToken,
|
|
@@ -245,3 +355,69 @@ function assertSameOwner(previous, next) {
|
|
|
245
355
|
throw new GuestSessionCoordinatorError('GUEST_SESSION_OWNERSHIP_CONFLICT');
|
|
246
356
|
}
|
|
247
357
|
}
|
|
358
|
+
/** How long before its access token expires `createGuestServerSession` refreshes a session: one minute. */
|
|
359
|
+
export const defaultGuestServerSessionRefreshAheadMs = 60_000;
|
|
360
|
+
/** The least time between two background refreshes of `createGuestServerSession`. */
|
|
361
|
+
export const guestServerSessionRefreshAheadIntervalMs = 10_000;
|
|
362
|
+
/**
|
|
363
|
+
* Reads a `GuestSessionCoordinator` as a server-backed session, with the
|
|
364
|
+
* session-family id as the session id, which the notifications client and
|
|
365
|
+
* the Worker need (it must not change on refresh). The game still starts
|
|
366
|
+
* the coordinator, binds accounts, and logs out through it. If you gave
|
|
367
|
+
* `createGuestSessionCoordinator` a `now`, pass the same function here, so
|
|
368
|
+
* both read expiry on one clock.
|
|
369
|
+
*/
|
|
370
|
+
export function createGuestServerSession(coordinator, options = {}) {
|
|
371
|
+
const refreshAheadMs = options.refreshAheadMs ?? defaultGuestServerSessionRefreshAheadMs;
|
|
372
|
+
if (!Number.isFinite(refreshAheadMs) || refreshAheadMs < 0) {
|
|
373
|
+
throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
|
|
374
|
+
}
|
|
375
|
+
const now = options.now ?? Date.now;
|
|
376
|
+
/** The token (by its expiry) a background refresh last ran for, and when. */
|
|
377
|
+
let refreshedAheadFor;
|
|
378
|
+
let lastRefreshAheadAt;
|
|
379
|
+
/** The one mapping: the server user is the player, and the family id is the session id. */
|
|
380
|
+
const toSession = (active) => ({
|
|
381
|
+
playerId: active.serverUserId,
|
|
382
|
+
sessionId: active.sessionFamilyId,
|
|
383
|
+
});
|
|
384
|
+
const sessionOf = (active) => active === undefined ? undefined : toSession(active);
|
|
385
|
+
return {
|
|
386
|
+
current: () => sessionOf(coordinator.current()),
|
|
387
|
+
onAuthenticated(listener) {
|
|
388
|
+
// The session current at subscription is not a new one; its refreshes are not either.
|
|
389
|
+
let announced = sessionOf(coordinator.current());
|
|
390
|
+
return coordinator.onSession((active) => {
|
|
391
|
+
const next = toSession(active);
|
|
392
|
+
if (announced?.playerId === next.playerId && announced.sessionId === next.sessionId) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
announced = next;
|
|
396
|
+
listener(next);
|
|
397
|
+
});
|
|
398
|
+
},
|
|
399
|
+
async getHeaders() {
|
|
400
|
+
const active = coordinator.current();
|
|
401
|
+
if (active !== undefined) {
|
|
402
|
+
const expiresAt = Date.parse(active.accessExpiresAt);
|
|
403
|
+
const at = now();
|
|
404
|
+
if (expiresAt <= at) {
|
|
405
|
+
// Concurrent callers share the coordinator's one refresh.
|
|
406
|
+
await coordinator.refresh();
|
|
407
|
+
}
|
|
408
|
+
else if (expiresAt - refreshAheadMs <= at
|
|
409
|
+
&& refreshedAheadFor !== active.accessExpiresAt
|
|
410
|
+
// A clock that went back counts as the interval passed.
|
|
411
|
+
&& (lastRefreshAheadAt === undefined
|
|
412
|
+
|| at < lastRefreshAheadAt
|
|
413
|
+
|| at - lastRefreshAheadAt >= guestServerSessionRefreshAheadIntervalMs)) {
|
|
414
|
+
refreshedAheadFor = active.accessExpiresAt;
|
|
415
|
+
lastRefreshAheadAt = at;
|
|
416
|
+
// The token is still valid: answer it now; a failure is retried once it expires.
|
|
417
|
+
coordinator.refresh().catch(() => undefined);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return coordinator.getHeaders();
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from './commerce-evidence-registry.js';
|
|
|
10
10
|
export * from './commerce-ledger.js';
|
|
11
11
|
export * from './commerce-reconciler.js';
|
|
12
12
|
export * from './default-client-reward-evidence.js';
|
|
13
|
+
export * from './development-session.js';
|
|
13
14
|
export * from './admob-client-reward.js';
|
|
14
15
|
export * from './contract.js';
|
|
15
16
|
export * from './evidence-verification.js';
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export * from './commerce-evidence-registry.js';
|
|
|
10
10
|
export * from './commerce-ledger.js';
|
|
11
11
|
export * from './commerce-reconciler.js';
|
|
12
12
|
export * from './default-client-reward-evidence.js';
|
|
13
|
+
export * from './development-session.js';
|
|
13
14
|
export * from './admob-client-reward.js';
|
|
14
15
|
export * from './contract.js';
|
|
15
16
|
export * from './evidence-verification.js';
|
|
@@ -27,9 +27,10 @@ export interface GameServicesNotificationSession {
|
|
|
27
27
|
* answers to the Worker's `authenticateNotificationSession` for this
|
|
28
28
|
* session's header, the same across token refreshes. A new sign-in starts
|
|
29
29
|
* a new one. A per-token or rotating id is not it (a
|
|
30
|
-
* `GuestSessionCoordinator` view's `sessionId` may rotate on refresh
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* `GuestSessionCoordinator` view's `sessionId` may rotate on refresh; its
|
|
31
|
+
* `sessionFamilyId` is the id, and `createGuestServerSession` passes it);
|
|
32
|
+
* with one, each rotation counts as a new session and sends the
|
|
33
|
+
* registration again.
|
|
33
34
|
*/
|
|
34
35
|
readonly sessionId: string;
|
|
35
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/game-services",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -219,9 +219,9 @@
|
|
|
219
219
|
"@orpc/client": "2.0.0-beta.14",
|
|
220
220
|
"@orpc/contract": "2.0.0-beta.14",
|
|
221
221
|
"@orpc/server": "2.0.0-beta.14",
|
|
222
|
-
"@mpgd/analytics": "0.
|
|
223
|
-
"@mpgd/
|
|
224
|
-
"@mpgd/
|
|
222
|
+
"@mpgd/analytics": "0.5.0",
|
|
223
|
+
"@mpgd/catalog": "0.10.7",
|
|
224
|
+
"@mpgd/platform": "0.27.2"
|
|
225
225
|
},
|
|
226
226
|
"peerDependencies": {
|
|
227
227
|
"@apple/app-store-server-library": "^3.1.0"
|
|
@@ -250,7 +250,7 @@
|
|
|
250
250
|
"fix": "ttsc fix",
|
|
251
251
|
"build": "cd ../.. && node tools/run-ttsx.mjs tools/package/build-packages.ts @mpgd/game-services",
|
|
252
252
|
"test:packed-transport": "pnpm build && node test/runtime-transport-package-import.mjs && node test/reward-evidence-import.mjs",
|
|
253
|
-
"test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-publisher-client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/service.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/service-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/client-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/scheduler.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/fcm.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/store-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/ait-functional-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-reward-evidence.test.ts && node tools/run-ttsx.mjs packages/game-services/src/ad-reward-verifier-registry.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-reward.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-routing.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-ledger-revocation.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-anonymous-auth.test.ts",
|
|
253
|
+
"test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-publisher-client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/service.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/service-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/client-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/scheduler.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/fcm.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/store-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/ait-functional-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts && node tools/run-ttsx.mjs packages/game-services/src/development-session.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-reward-evidence.test.ts && node tools/run-ttsx.mjs packages/game-services/src/ad-reward-verifier-registry.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-reward.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-routing.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-ledger-revocation.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-anonymous-auth.test.ts",
|
|
254
254
|
"test:recovery": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/monetization-recovery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-recovery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-reconciler.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-reconciler-conformance.test.ts",
|
|
255
255
|
"test": "pnpm test:unit && pnpm test:app-store-server && pnpm test:recovery && pnpm test:packed-transport",
|
|
256
256
|
"test:app-store-server": "vitest run"
|