@mpgd/game-services 0.15.3 → 0.16.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
@@ -71,9 +71,91 @@ and retry policy are not redesigned by this observer API.
71
71
  The root `pnpm test` pipeline includes compiled client JS and public declaration
72
72
  consumption checks alongside the client and target conformance suites.
73
73
 
74
+ ## Runtime backend transport
75
+
76
+ `createGameServicesRuntime` accepts `httpTransport` for its HTTP JSON backend
77
+ path. This is a `GameServicesBackendTransport` with fixed Game Services
78
+ endpoints, not a general `fetch` implementation. It is never used for the
79
+ `orpc` path. A production runtime still requires a valid public HTTPS
80
+ `baseUrl`; supplying a custom transport does not enable a local backend or
81
+ silently fall back to the default network path.
82
+
83
+ ```ts
84
+ import { createGameServicesRuntime } from '@mpgd/game-services/runtime';
85
+ import type { GameServicesBackendTransport } from '@mpgd/game-services/client';
86
+
87
+ declare const gateway: Parameters<typeof createGameServicesRuntime>[0]['gateway'];
88
+ declare const nativeJsonTransport: GameServicesBackendTransport;
89
+ declare const currentAccessToken: () => string;
90
+
91
+ const runtime = createGameServicesRuntime({
92
+ gateway,
93
+ playerId: 'game-player',
94
+ authorityMode: 'production',
95
+ baseUrl: 'https://api.example.com',
96
+ transport: 'http',
97
+ httpTransport: nativeJsonTransport,
98
+ getHeaders: () => ({ authorization: `Bearer ${currentAccessToken()}` }),
99
+ });
100
+ ```
101
+
102
+ `headers` remains available for static values. `getHeaders` is evaluated for
103
+ every backend request, including default HTTP and oRPC requests, so session
104
+ rotation does not retain an old token. An injected HTTP transport receives
105
+ those headers only with a Game Services endpoint request and must itself
106
+ enforce its configured HTTPS origin, redirect policy, and response limits;
107
+ the runtime cannot inspect a transport's internal network destinations.
108
+ Static headers are overridden by refreshed headers, then by explicit
109
+ per-request headers, with names compared case-insensitively. If `getHeaders`
110
+ fails, the runtime throws a sanitized
111
+ `GameServicesHeaderResolutionError` **before** sending the request; this is
112
+ distinct from an uncertain network outcome.
113
+ Non-2xx HTTP transport responses still become `GameServicesBackendError` and
114
+ are not retried through default fetch. Invalid responses and thrown native
115
+ transport errors become `GameServicesBackendTransportError` without embedding
116
+ the original exception, which might contain credentials. A failed request can
117
+ still have completed on the server: reconcile it before retrying a purchase
118
+ or reward claim. Do not forward these headers to an unrelated origin or copy
119
+ them into analytics or logs.
120
+
74
121
  All published entrypoints use explicit internal ESM module paths and are smoke-tested
75
122
  with native Node imports, without a bundler or TypeScript runtime loader.
76
123
 
124
+ ## Guest session and account binding foundation
125
+
126
+ `createGuestSessionCoordinator` from `@mpgd/game-services/guest-session`
127
+ accepts a game-owned `GuestSessionBackend` and a dedicated
128
+ `SecureCredentialStore`. The kit does **not** issue tokens or infer server
129
+ authentication from `installationId` or a local `playerId`. The backend must
130
+ authenticate opaque refresh tokens, verify external account proofs, atomically
131
+ deduplicate account binding by idempotency key, return `conflict` for an account
132
+ owned by another server user, and revoke sessions durably. It must define
133
+ token rotation, expiry, replay response, idempotent revocation, and database
134
+ transactions. The session ID may rotate during refresh or binding, but the
135
+ server user ID must remain the same; the
136
+ injected contract is not a production identity provider by itself.
137
+
138
+ The coordinator loads and saves the refresh token only through secure native
139
+ credential storage. A load failure never silently starts a new guest, and a
140
+ failed save cannot expose a new access token as active. Concurrent refreshes
141
+ share one backend call; logout closes header access immediately and waits for
142
+ in-flight token changes before revoking the latest token and removing the
143
+ credential. Failed revocation or native removal leaves the credential intact
144
+ and allows a later logout retry; callers must treat an uncertain logout as a
145
+ server-side session that may still be live. Backend and native load errors are
146
+ normalized to token-free coordinator codes, not forwarded with raw messages.
147
+ `getHeaders()` can be passed as the Game Services runtime's
148
+ `getHeaders` resolver. The public session view omits both bearer tokens.
149
+
150
+ Binding an external account never switches to a different server user on a
151
+ client callback. It does not merge purchases, currency, or progress. Use the
152
+ existing `progress-link` nonce/idempotency service separately for a
153
+ server-verified guest-progress handoff. A platform player ID and store purchase
154
+ binding remain distinct from the server user and external provider subject.
155
+ The tests use a fake backend and secure-storage port; they do not prove an
156
+ OAuth provider, durable session database, or device Keychain/Keystore is
157
+ correctly configured.
158
+
77
159
  Headless consumers can import operation input/result/progress types and the two-method
