@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 +55 -0
- package/README.md +46 -0
- package/dist/crowdy-client.d.ts +14 -0
- package/dist/crowdy-client.d.ts.map +1 -1
- package/dist/crowdy-client.js +19 -5
- package/dist/domains/portal.d.ts +128 -0
- package/dist/domains/portal.d.ts.map +1 -0
- package/dist/domains/portal.js +167 -0
- package/dist/generated/graphql.d.ts +70 -0
- package/dist/generated/graphql.d.ts.map +1 -1
- package/dist/generated/graphql.js +2 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/pkce.d.ts +19 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +46 -0
- package/package.json +1 -1
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
|
package/dist/crowdy-client.d.ts
CHANGED
|
@@ -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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"}
|
package/dist/crowdy-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|