@mpgd/game-services 0.27.1 → 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 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
+ }
@@ -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 type ServerGuestSessionView = Omit<ServerGuestSessionCredentials, 'accessToken' | 'refreshToken'>;
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;
@@ -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
- const next = validateSession(stored === null
102
- ? await fromBackend(() => input.backend.issueGuest({ installationId: input.installationId }))
103
- : await fromBackend(() => input.backend.refresh({ refreshToken: stored })));
104
- await persist(next);
105
- assertOpen();
106
- return view(next);
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
- await persist(next);
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 next = validateSession(result.session);
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 persist(next);
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';
@@ -133,7 +133,15 @@ export interface CreateAitFunctionalMessageDeliveryProviderOptions {
133
133
  readonly timeoutMs?: number;
134
134
  /** `FAIL` error codes that mean the recipient has not agreed to the template. */
135
135
  readonly agreementErrorCodes?: readonly string[];
136
- /** `reachedFailReason` texts (trimmed) that mean the recipient has not agreed to the template. */
136
+ /**
137
+ * `reachedFailReason` texts (trimmed) that mean the recipient has not
138
+ * agreed to the template. Each is 1 to 1,024 characters (UTF-16 code
139
+ * units) once trimmed: the provider reads at most the first 1,024
140
+ * characters of a `reachedFailReason` and then trims it, so a longer entry
141
+ * could never match, and an empty one would match a blank reason. The
142
+ * provider refuses either at creation. A received reason with leading
143
+ * whitespace loses as much of its end to that cut.
144
+ */
137
145
  readonly agreementFailReasons?: readonly string[];
138
146
  /** Further `FAIL` error codes that mean this recipient cannot receive the message. */
139
147
  readonly recipientErrorCodes?: readonly string[];
@@ -78,6 +78,8 @@ const channelFields = {
78
78
  friendtalk: 'sentFriendtalk',
79
79
  };
80
80
  const maximumResponseBodyBytes = 256 * 1_024;
81
+ /** The longest `reachedFailReason` the provider keeps; a longer one is cut to this. */
82
+ const maximumFailReasonLength = 1_024;
81
83
  const maximumIdentifierLength = 2_048;
82
84
  const maximumTimeoutMs = 60_000;
83
85
  /**
@@ -126,7 +128,7 @@ export function createAitFunctionalMessageDeliveryProvider(options) {
126
128
  const timeoutMs = normalizeTimeout(options.timeoutMs);
127
129
  const agreementErrorCodes = new Set(options.agreementErrorCodes ?? []);
128
130
  const recipientErrorCodes = new Set(options.recipientErrorCodes ?? []);
129
- const agreementFailReasons = new Set((options.agreementFailReasons ?? []).map((reason) => reason.trim()));
131
+ const agreementFailReasons = new Set((options.agreementFailReasons ?? []).map((reason, index) => readAgreementFailReason(reason, index)));
130
132
  // Documented codes come first (RFC "Delivery providers -> Apps in Toss"),
131
133
  // then the deployment's. A list must not turn 4095 into an agreement denial.
132
134
  const classifyFailCode = (code, status) => {
@@ -224,6 +226,14 @@ export function createAitFunctionalMessageDeliveryProvider(options) {
224
226
  },
225
227
  };
226
228
  }
229
+ /** One `agreementFailReasons` entry, trimmed, or a `TypeError` for one that could never match. */
230
+ function readAgreementFailReason(reason, index) {
231
+ const trimmed = typeof reason === 'string' ? reason.trim() : '';
232
+ if (trimmed.length === 0 || trimmed.length > maximumFailReasonLength) {
233
+ throw new TypeError(`agreementFailReasons[${index}]: entries must be 1 to ${maximumFailReasonLength} characters once trimmed: a reachedFailReason is read up to ${maximumFailReasonLength} characters, so a longer entry could never match and an empty one would match a blank reason.`);
234
+ }
235
+ return trimmed;
236
+ }
227
237
  function readSuccess(success, agreementFailReasons) {
228
238
  if (!isRecord(success)) {
229
239
  return { status: 'ambiguous', providerCode: 'SUCCESS' };
@@ -274,7 +284,9 @@ function readContents(input) {
274
284
  const reason = item['reachedFailReason'];
275
285
  contents.push({
276
286
  contentId: item['contentId'].slice(0, 256),
277
- ...(typeof reason === 'string' ? { reachedFailReason: reason.slice(0, 1_024) } : {}),
287
+ ...(typeof reason === 'string'
288
+ ? { reachedFailReason: reason.slice(0, maximumFailReasonLength) }
289
+ : {}),
278
290
  });
279
291
  }
280
292
  return contents;
@@ -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); with
31
- * one, each rotation counts as a new session and sends the registration
32
- * again.
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.27.1",
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.4.15",
223
- "@mpgd/platform": "0.27.1",
224
- "@mpgd/catalog": "0.10.6"
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"