@crowdedkingdoms/crowdyjs 6.1.1 → 7.1.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/MIGRATION.md CHANGED
@@ -1,3 +1,58 @@
1
+ # CrowdyJS v7 — Overworld portals & app-scoped tokens (BREAKING)
2
+
3
+ v7 splits the single app-agnostic game token into two credentials and makes
4
+ gameplay require an **app-scoped token**. This is a breaking change requiring
5
+ servers on the matching release (management-api + game-api + Buddy with the
6
+ app-scoped-token feature).
7
+
8
+ **The two credential kinds**
9
+
10
+ - **Identity SESSION token** — returned by `client.auth.login()` / `register()`.
11
+ It talks to the **Management API only** (account, studio admin, and minting
12
+ app tokens). It is **no longer valid for gameplay**: the Game API and Buddy
13
+ reject it. Never hand it to a game stack.
14
+ - **App-scoped GAMEPLAY token** — short-lived (default ~30 min), confined to one
15
+ app. Minted from a session token via the portal flow; used against that app's
16
+ Game API + realtime surface.
17
+
18
+ **What breaks**
19
+
20
+ - Driving `client.udp`, `client.world(appId)`, `serverWithLeastClients`,
21
+ `connectUdpProxy`, world reads/writes, etc. with a plain login token now fails
22
+ (`APP_TOKEN_REQUIRED` on `udpNotifications`; `FORBIDDEN`/`SCOPE_MISSING` on
23
+ HTTP). You must obtain an app token first.
24
+ - A single client can no longer be both your identity client and your game
25
+ client. Use the two-client pattern: an Overworld/identity client (holds the
26
+ session token) and a per-game client (holds that game's app token).
27
+
28
+ **New: `client.portal`**
29
+
30
+ ```ts
31
+ // Native / same-origin: mint directly with the session token.
32
+ const appToken = await overworld.portal.mintAppToken(appId);
33
+ const game = createCrowdyClient({ httpUrl: appToken.gameApiUrl!, wsUrl: appToken.gameApiWsUrl!,
34
+ managementUrl, tokenStore: new BrowserLocalStorageTokenStore('crowdyjs:token:' + appId) });
35
+ game.setToken(appToken.token);
36
+
37
+ // Browser cross-origin handoff (OAuth2 Authorization Code + PKCE):
38
+ // game origin, on "enter":
39
+ const url = await game.portal.beginEntry({ appId, authorizeUrl: 'https://overworld.example.com/authorize',
40
+ redirectUri: location.origin + location.pathname });
41
+ location.assign(url);
42
+ // Overworld /authorize page (holds the session token):
43
+ location.assign(await overworld.portal.handleAuthorizeRequest());
44
+ // game origin, on callback boot:
45
+ const token = await game.portal.completeEntry(); // exchanges code+verifier, stores app token
46
+ // keep playing past expiry without re-portaling:
47
+ await game.portal.refresh();
48
+ ```
49
+
50
+ The session token never reaches the game origin — only the app token does. New
51
+ realtime `RealtimeConnectionEvent` codes: `APP_TOKEN_REQUIRED`,
52
+ `APP_SCOPE_MISMATCH`. New `UdpErrorCode`: `TOKEN_EXPIRED`.
53
+
54
+ ---
55
+
1
56
  # CrowdyJS — npm org rename (v6 version line kept)
2
57
 
3
58
  The package moved to the **`@crowdedkingdoms`** npm organization. The version line
package/README.md CHANGED
@@ -260,6 +260,52 @@ The key parameter is optional and trailing, so it's safe to omit. Requires a ser
260
260
  - `client.session.restore()` reads from the configured `tokenStore`. `BrowserLocalStorageTokenStore` is provided; bring your own for SSR or Node usage.
261
261
  - A single `AuthState` is observed by both the HTTP client and the realtime socket, so HTTP and WebSocket auth can never drift.
262
262
 
263
+ ## Overworld portals & app-scoped tokens (v7)
264
+
265
+ As of v7 gameplay requires an **app-scoped token**, not the login token. Login
266
+ returns an **identity session token** (Management API only — account, studio
267
+ admin, and minting); each game is entered with a short-lived token confined to
268
+ that one app, so a game stack never receives the player's full session.
269
+
270
+ Use two clients: an Overworld/identity client (session token) and a per-game
271
+ client (app token), sharing only the Management URL.
272
+
273
+ ```ts
274
+ // Overworld/identity client
275
+ const overworld = createCrowdyClient({ managementUrl, tokenStore: new BrowserLocalStorageTokenStore('crowdyjs:session') });
276
+ await overworld.auth.login({ email, password });
277
+
278
+ // Native / same-origin: mint directly, then build a game client.
279
+ const t = await overworld.portal.mintAppToken(appId);
280
+ const game = createCrowdyClient({ httpUrl: t.gameApiUrl!, wsUrl: t.gameApiWsUrl!, managementUrl,
281
+ tokenStore: new BrowserLocalStorageTokenStore('crowdyjs:app:' + appId) });
282
+ game.setToken(t.token);
283
+ game.world(appId).subscribe({ actorUpdate: (n) => { /* ... */ } });
284
+ ```
285
+
286
+ Browser cross-origin handoff is OAuth2 Authorization Code + PKCE — the verifier
287
+ never leaves the game origin:
288
+
289
+ ```ts
290
+ // Game origin, on "enter": redirect to the Overworld authorize page.
291
+ location.assign(await game.portal.beginEntry({
292
+ appId, authorizeUrl: 'https://overworld.example.com/authorize',
293
+ redirectUri: location.origin + location.pathname,
294
+ }));
295
+
296
+ // Overworld /authorize page (holds the session token):
297
+ location.assign(await overworld.portal.handleAuthorizeRequest());
298
+
299
+ // Game origin, on callback boot: exchange code+verifier -> app token (stored).
300
+ const entered = await game.portal.completeEntry();
301
+ // Keep playing past expiry without re-portaling (same app):
302
+ await game.portal.refresh();
303
+ ```
304
+
305
+ Game-to-game routes through the Overworld for a fresh per-game token. New
306
+ realtime codes: `APP_TOKEN_REQUIRED`, `APP_SCOPE_MISMATCH`; new `UdpErrorCode`:
307
+ `TOKEN_EXPIRED`. See [MIGRATION.md](MIGRATION.md) for the full v7 breaking guide.
308
+
263
309
  ## Surface scope & security
264
310
 
265
311
  As of v6 (completed in v6.1), CrowdyJS wraps the **full** management-api + game-api
@@ -29,6 +29,7 @@ import { WorldClient } from './world.js';
29
29
  import { AuthAPI } from './domains/auth.js';
30
30
  import { UsersAPI } from './domains/users.js';
31
31
  import { AppsAPI } from './domains/apps.js';
32
+ import { PortalAPI, type PkceStore } from './domains/portal.js';
32
33
  import { PlatformAPI } from './domains/platform.js';
33
34
  import { OrganizationsAPI } from './domains/organizations.js';
34
35
  import { AppAccessAPI } from './domains/appAccess.js';
@@ -82,6 +83,13 @@ export interface CrowdyClientConfig {
82
83
  tokenStore?: TokenStore;
83
84
  /** Optional logger for SDK diagnostics (request/realtime lifecycle). */
84
85
  logger?: CrowdyLogger;
86
+ /**
87
+ * Optional storage for the PKCE verifier across the portal redirect round-trip
88
+ * (`client.portal.beginEntry` -> `completeEntry`). Defaults to a
89
+ * sessionStorage-backed store in the browser; supply your own for SSR, native,
90
+ * or tests where sessionStorage is unavailable.
91
+ */
92
+ pkceStore?: PkceStore;
85
93
  /** Realtime (WebSocket) tuning for reconnect backoff and `...AndWait` timeouts. */
86
94
  realtime?: {
87
95
  /** Max reconnect attempts before giving up (default tuned for browsers). */
@@ -109,6 +117,12 @@ export declare class CrowdyClient {
109
117
  readonly users: UsersAPI;
110
118
  /** App discovery + routing (which game-api serves a given app). */
111
119
  readonly apps: AppsAPI;
120
+ /**
121
+ * Overworld portal: mint/exchange/refresh app-scoped gameplay tokens and the
122
+ * PKCE browser handoff. Identity session token mints; games receive only an
123
+ * app token.
124
+ */
125
+ readonly portal: PortalAPI;
112
126
  /** Public platform discovery (shared game-api URL, free app quota). */
113
127
  readonly platform: PlatformAPI;
114
128
  /** Organizations, members, RBAC roles, and org API tokens (studio admin). */
@@ -1 +1 @@
1
- {"version":3,"file":"crowdy-client.d.ts","sourceRoot":"","sources":["../src/crowdy-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"crowdy-client.d.ts","sourceRoot":"","sources":["../src/crowdy-client.ts"],"names":[],"mappings":"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"}
@@ -27,6 +27,7 @@ import { WorldClient } from './world.js';
27
27
  import { AuthAPI } from './domains/auth.js';
28
28
  import { UsersAPI } from './domains/users.js';
29
29
  import { AppsAPI } from './domains/apps.js';
30
+ import { PortalAPI } from './domains/portal.js';
30
31
  import { PlatformAPI } from './domains/platform.js';
31
32
  import { OrganizationsAPI } from './domains/organizations.js';
32
33
  import { AppAccessAPI } from './domains/appAccess.js';
@@ -56,20 +57,19 @@ export class CrowdyClient {
56
57
  this.session = new AuthState(config.tokenStore);
57
58
  this.graphql = new GraphQLClient({
58
59
  httpUrl: config.httpUrl,
59
- graphqlEndpoint: config.graphqlEndpoint,
60
+ graphqlEndpoint: config.graphqlEndpoint ?? toGraphqlEndpoint(config.httpUrl, 'graphql'),
60
61
  timeout: config.timeout,
61
62
  logger: config.logger,
62
63
  }, this.session);
63
64
  this.realtime = new SubscriptionManager({
64
65
  wsUrl: config.wsUrl,
65
- wsEndpoint: config.wsEndpoint,
66
+ wsEndpoint: config.wsEndpoint ?? toGraphqlEndpoint(config.wsUrl, 'graphql'),
66
67
  logger: config.logger,
67
68
  ...config.realtime,
68
69
  }, this.session);
69
70
  const managementGraphqlEndpoint = config.managementGraphqlEndpoint ??
70
- (config.managementUrl
71
- ? `${config.managementUrl.replace(/\/$/, '')}/graphql`
72
- : config.graphqlEndpoint);
71
+ toGraphqlEndpoint(config.managementUrl, 'graphql') ??
72
+ config.graphqlEndpoint;
73
73
  // Management-api client. Falls back to game-api endpoint if the caller
74
74
  // hasn't configured `managementUrl` yet (single-endpoint legacy mode).
75
75
  this.management = new GraphQLClient({
@@ -81,6 +81,7 @@ export class CrowdyClient {
81
81
  this.auth = new AuthAPI(this.management, this.session);
82
82
  this.users = new UsersAPI(this.management);
83
83
  this.apps = new AppsAPI(this.management);
84
+ this.portal = new PortalAPI(this.management, this.session, config.pkceStore);
84
85
  this.platform = new PlatformAPI(this.management);
85
86
  this.organizations = new OrganizationsAPI(this.management);
86
87
  this.appAccess = new AppAccessAPI(this.management);
@@ -144,3 +145,16 @@ export class CrowdyClient {
144
145
  export function createCrowdyClient(config = {}) {
145
146
  return new CrowdyClient(config);
146
147
  }
148
+ /**
149
+ * Normalize a base URL into a GraphQL endpoint. Accepts either a base origin
150
+ * (`https://game.example.com`) or a full endpoint already ending in `/graphql`
151
+ * (the historical form some callers pass as `httpUrl`/`wsUrl`), so the portal's
152
+ * `gameApiUrl` (a base URL) is usable directly. Returns undefined for empty input.
153
+ */
154
+ function toGraphqlEndpoint(url, suffix) {
155
+ const trimmed = url?.trim();
156
+ if (!trimmed)
157
+ return undefined;
158
+ const noSlash = trimmed.replace(/\/$/, '');
159
+ return noSlash.endsWith(`/${suffix}`) ? noSlash : `${noSlash}/${suffix}`;
160
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Overworld portal: the client side of app-scoped tokens.
3
+ *
4
+ * Two distinct credential kinds replace the old single app-agnostic token:
5
+ * - an identity SESSION token (from `client.auth.login` / `register`) that
6
+ * talks to the Management API and is the ONLY thing that can mint app
7
+ * tokens. Never send it to a game stack.
8
+ * - short-lived, app-scoped GAMEPLAY tokens, one per game, used against that
9
+ * game's Game API + realtime surface.
10
+ *
11
+ * Typical wiring is two `CrowdyClient`s sharing nothing but the same Management
12
+ * URL: an "Overworld"/identity client holding the session token, and a per-game
13
+ * client whose token store holds that game's app token.
14
+ *
15
+ * Browser handoff (cross-origin) is an OAuth2 Authorization-Code + PKCE flow:
16
+ * 1. game origin: `beginEntry()` -> redirect the player to the Overworld
17
+ * `/authorize` page carrying a PKCE challenge + the game's redirect URI.
18
+ * 2. Overworld origin (holds the session): `handleAuthorizeRequest()` ->
19
+ * mints a one-time code and redirects back to the game.
20
+ * 3. game origin: `completeEntry()` -> exchanges the code (+ the PKCE verifier
21
+ * it kept locally) for an app token and stores it.
22
+ *
23
+ * Native / same-origin callers can skip the redirect dance and call
24
+ * `mintAppToken(appId)` directly with a session token.
25
+ */
26
+ import type { GraphQLClient } from '../client.js';
27
+ import type { SessionStore } from '../session.js';
28
+ export interface AppTokenResponse {
29
+ /** Opaque app-scoped gameplay token. Send to the app's Game API as a Bearer. */
30
+ token: string;
31
+ gameTokenId: string;
32
+ /** The app this token is confined to (decimal string). */
33
+ appId: string;
34
+ /** ISO-8601 UTC expiry. Refresh (same app) or re-portal before this. */
35
+ expiresAt: string;
36
+ /** Base HTTPS URL of the Game API serving this app (null if unrouted). */
37
+ gameApiUrl: string | null;
38
+ /** WebSocket URL of the Game API serving this app. */
39
+ gameApiWsUrl: string | null;
40
+ /** Browser launch URL for this app, if configured. */
41
+ launchUrl: string | null;
42
+ }
43
+ export interface PortalAuthorizationCode {
44
+ code: string;
45
+ redirectUri: string;
46
+ expiresAt: string;
47
+ }
48
+ /** Persists the PKCE verifier across the cross-origin redirect round-trip. */
49
+ export interface PkceStore {
50
+ get(state: string): string | null | Promise<string | null>;
51
+ set(state: string, verifier: string): void | Promise<void>;
52
+ remove(state: string): void | Promise<void>;
53
+ }
54
+ /** Default PKCE store backed by sessionStorage; no-op when unavailable (SSR). */
55
+ export declare class BrowserSessionPkceStore implements PkceStore {
56
+ private readonly prefix;
57
+ constructor(prefix?: string);
58
+ private ss;
59
+ get(state: string): string | null;
60
+ set(state: string, verifier: string): void;
61
+ remove(state: string): void;
62
+ }
63
+ export interface BeginEntryParams {
64
+ /** Target app id (decimal string). */
65
+ appId: string;
66
+ /** The Overworld identity origin's authorize page, e.g. `https://overworld.example.com/authorize`. */
67
+ authorizeUrl: string;
68
+ /** Where the Overworld should send the player back (this game's callback). */
69
+ redirectUri: string;
70
+ /** Optional CSRF/correlation state; one is generated if omitted. */
71
+ state?: string;
72
+ }
73
+ export declare class PortalAPI {
74
+ private readonly management;
75
+ private readonly session;
76
+ private readonly pkceStore;
77
+ constructor(management: GraphQLClient, session: SessionStore, pkceStore?: PkceStore);
78
+ /**
79
+ * Native/direct mint: exchange the caller's identity session token for an
80
+ * app-scoped gameplay token. Returns the token; it is NOT stored on this
81
+ * client (build a per-game client with it). Free/open apps auto-grant access;
82
+ * paid apps require an existing entitlement.
83
+ */
84
+ mintAppToken(appId: string): Promise<AppTokenResponse>;
85
+ /**
86
+ * Overworld/identity side: mint a one-time authorization code for a target
87
+ * app, bound to the destination game's PKCE challenge + redirect URI.
88
+ * Requires the identity session token.
89
+ */
90
+ createAuthorizationCode(params: {
91
+ appId: string;
92
+ codeChallenge: string;
93
+ codeChallengeMethod?: string;
94
+ redirectUri: string;
95
+ }): Promise<PortalAuthorizationCode>;
96
+ /**
97
+ * Destination-game side: exchange a one-time code (+ PKCE verifier) for an app
98
+ * token and store it on this client's session so subsequent Game API calls are
99
+ * authenticated. Public — no session token required.
100
+ */
101
+ exchangeCode(code: string, codeVerifier?: string): Promise<AppTokenResponse>;
102
+ /**
103
+ * Same-app refresh: rotate the current app token for a fresh one (extended
104
+ * TTL) and store it. Call before expiry to keep playing without bouncing
105
+ * through the Overworld. Requires the current app token on this session.
106
+ */
107
+ refresh(): Promise<AppTokenResponse>;
108
+ /**
109
+ * Destination-game side, step 1: generate a PKCE pair, persist the verifier,
110
+ * and return the Overworld authorize URL to navigate to. The caller does
111
+ * `window.location.assign(url)`.
112
+ */
113
+ beginEntry(params: BeginEntryParams): Promise<string>;
114
+ /**
115
+ * Overworld/identity side: handle an incoming `/authorize` request. Reads the
116
+ * game's params from the URL, mints a code with the session token, and returns
117
+ * the URL to redirect the player back to (carrying `code` + `state`).
118
+ */
119
+ handleAuthorizeRequest(search?: string): Promise<string>;
120
+ /**
121
+ * Destination-game side, step 3: read `code` + `state` from the callback URL,
122
+ * load the stored verifier, exchange for an app token, and store it. Returns
123
+ * null when there is no `code` param (so it's safe to call unconditionally on
124
+ * boot).
125
+ */
126
+ completeEntry(search?: string): Promise<AppTokenResponse | null>;
127
+ }
128
+ //# sourceMappingURL=portal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../src/domains/portal.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Overworld portal: the client side of app-scoped tokens.
3
+ *
4
+ * Two distinct credential kinds replace the old single app-agnostic token:
5
+ * - an identity SESSION token (from `client.auth.login` / `register`) that
6
+ * talks to the Management API and is the ONLY thing that can mint app
7
+ * tokens. Never send it to a game stack.
8
+ * - short-lived, app-scoped GAMEPLAY tokens, one per game, used against that
9
+ * game's Game API + realtime surface.
10
+ *
11
+ * Typical wiring is two `CrowdyClient`s sharing nothing but the same Management
12
+ * URL: an "Overworld"/identity client holding the session token, and a per-game
13
+ * client whose token store holds that game's app token.
14
+ *
15
+ * Browser handoff (cross-origin) is an OAuth2 Authorization-Code + PKCE flow:
16
+ * 1. game origin: `beginEntry()` -> redirect the player to the Overworld
17
+ * `/authorize` page carrying a PKCE challenge + the game's redirect URI.
18
+ * 2. Overworld origin (holds the session): `handleAuthorizeRequest()` ->
19
+ * mints a one-time code and redirects back to the game.
20
+ * 3. game origin: `completeEntry()` -> exchanges the code (+ the PKCE verifier
21
+ * it kept locally) for an app token and stores it.
22
+ *
23
+ * Native / same-origin callers can skip the redirect dance and call
24
+ * `mintAppToken(appId)` directly with a session token.
25
+ */
26
+ import { parse } from 'graphql';
27
+ import { generatePkcePair, generateState } from '../pkce.js';
28
+ /** Default PKCE store backed by sessionStorage; no-op when unavailable (SSR). */
29
+ export class BrowserSessionPkceStore {
30
+ constructor(prefix = 'crowdyjs:pkce:') {
31
+ this.prefix = prefix;
32
+ }
33
+ ss() {
34
+ return typeof sessionStorage === 'undefined' ? null : sessionStorage;
35
+ }
36
+ get(state) {
37
+ return this.ss()?.getItem(this.prefix + state) ?? null;
38
+ }
39
+ set(state, verifier) {
40
+ this.ss()?.setItem(this.prefix + state, verifier);
41
+ }
42
+ remove(state) {
43
+ this.ss()?.removeItem(this.prefix + state);
44
+ }
45
+ }
46
+ const APP_TOKEN_FIELDS = 'token gameTokenId appId expiresAt gameApiUrl gameApiWsUrl launchUrl';
47
+ const MintAppTokenDocument = parse(`mutation MintAppToken($input: MintAppTokenInput!) { mintAppToken(input: $input) { ${APP_TOKEN_FIELDS} } }`);
48
+ const CreatePortalAuthorizationCodeDocument = parse(`mutation CreatePortalAuthorizationCode($input: CreatePortalAuthorizationCodeInput!) { createPortalAuthorizationCode(input: $input) { code redirectUri expiresAt } }`);
49
+ const ExchangePortalCodeDocument = parse(`mutation ExchangePortalCode($input: ExchangePortalCodeInput!) { exchangePortalCode(input: $input) { ${APP_TOKEN_FIELDS} } }`);
50
+ const RefreshAppTokenDocument = parse(`mutation RefreshAppToken { refreshAppToken { ${APP_TOKEN_FIELDS} } }`);
51
+ export class PortalAPI {
52
+ constructor(management, session, pkceStore = new BrowserSessionPkceStore()) {
53
+ this.management = management;
54
+ this.session = session;
55
+ this.pkceStore = pkceStore;
56
+ }
57
+ /**
58
+ * Native/direct mint: exchange the caller's identity session token for an
59
+ * app-scoped gameplay token. Returns the token; it is NOT stored on this
60
+ * client (build a per-game client with it). Free/open apps auto-grant access;
61
+ * paid apps require an existing entitlement.
62
+ */
63
+ async mintAppToken(appId) {
64
+ const data = await this.management.request(MintAppTokenDocument, {
65
+ input: { appId },
66
+ });
67
+ return data.mintAppToken;
68
+ }
69
+ /**
70
+ * Overworld/identity side: mint a one-time authorization code for a target
71
+ * app, bound to the destination game's PKCE challenge + redirect URI.
72
+ * Requires the identity session token.
73
+ */
74
+ async createAuthorizationCode(params) {
75
+ const data = await this.management.request(CreatePortalAuthorizationCodeDocument, { input: params });
76
+ return data.createPortalAuthorizationCode;
77
+ }
78
+ /**
79
+ * Destination-game side: exchange a one-time code (+ PKCE verifier) for an app
80
+ * token and store it on this client's session so subsequent Game API calls are
81
+ * authenticated. Public — no session token required.
82
+ */
83
+ async exchangeCode(code, codeVerifier) {
84
+ const data = await this.management.request(ExchangePortalCodeDocument, {
85
+ input: { code, codeVerifier },
86
+ });
87
+ this.session.setToken(data.exchangePortalCode.token);
88
+ return data.exchangePortalCode;
89
+ }
90
+ /**
91
+ * Same-app refresh: rotate the current app token for a fresh one (extended
92
+ * TTL) and store it. Call before expiry to keep playing without bouncing
93
+ * through the Overworld. Requires the current app token on this session.
94
+ */
95
+ async refresh() {
96
+ const data = await this.management.request(RefreshAppTokenDocument, {});
97
+ this.session.setToken(data.refreshAppToken.token);
98
+ return data.refreshAppToken;
99
+ }
100
+ // ----- Browser PKCE redirect helpers -------------------------------------
101
+ /**
102
+ * Destination-game side, step 1: generate a PKCE pair, persist the verifier,
103
+ * and return the Overworld authorize URL to navigate to. The caller does
104
+ * `window.location.assign(url)`.
105
+ */
106
+ async beginEntry(params) {
107
+ const state = params.state ?? generateState();
108
+ const pkce = await generatePkcePair();
109
+ await this.pkceStore.set(state, pkce.verifier);
110
+ const url = new URL(params.authorizeUrl);
111
+ url.searchParams.set('app_id', params.appId);
112
+ url.searchParams.set('code_challenge', pkce.challenge);
113
+ url.searchParams.set('code_challenge_method', pkce.method);
114
+ url.searchParams.set('redirect_uri', params.redirectUri);
115
+ url.searchParams.set('state', state);
116
+ return url.toString();
117
+ }
118
+ /**
119
+ * Overworld/identity side: handle an incoming `/authorize` request. Reads the
120
+ * game's params from the URL, mints a code with the session token, and returns
121
+ * the URL to redirect the player back to (carrying `code` + `state`).
122
+ */
123
+ async handleAuthorizeRequest(search) {
124
+ const params = new URLSearchParams(search ?? defaultSearch());
125
+ const appId = params.get('app_id');
126
+ const codeChallenge = params.get('code_challenge');
127
+ const redirectUri = params.get('redirect_uri');
128
+ const state = params.get('state') ?? '';
129
+ const codeChallengeMethod = params.get('code_challenge_method') ?? 'S256';
130
+ if (!appId || !codeChallenge || !redirectUri) {
131
+ throw new Error('authorize request missing app_id, code_challenge, or redirect_uri');
132
+ }
133
+ const result = await this.createAuthorizationCode({
134
+ appId,
135
+ codeChallenge,
136
+ codeChallengeMethod,
137
+ redirectUri,
138
+ });
139
+ const back = new URL(result.redirectUri);
140
+ back.searchParams.set('code', result.code);
141
+ if (state)
142
+ back.searchParams.set('state', state);
143
+ return back.toString();
144
+ }
145
+ /**
146
+ * Destination-game side, step 3: read `code` + `state` from the callback URL,
147
+ * load the stored verifier, exchange for an app token, and store it. Returns
148
+ * null when there is no `code` param (so it's safe to call unconditionally on
149
+ * boot).
150
+ */
151
+ async completeEntry(search) {
152
+ const params = new URLSearchParams(search ?? defaultSearch());
153
+ const code = params.get('code');
154
+ if (!code)
155
+ return null;
156
+ const state = params.get('state') ?? '';
157
+ const verifier = (await this.pkceStore.get(state)) ?? undefined;
158
+ const token = await this.exchangeCode(code, verifier);
159
+ if (state)
160
+ await this.pkceStore.remove(state);
161
+ return token;
162
+ }
163
+ }
164
+ function defaultSearch() {
165
+ const loc = globalThis.location;
166
+ return loc?.search ?? '';
167
+ }