78
160
  `GameServicesOperationClient` port from `@mpgd/game-services/operations`. This entrypoint
79
161
  has no runtime implementation and its declarations require no DOM or fetch globals.
package/dist/client.d.ts CHANGED
@@ -30,6 +30,8 @@ export interface GameServicesBackendTransportRequest<TBody = unknown> {
30
30
  readonly method: 'POST';
31
31
  readonly endpoint: GameServicesBackendEndpoint;
32
32
  readonly body: TBody;
33
+ /** Resolved for this request only; custom transports must not forward elsewhere. */
34
+ readonly headers?: Readonly<Record<string, string>>;
33
35
  }
34
36
  export interface GameServicesBackendTransportResponse<TBody = unknown> {
35
37
  readonly status: number;
@@ -45,12 +47,15 @@ export interface CreateGameServicesFetchBackendTransportInput {
45
47
  readonly baseUrl: string;
46
48
  readonly fetch?: GameServicesFetch;
47
49
  readonly headers?: Record<string, string>;
50
+ readonly getHeaders?: GameServicesHeaderResolver;
48
51
  }
49
52
  export interface CreateGameServicesOrpcClientInput {
50
53
  readonly url: string;
51
54
  readonly fetch?: typeof fetch;
52
55
  readonly headers?: Record<string, string>;
56
+ readonly getHeaders?: GameServicesHeaderResolver;
53
57
  }
58
+ export type GameServicesHeaderResolver = () => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>;
54
59
  export type GameServicesFetch = (url: string, init: {
55
60
  readonly method: 'POST';
56
61
  readonly headers: Record<string, string>;
@@ -67,6 +72,20 @@ export declare class GameServicesBackendError extends Error {
67
72
  readonly body: unknown;
68
73
  constructor(endpoint: GameServicesBackendEndpoint, status: number, body: unknown);
69
74
  }
75
+ export declare class GameServicesBackendTransportError extends Error {
76
+ readonly endpoint: GameServicesBackendEndpoint;
77
+ constructor(endpoint: GameServicesBackendEndpoint);
78
+ }
79
+ /** Credentials could not be resolved; no backend request was dispatched. */
80
+ export declare class GameServicesHeaderResolutionError extends Error {
81
+ constructor();
82
+ }
83
+ /** Resolve rotating credentials once and preserve static → dynamic → request precedence. */
84
+ export declare function resolveGameServicesRequestHeaders(input: {
85
+ readonly headers?: Readonly<Record<string, string>> | undefined;
86
+ readonly getHeaders?: GameServicesHeaderResolver | undefined;
87
+ readonly requestHeaders?: Readonly<Record<string, string>> | undefined;
88
+ }): Promise<Readonly<Record<string, string>>>;
70
89
  export interface GameServicesClient extends GameServicesOperationClient {
71
90
  submitLeaderboardScore(input: GameServicesLeaderboardInput): Promise<GameServicesLeaderboardResult>;
72
91
  }
package/dist/client.js CHANGED
@@ -19,6 +19,39 @@ export class GameServicesBackendError extends Error {
19
19
  this.body = body;
20
20
  }
21
21
  }
22
+ export class GameServicesBackendTransportError extends Error {
23
+ endpoint;
24
+ constructor(endpoint) {
25
+ super(`GameServices backend transport failed: ${endpoint}`);
26
+ this.name = 'GameServicesBackendTransportError';
27
+ this.endpoint = endpoint;
28
+ }
29
+ }
30
+ /** Credentials could not be resolved; no backend request was dispatched. */
31
+ export class GameServicesHeaderResolutionError extends Error {
32
+ constructor() {
33
+ super('Game Services request headers could not be resolved before dispatch.');
34
+ this.name = 'GameServicesHeaderResolutionError';
35
+ }
36
+ }
37
+ /** Resolve rotating credentials once and preserve static → dynamic → request precedence. */
38
+ export async function resolveGameServicesRequestHeaders(input) {
39
+ let dynamicHeaders;
40
+ try {
41
+ dynamicHeaders = await input.getHeaders?.();
42
+ }
43
+ catch {
44
+ // Resolver exceptions can contain credentials; expose a stable outcome.
45
+ throw new GameServicesHeaderResolutionError();
46
+ }
47
+ const merged = new Map();
48
+ for (const layer of [input.headers, dynamicHeaders, input.requestHeaders]) {
49
+ for (const [name, value] of Object.entries(layer ?? {})) {
50
+ merged.set(name.toLowerCase(), value);
51
+ }
52
+ }
53
+ return Object.fromEntries(merged);
54
+ }
22
55
  export function createGameServicesClient(input) {
23
56
  const now = input.now ?? (() => new Date().toISOString());
24
57
  const analytics = createAnalyticsReporter({
@@ -297,10 +330,15 @@ export function createGameServicesFetchBackendTransport(input) {
297
330
  const fetcher = input.fetch ?? readGlobalFetch();
298
331
  return {
299
332
  async send(request) {
333
+ const headers = await resolveGameServicesRequestHeaders({
334
+ headers: input.headers,
335
+ getHeaders: input.getHeaders,
336
+ requestHeaders: request.headers,
337
+ });
300
338
  const response = await fetcher(joinUrl(input.baseUrl, request.endpoint), {
301
339
  method: request.method,
302
340
  headers: {
303
- ...(input.headers ?? {}),
341
+ ...headers,
304
342
  'content-type': 'application/json',
305
343
  },
306
344
  body: JSON.stringify(request.body),
@@ -316,10 +354,10 @@ export function createGameServicesOrpcClient(input) {
316
354
  const link = new RPCLink({
317
355
  origin: input.url,
318
356
  ...(input.fetch === undefined ? {} : { fetch: input.fetch }),
319
- ...(input.headers === undefined
357
+ ...(input.headers === undefined && input.getHeaders === undefined
320
358
  ? {}
321
359
  : {
322
- headers: () => input.headers,
360
+ headers: () => resolveGameServicesRequestHeaders(input),
323
361
  }),
324
362
  });
325
363
  return createORPCClient(link);
@@ -389,11 +427,26 @@ function isGameServicesLeaderboardTarget(target) {
389
427
  || target === 'reddit';
390
428
  }
391
429
  async function sendGameServicesBackendRequest(transport, endpoint, body) {
392
- const response = await transport.send({
393
- method: 'POST',
394
- endpoint,
395
- body,
396
- });
430
+ let response;
431
+ try {
432
+ response = await transport.send({
433
+ method: 'POST',
434
+ endpoint,
435
+ body,
436
+ });
437
+ }
438
+ catch (error) {
439
+ if (error instanceof GameServicesHeaderResolutionError) {
440
+ throw error;
441
+ }
442
+ // Do not leak SDK or native transport exceptions (which can contain
443
+ // request headers); the caller must reconcile uncertain server outcomes.
444
+ throw new GameServicesBackendTransportError(endpoint);
445
+ }
446
+ if (!Number.isInteger(response?.status) || response.status < 100 || response.status > 599
447
+ || !Object.hasOwn(response, 'body')) {
448
+ throw new GameServicesBackendTransportError(endpoint);
449
+ }
397
450
  if (response.status < 200 || response.status >= 300) {
398
451
  throw new GameServicesBackendError(endpoint, response.status, response.body);
399
452
  }
@@ -0,0 +1,84 @@
1
+ import type { SecureCredentialStore } from '@mpgd/platform';
2
+ /** These identifiers are deliberately not interchangeable authentication claims. */
3
+ export interface GuestInstallationIdentity {
4
+ readonly installationId: string;
5
+ }
6
+ export interface VerifiedExternalAccount {
7
+ readonly provider: string;
8
+ readonly subject: string;
9
+ }
10
+ export interface NativeGamePlayerIdentity {
11
+ readonly platform: 'game-center' | 'play-games';
12
+ readonly platformPlayerId: string;
13
+ }
14
+ export interface StorePurchaseAccountBinding {
15
+ readonly store: 'app-store' | 'google-play';
16
+ readonly bindingId: string;
17
+ }
18
+ /** Only a trusted server may issue this opaque-token response. */
19
+ export interface ServerGuestSessionCredentials {
20
+ readonly serverUserId: string;
21
+ /** May rotate on refresh or binding; serverUserId remains the ownership key. */
22
+ readonly sessionId: string;
23
+ readonly identityLevel: 'guest' | 'authenticated';
24
+ readonly accessToken: string;
25
+ readonly refreshToken: string;
26
+ readonly accessExpiresAt: string;
27
+ }
28
+ /** Identity view: neither bearer token is returned to game code. */
29
+ export type ServerGuestSessionView = Omit<ServerGuestSessionCredentials, 'accessToken' | 'refreshToken'>;
30
+ export type AccountBindingOutcome = {
31
+ readonly status: 'bound' | 'already-bound';
32
+ readonly session: ServerGuestSessionCredentials;
33
+ } | {
34
+ readonly status: 'conflict';
35
+ };
36
+ /**
37
+ * Implement on the game backend. Refresh/revoke must authenticate the opaque
38
+ * refresh token, and bindAccount must verify both it and the external proof,
39
+ * atomically deduplicate by idempotencyKey, and return conflict for an account
40
+ * already owned by another server user. An installationId is never authority.
41
+ */
42
+ export interface GuestSessionBackend {
43
+ issueGuest(input: GuestInstallationIdentity): Promise<ServerGuestSessionCredentials>;
44
+ refresh(input: {
45
+ readonly refreshToken: string;
46
+ }): Promise<ServerGuestSessionCredentials>;
47
+ revoke(input: {
48
+ readonly refreshToken: string;
49
+ }): Promise<void>;
50
+ bindAccount(input: {
51
+ readonly refreshToken: string;
52
+ readonly externalProof: string;
53
+ readonly idempotencyKey: string;
54
+ }): Promise<AccountBindingOutcome>;
55
+ }
56
+ export type GuestSessionCoordinatorErrorCode = 'GUEST_SESSION_CLOSED' | 'GUEST_SESSION_NOT_READY' | 'GUEST_SESSION_INVALID_INPUT' | 'GUEST_SESSION_INVALID_RESPONSE' | 'GUEST_SESSION_OWNERSHIP_CONFLICT' | 'GUEST_SESSION_BACKEND_FAILED' | 'GUEST_SESSION_CREDENTIAL_LOAD_FAILED' | 'GUEST_SESSION_CREDENTIAL_SAVE_FAILED' | 'GUEST_SESSION_LOGOUT_UNCERTAIN';
57
+ /** Error messages intentionally contain no token, proof, or installation ID. */
58
+ export declare class GuestSessionCoordinatorError extends Error {
59
+ readonly code: GuestSessionCoordinatorErrorCode;
60
+ constructor(code: GuestSessionCoordinatorErrorCode);
61
+ }
62
+ export interface GuestSessionCoordinator {
63
+ start(): Promise<ServerGuestSessionView>;
64
+ refresh(): Promise<ServerGuestSessionView>;
65
+ bindAccount(input: {
66
+ readonly externalProof: string;
67
+ readonly idempotencyKey: string;
68
+ }): Promise<{
69
+ readonly status: 'bound' | 'already-bound' | 'conflict';
70
+ }>;
71
+ getHeaders(): Readonly<Record<'authorization', string>>;
72
+ logout(): Promise<void>;
73
+ }
74
+ /**
75
+ * Serializes token mutation and closes synchronously on logout. Native secure
76
+ * storage failures never fall back to ordinary game storage or mint a new guest.
77
+ */
78
+ export declare function createGuestSessionCoordinator(input: {
79
+ readonly backend: GuestSessionBackend;
80
+ readonly credentials: SecureCredentialStore;
81
+ readonly installationId: string;
82
+ readonly credentialKey?: string;
83
+ readonly now?: () => number;
84
+ }): GuestSessionCoordinator;
@@ -0,0 +1,247 @@
1
+ /** Error messages intentionally contain no token, proof, or installation ID. */
2
+ export class GuestSessionCoordinatorError extends Error {
3
+ code;
4
+ constructor(code) {
5
+ super(`Guest session operation failed: ${code}.`);
6
+ this.name = 'GuestSessionCoordinatorError';
7
+ this.code = code;
8
+ }
9
+ }
10
+ const defaultCredentialKey = 'mpgd.guest.refresh';
11
+ /**
12
+ * Serializes token mutation and closes synchronously on logout. Native secure
13
+ * storage failures never fall back to ordinary game storage or mint a new guest.
14
+ */
15
+ export function createGuestSessionCoordinator(input) {
16
+ if (typeof input.installationId !== 'string' || input.installationId.trim() === '') {
17
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
18
+ }
19
+ const credentialKey = input.credentialKey ?? defaultCredentialKey;
20
+ if (credentialKey.trim() === '') {
21
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
22
+ }
23
+ const now = input.now ?? Date.now;
24
+ let current;
25
+ let status = 'idle';
26
+ let tail = Promise.resolve();
27
+ let startInFlight;
28
+ let refreshInFlight;
29
+ let logoutInFlight;
30
+ function enqueue(operation) {
31
+ const next = tail.then(operation, operation);
32
+ tail = next.then(() => undefined, () => undefined);
33
+ return next;
34
+ }
35
+ function assertOpen() {
36
+ if (status === 'closed') {
37
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_CLOSED');
38
+ }
39
+ }
40
+ function requireCurrent() {
41
+ if (status !== 'active' || current === undefined) {
42
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_NOT_READY');
43
+ }
44
+ return current;
45
+ }
46
+ async function loadStoredRefreshToken() {
47
+ try {
48
+ const stored = await input.credentials.load({ key: credentialKey });
49
+ if (stored !== null && (typeof stored !== 'string' || stored === '')) {
50
+ throw new Error('Invalid stored refresh token.');
51
+ }
52
+ return stored;
53
+ }
54
+ catch {
55
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_CREDENTIAL_LOAD_FAILED');
56
+ }
57
+ }
58
+ async function fromBackend(operation) {
59
+ try {
60
+ return await operation();
61
+ }
62
+ catch {
63
+ // Backend exceptions can contain URLs, proofs, or tokens. The public
64
+ // coordinator surface intentionally exposes only a stable error code.
65
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_BACKEND_FAILED');
66
+ }
67
+ }
68
+ async function persist(next) {
69
+ try {
70
+ await input.credentials.save({ key: credentialKey, value: next.refreshToken });
71
+ }
72
+ catch {
73
+ // Keep the latest token only for an explicit revoke; never expose it as
74
+ // an active session after native persistence failed.
75
+ current = next;
76
+ if (status !== 'closed') {
77
+ status = 'failed';
78
+ }
79
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_CREDENTIAL_SAVE_FAILED');
80
+ }
81
+ current = next;
82
+ if (status !== 'closed') {
83
+ status = 'active';
84
+ }
85
+ }
86
+ return {
87
+ async start() {
88
+ assertOpen();
89
+ if (status === 'active' && current !== undefined) {
90
+ return Promise.resolve(view(current));
91
+ }
92
+ if (startInFlight !== undefined) {
93
+ return startInFlight;
94
+ }
95
+ if (status === 'failed') {
96
+ return Promise.reject(new GuestSessionCoordinatorError('GUEST_SESSION_NOT_READY'));
97
+ }
98
+ startInFlight = enqueue(async () => {
99
+ assertOpen();
100
+ 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);
107
+ }).finally(() => { startInFlight = undefined; });
108
+ return startInFlight;
109
+ },
110
+ async refresh() {
111
+ assertOpen();
112
+ if (refreshInFlight !== undefined) {
113
+ return refreshInFlight;
114
+ }
115
+ refreshInFlight = enqueue(async () => {
116
+ const previous = requireCurrent();
117
+ const next = validateSession(await fromBackend(() => input.backend.refresh({
118
+ refreshToken: previous.refreshToken,
119
+ })));
120
+ assertSameOwner(previous, next);
121
+ await persist(next);
122
+ assertOpen();
123
+ return view(next);
124
+ }).finally(() => { refreshInFlight = undefined; });
125
+ return refreshInFlight;
126
+ },
127
+ async bindAccount(binding) {
128
+ assertOpen();
129
+ if (typeof binding.externalProof !== 'string' || binding.externalProof.trim() === ''
130
+ || typeof binding.idempotencyKey !== 'string' || binding.idempotencyKey.trim() === '') {
131
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
132
+ }
133
+ return enqueue(async () => {
134
+ const previous = requireCurrent();
135
+ const result = await fromBackend(() => input.backend.bindAccount({
136
+ refreshToken: previous.refreshToken,
137
+ externalProof: binding.externalProof,
138
+ idempotencyKey: binding.idempotencyKey,
139
+ }));
140
+ if (typeof result !== 'object' || result === null || !('status' in result)) {
141
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_RESPONSE');
142
+ }
143
+ if (result.status === 'conflict') {
144
+ assertOpen();
145
+ return { status: 'conflict' };
146
+ }
147
+ if (result.status !== 'bound' && result.status !== 'already-bound') {
148
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_RESPONSE');
149
+ }
150
+ const next = validateSession(result.session);
151
+ assertSameOwner(previous, next);
152
+ if (next.identityLevel !== 'authenticated') {
153
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_RESPONSE');
154
+ }
155
+ await persist(next);
156
+ assertOpen();
157
+ return { status: result.status };
158
+ });
159
+ },
160
+ getHeaders() {
161
+ assertOpen();
162
+ const active = requireCurrent();
163
+ if (Date.parse(active.accessExpiresAt) <= now()) {
164
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_NOT_READY');
165
+ }
166
+ return { authorization: `Bearer ${active.accessToken}` };
167
+ },
168
+ logout() {
169
+ if (logoutInFlight !== undefined) {
170
+ return logoutInFlight;
171
+ }
172
+ status = 'closed';
173
+ const attempt = enqueue(async () => {
174
+ let token = current?.refreshToken;
175
+ if (token === undefined) {
176
+ try {
177
+ token = (await input.credentials.load({ key: credentialKey })) ?? undefined;
178
+ }
179
+ catch {
180
+ // Keep the credential intact so a later logout can retry the load.
181
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_LOGOUT_UNCERTAIN');
182
+ }
183
+ }
184
+ if (token !== undefined) {
185
+ try {
186
+ await input.backend.revoke({ refreshToken: token });
187
+ }
188
+ catch {
189
+ // Do not delete a still-live refresh token on revoke failure.
190
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_LOGOUT_UNCERTAIN');
191
+ }
192
+ }
193
+ try {
194
+ await input.credentials.remove({ key: credentialKey });
195
+ }
196
+ catch {
197
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_LOGOUT_UNCERTAIN');
198
+ }
199
+ current = undefined;
200
+ });
201
+ logoutInFlight = attempt;
202
+ void attempt.catch(() => {
203
+ // A failed revoke or remove can be retried on this closed coordinator.
204
+ logoutInFlight = undefined;
205
+ });
206
+ return logoutInFlight;
207
+ },
208
+ };
209
+ }
210
+ function view(session) {
211
+ return {
212
+ serverUserId: session.serverUserId,
213
+ sessionId: session.sessionId,
214
+ identityLevel: session.identityLevel,
215
+ accessExpiresAt: session.accessExpiresAt,
216
+ };
217
+ }
218
+ // A server may already have consumed the previous refresh token. Persist a
219
+ // well-formed rotated token even when the device clock makes access look expired.
220
+ function validateSession(value) {
221
+ if (!isRecord(value)
222
+ || typeof value.serverUserId !== 'string' || value.serverUserId.trim() === ''
223
+ || typeof value.sessionId !== 'string' || value.sessionId.trim() === ''
224
+ || (value.identityLevel !== 'guest' && value.identityLevel !== 'authenticated')
225
+ || typeof value.accessToken !== 'string' || value.accessToken.trim() === ''
226
+ || typeof value.refreshToken !== 'string' || value.refreshToken.trim() === ''
227
+ || typeof value.accessExpiresAt !== 'string'
228
+ || !Number.isFinite(Date.parse(value.accessExpiresAt))) {
229
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_RESPONSE');
230
+ }
231
+ return {
232
+ serverUserId: value.serverUserId,
233
+ sessionId: value.sessionId,
234
+ identityLevel: value.identityLevel,
235
+ accessToken: value.accessToken,
236
+ refreshToken: value.refreshToken,
237
+ accessExpiresAt: value.accessExpiresAt,
238
+ };
239
+ }
240
+ function isRecord(value) {
241
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
242
+ }
243
+ function assertSameOwner(previous, next) {
244
+ if (previous.serverUserId !== next.serverUserId) {
245
+ throw new GuestSessionCoordinatorError('GUEST_SESSION_OWNERSHIP_CONFLICT');
246
+ }
247
+ }
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from './apps-in-toss-partner-api.js';
5
5
  export * from './client.js';
6
6
  export * from './contract.js';
7
7
  export * from './evidence-verification.js';
8
+ export * from './guest-session.js';
8
9
  export * from './google-play-purchase.js';
9
10
  export * from './microsoft-store-identity.js';
10
11
  export * from './microsoft-store-purchase.js';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export * from './apps-in-toss-partner-api.js';
5
5
  export * from './client.js';
6
6
  export * from './contract.js';
7
7
  export * from './evidence-verification.js';
8
+ export * from './guest-session.js';
8
9
  export * from './google-play-purchase.js';
9
10
  export * from './microsoft-store-identity.js';
10
11
  export * from './microsoft-store-purchase.js';
package/dist/runtime.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { AnalyticsSink } from '@mpgd/analytics';
2
2
  import type { PlatformGateway, PlatformTarget } from '@mpgd/platform';
3
- import { type GameServicesBackendApi, type GameServicesClient } from './client.js';
3
+ import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesClient, type GameServicesHeaderResolver } from './client.js';
4
4
  import type { GameServicesLedgerTarget } from './types.js';
5
5
  export type GameServicesAuthorityMode = 'production' | 'non-production';
6
6
  export type GameServicesRuntimeMode = 'disabled' | 'local' | 'http' | 'orpc';
@@ -12,23 +12,33 @@ export interface GameServicesRuntime {
12
12
  readonly target?: GameServicesLedgerTarget;
13
13
  readonly client?: GameServicesClient;
14
14
  }
15
- export interface CreateGameServicesRuntimeInput {
15
+ interface CreateGameServicesRuntimeCommonInput {
16
16
  readonly gateway: PlatformGateway;
17
17
  readonly playerId: string;
18
18
  readonly authorityMode: GameServicesAuthorityMode;
19
19
  readonly target?: PlatformTarget | string;
20
20
  readonly deploymentTarget?: string;
21
21
  readonly baseUrl?: string;
22
- readonly transport?: 'http' | 'orpc';
23
22
  /** Static headers sent with every authoritative game-services request. */
24
23
  readonly headers?: Record<string, string>;
24
+ /** Resolve rotating credentials at request time, not runtime creation time. */
25
+ readonly getHeaders?: GameServicesHeaderResolver;
25
26
  readonly allowLocalBackend?: boolean;
26
27
  readonly localBackend?: GameServicesBackendApi;
27
28
  readonly analytics?: AnalyticsSink;
28
29
  readonly analyticsSessionId?: string;
29
30
  readonly now?: () => string;
30
31
  }
32
+ export type CreateGameServicesRuntimeInput = CreateGameServicesRuntimeCommonInput & ({
33
+ readonly transport?: 'http';
34
+ /** A JSON endpoint transport, not a replacement for the Fetch API. */
35
+ readonly httpTransport?: GameServicesBackendTransport;
36
+ } | {
37
+ readonly transport: 'orpc';
38
+ readonly httpTransport?: never;
39
+ });
31
40
  export declare function createGameServicesRuntime(input: CreateGameServicesRuntimeInput): GameServicesRuntime;
32
41
  export declare function resolveGameServicesLedgerTarget(target: PlatformTarget | string): GameServicesLedgerTarget | null;
33
42
  export declare function resolveGameServicesAuthorityMode(profile: string): GameServicesAuthorityMode;
34
43
  export declare function resolveGameServicesTransport(transport: string | undefined): 'http' | 'orpc';
44
+ export {};
package/dist/runtime.js CHANGED
@@ -1,12 +1,18 @@
1
- import { createGameServicesClient, createGameServicesFetchBackendTransport, createGameServicesHttpBackendApi, createGameServicesOrpcBackendApi, createGameServicesOrpcClient, } from './client.js';
1
+ import { createGameServicesClient, createGameServicesFetchBackendTransport, createGameServicesHttpBackendApi, createGameServicesOrpcBackendApi, createGameServicesOrpcClient, resolveGameServicesRequestHeaders, } from './client.js';
2
2
  export function createGameServicesRuntime(input) {
3
3
  assertAuthorityMode(input.authorityMode);
4
4
  assertTransport(input.transport);
5
+ if (input.transport === 'orpc' && input.httpTransport !== undefined) {
6
+ throw new Error('Game Services httpTransport cannot be used with oRPC.');
7
+ }
5
8
  const target = resolveGameServicesLedgerTarget(input.target ?? input.gateway.target);
6
9
  if (target === null) {
7
10
  return disabledRuntime('unsupported_target');
8
11
  }
9
12
  const baseUrl = normalizeBaseUrl(input.baseUrl);
13
+ if (input.httpTransport !== undefined && baseUrl === undefined) {
14
+ throw new Error('Game Services httpTransport requires an authoritative baseUrl.');
15
+ }
10
16
  let backend;
11
17
  let mode;
12
18
  if (baseUrl !== undefined) {
@@ -22,12 +28,10 @@ export function createGameServicesRuntime(input) {
22
28
  ? createGameServicesOrpcBackendApi(createGameServicesOrpcClient({
23
29
  url: baseUrl,
24
30
  ...(input.headers === undefined ? {} : { headers: input.headers }),
31
+ ...(input.getHeaders === undefined ? {} : { getHeaders: input.getHeaders }),
25
32
  }))
26
33
  : createGameServicesHttpBackendApi({
27
- transport: createGameServicesFetchBackendTransport({
28
- baseUrl,
29
- ...(input.headers === undefined ? {} : { headers: input.headers }),
30
- }),
34
+ transport: withRuntimeHeaders(input.httpTransport ?? createGameServicesFetchBackendTransport({ baseUrl }), input),
31
35
  });
32
36
  }
33
37
  else {
@@ -63,6 +67,21 @@ export function createGameServicesRuntime(input) {
63
67
  }),
64
68
  };
65
69
  }
70
+ function withRuntimeHeaders(transport, input) {
71
+ return {
72
+ async send(request) {
73
+ const headers = await resolveGameServicesRequestHeaders({
74
+ headers: input.headers,
75
+ getHeaders: input.getHeaders,
76
+ requestHeaders: request.headers,
77
+ });
78
+ return transport.send({
79
+ ...request,
80
+ headers,
81
+ });
82
+ },
83
+ };
84
+ }
66
85
  export function resolveGameServicesLedgerTarget(target) {
67
86
  if (target === 'browser'
68
87
  || target === 'microsoft-store'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.15.3",
3
+ "version": "0.16.0",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -73,6 +73,10 @@
73
73
  "types": "./dist/google-play-purchase.d.ts",
74
74
  "default": "./dist/google-play-purchase.js"
75
75
  },
76
+ "./guest-session": {
77
+ "types": "./dist/guest-session.d.ts",
78
+ "default": "./dist/guest-session.js"
79
+ },
76
80
  "./microsoft-store-identity": {
77
81
  "types": "./dist/microsoft-store-identity.d.ts",
78
82
  "default": "./dist/microsoft-store-identity.js"
@@ -138,11 +142,12 @@
138
142
  "@orpc/client": "2.0.0-beta.14",
139
143
  "@orpc/contract": "2.0.0-beta.14",
140
144
  "@orpc/server": "2.0.0-beta.14",
141
- "@mpgd/analytics": "0.3.14",
142
- "@mpgd/catalog": "0.7.4",
143
- "@mpgd/platform": "0.12.2"
145
+ "@mpgd/analytics": "0.3.15",
146
+ "@mpgd/platform": "0.13.0",
147
+ "@mpgd/catalog": "0.7.5"
144
148
  },
145
149
  "devDependencies": {
150
+ "@opentelemetry/api": "1.9.0",
146
151
  "ttsc": "0.30.4",
147
152
  "typescript": "7.0.2"
148
153
  },
@@ -156,6 +161,9 @@
156
161
  "lint": "ttsc --noEmit",
157
162
  "format": "ttsc format",
158
163
  "fix": "ttsc fix",
159
- "test": "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/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/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"
164
+ "build": "cd ../.. && node tools/run-ttsx.mjs tools/package/build-packages.ts @mpgd/game-services",
165
+ "test:packed-transport": "pnpm build && node test/runtime-transport-package-import.mjs",
166
+ "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/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/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",
167
+ "test": "pnpm test:unit && pnpm test:packed-transport"
160
168
  }
161
169
  }