@flow-industries/id 0.18.0 → 0.19.1

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.
Files changed (61) hide show
  1. package/README.md +19 -0
  2. package/dist/sdk/client/access-key.d.ts +1 -1
  3. package/dist/sdk/client/access-key.js +20 -6
  4. package/dist/sdk/client/create-flow.js +19 -11
  5. package/dist/sdk/client/dialog-host.js +8 -2
  6. package/dist/sdk/client/flow-widget.js +2 -1
  7. package/dist/sdk/client/idb.d.ts +2 -2
  8. package/dist/sdk/client/idb.js +17 -2
  9. package/dist/sdk/client/profile-button.js +1 -1
  10. package/dist/sdk/client/rooms.js +19 -4
  11. package/dist/sdk/client/session.js +8 -5
  12. package/dist/sdk/client/signing.js +22 -7
  13. package/dist/sdk/client/store.js +4 -5
  14. package/dist/sdk/cookies.d.ts +3 -1
  15. package/dist/sdk/cookies.js +11 -9
  16. package/dist/sdk/dialog/remote/Messenger.d.ts +0 -5
  17. package/dist/sdk/dialog/remote/Messenger.js +26 -14
  18. package/dist/sdk/driver-error.d.ts +8 -0
  19. package/dist/sdk/driver-error.js +17 -0
  20. package/dist/sdk/hex.d.ts +6 -0
  21. package/dist/sdk/hex.js +6 -0
  22. package/dist/sdk/id-host.d.ts +1 -1
  23. package/dist/sdk/id-host.js +4 -5
  24. package/dist/sdk/json.d.ts +15 -0
  25. package/dist/sdk/json.js +3 -0
  26. package/dist/sdk/react/flow-widget.js +2 -2
  27. package/dist/sdk/react/hooks.d.ts +8 -4
  28. package/dist/sdk/react/hooks.js +1 -10
  29. package/dist/sdk/react/profile-button.js +2 -2
  30. package/dist/sdk/server.js +2 -1
  31. package/dist/sdk/session-core.d.ts +1 -6
  32. package/dist/sdk/session-core.js +7 -4
  33. package/dist/sdk/session-route.d.ts +0 -20
  34. package/dist/sdk/session-route.js +25 -9
  35. package/dist/sdk/start/graceful-shutdown.d.ts +8 -0
  36. package/dist/sdk/start/graceful-shutdown.js +81 -0
  37. package/dist/sdk/start/index.d.ts +2 -0
  38. package/dist/sdk/start/index.js +3 -2
  39. package/dist/sdk/token-expiry.js +3 -1
  40. package/dist/sdk/types/cosmetics.d.ts +162 -0
  41. package/dist/sdk/types/cosmetics.js +78 -0
  42. package/dist/sdk/types/events.d.ts +4 -1
  43. package/dist/sdk/types/game.d.ts +58 -0
  44. package/dist/sdk/types/game.js +8 -0
  45. package/dist/sdk/types/index.d.ts +8 -4
  46. package/dist/sdk/types/index.js +2 -0
  47. package/dist/sdk/types/messenger.d.ts +1 -1
  48. package/dist/sdk/types/protocol.d.ts +6 -5
  49. package/dist/sdk/types/protocol.js +10 -4
  50. package/dist/sdk/types/room-events.d.ts +49 -1
  51. package/dist/sdk/types/rooms.d.ts +66 -3
  52. package/dist/sdk/types/rooms.js +0 -1
  53. package/dist/sdk/types/sdk.d.ts +2 -1
  54. package/dist/sdk/types/server.d.ts +18 -0
  55. package/dist/sdk/types/xp.d.ts +8 -7
  56. package/dist/sdk/verify.js +1 -0
  57. package/dist/sdk/wagmi/index.d.ts +7 -3
  58. package/dist/sdk/wagmi/index.js +19 -32
  59. package/package.json +15 -8
  60. package/dist/sdk/client/refresh-store.d.ts +0 -22
  61. package/dist/sdk/client/refresh-store.js +0 -66
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Normalizes a hex string to the `0x`-prefixed form ox/viem expect.
3
+ *
4
+ * The template literal infers `` `0x${string}` `` on its own, so callers need no assertion.
5
+ */
6
+ export declare const hex0x: (value: string) => `0x${string}`;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Normalizes a hex string to the `0x`-prefixed form ox/viem expect.
3
+ *
4
+ * The template literal infers `` `0x${string}` `` on its own, so callers need no assertion.
5
+ */
6
+ export const hex0x = (value) => `0x${value.replace(/^0x/, "")}`;
@@ -5,7 +5,7 @@
5
5
  * without carrying any per-app configuration.
6
6
  */
7
7
  export declare const DEFAULT_ISSUER_URL = "https://id.flow.industries";
8
- /** Flow ID origin of a local auth stack (`bun run dev` in the auth repo). */
8
+ /** Legacy fallback for local consumers that do not pass the dev.sh-selected host. */
9
9
  export declare const LOCAL_ISSUER_URL = "http://localhost:5175";
10
10
  /**
11
11
  * Path of the first-party session route every app serves from its own origin
@@ -5,7 +5,7 @@
5
5
  * without carrying any per-app configuration.
6
6
  */
7
7
  export const DEFAULT_ISSUER_URL = "https://id.flow.industries";
8
- /** Flow ID origin of a local auth stack (`bun run dev` in the auth repo). */
8
+ /** Legacy fallback for local consumers that do not pass the dev.sh-selected host. */
9
9
  export const LOCAL_ISSUER_URL = "http://localhost:5175";
10
10
  /**
11
11
  * Path of the first-party session route every app serves from its own origin
@@ -24,7 +24,7 @@ export function isLocalHostname(hostname) {
24
24
  * to the request's own origin.
25
25
  */
26
26
  export function defaultAudience(requestOrigin) {
27
- return ((typeof process !== "undefined" ? process.env?.APP_ORIGIN : undefined) ??
27
+ return ((globalThis.process !== undefined ? process.env?.APP_ORIGIN : undefined) ??
28
28
  requestOrigin);
29
29
  }
30
30
  /**
@@ -34,11 +34,10 @@ export function defaultAudience(requestOrigin) {
34
34
  * behalf of a request (servers have no `window`).
35
35
  */
36
36
  export function defaultIdHost(appOrigin) {
37
- if (typeof process !== "undefined" && process.env?.FLOW_ID_HOST) {
37
+ if (globalThis.process !== undefined && process.env?.FLOW_ID_HOST) {
38
38
  return process.env.FLOW_ID_HOST;
39
39
  }
40
- const origin = appOrigin ??
41
- (typeof window !== "undefined" ? window.location.origin : undefined);
40
+ const origin = appOrigin ?? ("window" in globalThis ? window.location.origin : undefined);
42
41
  if (origin) {
43
42
  try {
44
43
  if (isLocalHostname(new URL(origin).hostname))
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The value domain for data that arrives as JSON — request bodies, provider responses, stored
3
+ * blobs, postMessage payloads.
4
+ *
5
+ * Primitive tests are written as coercion round-trips rather than `typeof`: `String(x) === x` and
6
+ * `Number(x) === x` hold only for the primitive itself, so each one establishes the value's
7
+ * contract instead of narrowing a representation.
8
+ */
9
+ export type RawValue = string | number | boolean | null | undefined | RawValue[] | RawRecord;
10
+ export type RawRecord = {
11
+ readonly [key: string]: RawValue;
12
+ };
13
+ export declare const isString: (v: RawValue) => v is string;
14
+ export declare const isNum: (v: RawValue) => v is number;
15
+ export declare const isRecord: (v: RawValue) => v is RawRecord;
@@ -0,0 +1,3 @@
1
+ export const isString = (v) => String(v) === v;
2
+ export const isNum = (v) => Number(v) === v && Number.isFinite(v);
3
+ export const isRecord = (v) => v !== null && v !== undefined && !Array.isArray(v) && Object(v) === v;
@@ -29,8 +29,8 @@ export function FlowWidget({ widget, className, host, theme, }) {
29
29
  widget,
30
30
  container,
31
31
  flow,
32
- ...(host ? { host } : {}),
33
- ...(themeRef.current ? { theme: themeRef.current } : {}),
32
+ host: host ? host : undefined,
33
+ theme: themeRef.current ? themeRef.current : undefined,
34
34
  });
35
35
  handleRef.current = handle;
36
36
  if (handle.frame) {
@@ -39,24 +39,28 @@ export declare function useFlowId(): {
39
39
  * Fetches the public room list once (call `reload` to refresh). Pagination
40
40
  * stays manual: pass the returned `nextCursor` back through `flow.rooms.list`.
41
41
  */
42
- export declare function useRooms(): {
42
+ /** What {@link useRooms} returns. */
43
+ export type RoomsState = {
43
44
  rooms: RoomSummary[];
44
45
  nextCursor: string | null;
45
46
  loading: boolean;
46
47
  error: Error | null;
47
48
  reload: () => void;
48
49
  };
50
+ export declare function useRooms(): RoomsState;
49
51
  /**
50
52
  * Fetches one room's detail, and optionally polls its live presence when
51
53
  * `presencePollMs` is set (polling until the presence store grows a push
52
54
  * channel). Re-fetches when the slug changes; call `reload` to refresh.
53
55
  */
54
- export declare function useRoom(slug: string, options?: {
55
- presencePollMs?: number;
56
- }): {
56
+ /** What {@link useRoom} returns. */
57
+ export type RoomState = {
57
58
  room: RoomDetail | null;
58
59
  presence: RoomPresenceSnapshot | null;
59
60
  loading: boolean;
60
61
  error: Error | null;
61
62
  reload: () => void;
62
63
  };
64
+ export declare function useRoom(slug: string, options?: {
65
+ presencePollMs?: number;
66
+ }): RoomState;
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useContext, useEffect, useState, useSyncExternalStore, } from "react";
2
2
  import { getFlow as getSingletonFlow } from "../client/create-flow";
3
3
  import { FlowContext } from "./provider";
4
- const asError = (e) => e instanceof Error ? e : new Error(String(e));
4
+ const asError = (cause) => cause instanceof Error ? cause : new Error(String(cause));
5
5
  /**
6
6
  * Returns the Flow instance to use. Resolution order:
7
7
  * 1. The instance from a `<FlowIdProvider>` ancestor (if any)
@@ -54,10 +54,6 @@ export function useFlowId() {
54
54
  getToken: flow.getToken,
55
55
  };
56
56
  }
57
- /**
58
- * Fetches the public room list once (call `reload` to refresh). Pagination
59
- * stays manual: pass the returned `nextCursor` back through `flow.rooms.list`.
60
- */
61
57
  export function useRooms() {
62
58
  const flow = useFlow();
63
59
  const [result, setResult] = useState(null);
@@ -80,11 +76,6 @@ export function useRooms() {
80
76
  reload: load,
81
77
  };
82
78
  }
83
- /**
84
- * Fetches one room's detail, and optionally polls its live presence when
85
- * `presencePollMs` is set (polling until the presence store grows a push
86
- * channel). Re-fetches when the slug changes; call `reload` to refresh.
87
- */
88
79
  export function useRoom(slug, options) {
89
80
  const flow = useFlow();
90
81
  const pollMs = options?.presencePollMs ?? 0;
@@ -26,8 +26,8 @@ export function ProfileButton({ className, host, theme }) {
26
26
  const handle = createProfileButton({
27
27
  container,
28
28
  flow,
29
- ...(host ? { host } : {}),
30
- ...(themeRef.current ? { theme: themeRef.current } : {}),
29
+ host: host ? host : undefined,
30
+ theme: themeRef.current ? themeRef.current : undefined,
31
31
  });
32
32
  handleRef.current = handle;
33
33
  return () => {
@@ -11,6 +11,7 @@ async function fetchProfile(issuerUrl, jwt, audience) {
11
11
  });
12
12
  if (!res.ok)
13
13
  return undefined;
14
+ /* SAFETY: this server's own /session/verify endpoint, called over the loopback. */
14
15
  const data = (await res.json());
15
16
  return data.payload?.profile ?? undefined;
16
17
  }
@@ -39,6 +40,6 @@ export async function resolveSession(cookieHeader, opts) {
39
40
  state,
40
41
  claims,
41
42
  setCookies,
42
- ...(profile ? { profile } : {}),
43
+ profile: profile ? profile : undefined,
43
44
  };
44
45
  }
@@ -1,11 +1,6 @@
1
- /**
2
- * Internal session-resolution core shared by `resolveSession` (SSR) and the
3
- * session route. Not part of the published API surface — `server.ts` and
4
- * `session-route.ts` compose it into the public entry points.
5
- */
6
1
  import type { FlowUser, SessionRouteSession, VerifiedFlowJWT } from "./types";
7
2
  export declare function claimsToUser(claims: VerifiedFlowJWT): FlowUser;
8
- export declare function isJwtVerificationFailure(err: unknown): boolean;
3
+ export declare function isJwtVerificationFailure(cause: unknown): boolean;
9
4
  /** The Set-Cookie pair carrying a session (1h JWT + 30-day refresh token). */
10
5
  export declare function sessionCookies(audience: string, jwt: string, refreshToken: string): string[];
11
6
  /** The Set-Cookie pair deleting both session cookies. */
@@ -1,3 +1,4 @@
1
+ import { errField } from "./driver-error";
1
2
  /**
2
3
  * Internal session-resolution core shared by `resolveSession` (SSR) and the
3
4
  * session route. Not part of the published API surface — `server.ts` and
@@ -9,7 +10,7 @@ import { verifyFlowJWT } from "./verify";
9
10
  export function claimsToUser(claims) {
10
11
  return {
11
12
  id: claims.sub,
12
- username: typeof claims.username === "string" ? claims.username : "",
13
+ username: String(claims.username) === claims.username ? claims.username : "",
13
14
  // Fail-safe: only an explicit `false` marks a full account; a missing or
14
15
  // mangled claim must never grant full privileges.
15
16
  isGuest: claims.guest !== false,
@@ -25,6 +26,8 @@ async function refreshSession(issuerUrl, token) {
25
26
  return "dead";
26
27
  if (!res.ok)
27
28
  return null;
29
+ /* SAFETY: the response is this issuer's own /session/restore payload; a non-ok status has
30
+ already thrown above. */
28
31
  return (await res.json());
29
32
  }
30
33
  catch {
@@ -57,9 +60,9 @@ function refreshOnce(issuerUrl, token) {
57
60
  // fetch failure, timeouts — is transient infrastructure trouble, and falling
58
61
  // through to a rotation there would turn a JWKS outage into a rotation storm
59
62
  // against the same struggling server.
60
- export function isJwtVerificationFailure(err) {
61
- const code = err?.code;
62
- if (typeof code !== "string")
63
+ export function isJwtVerificationFailure(cause) {
64
+ const code = errField(cause, "code");
65
+ if (!code)
63
66
  return false;
64
67
  return (code.startsWith("ERR_JWT") ||
65
68
  code.startsWith("ERR_JWS") ||
@@ -1,23 +1,3 @@
1
- /**
2
- * The first-party session route every consumer app serves from its own
3
- * origin — the only path between a browser and the refresh token. The
4
- * rotating refresh token lives in an HttpOnly cookie owned by the app's
5
- * server; the browser client calls this route instead of ever holding the
6
- * token itself:
7
- *
8
- * - `GET` resolve: fresh JWT from the cookies, rotating server-side when
9
- * the access token is expiring.
10
- * - `POST` install: one-time handoff of a freshly minted session (refresh
11
- * token + access JWT from the dialog) into the HttpOnly cookies.
12
- * The JWT verifies locally against the issuer's cached JWKS — no
13
- * rotation of a seconds-old token; a bogus refresh token simply
14
- * dies at the first GET, like any planted cookie.
15
- * - `DELETE` sign-out: revoke the token's lineage at the issuer and clear
16
- * the cookies.
17
- *
18
- * Framework-agnostic (`Request` → `Response`); `flowSessionHandlers` from
19
- * `@flow-industries/id/start` adapts it to a TanStack Start route file.
20
- */
21
1
  import type { SessionRouteOptions } from "./types";
22
2
  /**
23
3
  * Handles one session-route request (no path check — the caller routed it).
@@ -1,3 +1,4 @@
1
+ import { z } from "zod";
1
2
  /**
2
3
  * The first-party session route every consumer app serves from its own
3
4
  * origin — the only path between a browser and the refresh token. The
@@ -22,6 +23,21 @@ import { cookieNamesFor, parseCookieHeader } from "./cookies";
22
23
  import { DEFAULT_SESSION_PATH, defaultAudience, resolveIdHost, } from "./id-host";
23
24
  import { claimsToUser, clearingCookies, isJwtVerificationFailure, resolveSessionCore, sessionCookies, } from "./session-core";
24
25
  import { verifyFlowJWT } from "./verify";
26
+ /** The body the browser SDK posts to its own app's session route. */
27
+ const installBodySchema = z.object({
28
+ refreshToken: z.string().min(1).optional(),
29
+ jwt: z.string().min(1).optional(),
30
+ mint: z.array(z.string()).optional(),
31
+ });
32
+ const additionalBodySchema = z.object({
33
+ additionalSessions: z
34
+ .array(z.object({
35
+ audience: z.string(),
36
+ jwt: z.string(),
37
+ refreshToken: z.string(),
38
+ }))
39
+ .optional(),
40
+ });
25
41
  function json(body, status, setCookies = []) {
26
42
  const headers = new Headers({
27
43
  "Content-Type": "application/json",
@@ -65,14 +81,15 @@ export async function handleSessionRequest(request, opts = {}) {
65
81
  return json({ state: session }, 200, setCookies);
66
82
  }
67
83
  case "POST": {
68
- const body = (await request.json().catch(() => null));
84
+ const parsed = installBodySchema.safeParse(await request.json().catch(() => null));
85
+ const body = parsed.success ? parsed.data : null;
69
86
  // Reconcile mint: the browser asks its own server to obtain additional-
70
87
  // audience sessions (e.g. talk.flow.game) authorized by the PRIMARY
71
88
  // refresh cookie, for an embed that booted with no session of its own.
72
89
  // The refresh token never touches page JS — it's read from the HttpOnly
73
90
  // cookie here and presented to the issuer as a bearer.
74
- if (Array.isArray(body?.mint)) {
75
- const audiences = body.mint.filter((a) => typeof a === "string");
91
+ if (body?.mint) {
92
+ const audiences = body.mint;
76
93
  const names = cookieNamesFor(audience);
77
94
  const token = parseCookieHeader(request.headers.get("Cookie"))[names.refresh];
78
95
  let additionalSessions = [];
@@ -87,8 +104,10 @@ export async function handleSessionRequest(request, opts = {}) {
87
104
  body: JSON.stringify({ audiences }),
88
105
  });
89
106
  if (res.ok) {
90
- const data = (await res.json());
91
- additionalSessions = data.additionalSessions ?? [];
107
+ const data = additionalBodySchema.safeParse(await res.json());
108
+ additionalSessions = data.success
109
+ ? (data.data.additionalSessions ?? [])
110
+ : [];
92
111
  }
93
112
  }
94
113
  catch { }
@@ -97,10 +116,7 @@ export async function handleSessionRequest(request, opts = {}) {
97
116
  }
98
117
  const refreshToken = body?.refreshToken;
99
118
  const jwt = body?.jwt;
100
- if (typeof refreshToken !== "string" ||
101
- refreshToken.length === 0 ||
102
- typeof jwt !== "string" ||
103
- jwt.length === 0) {
119
+ if (!refreshToken || !jwt) {
104
120
  return json({ state: null }, 400);
105
121
  }
106
122
  // The audience check on the verified JWT is what stops a token minted
@@ -0,0 +1,8 @@
1
+ import type { GracefulShutdownCleanup, GracefulShutdownOptions, GracefulShutdownServer } from "../types";
2
+ export declare const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 20000;
3
+ /**
4
+ * Install one process-wide graceful-shutdown handler for a Bun-compatible
5
+ * server. The first signal stops new accepts, drains active HTTP and consumer
6
+ * cleanup together, force-closes at the deadline, and exits successfully.
7
+ */
8
+ export declare function installGracefulShutdown(server: GracefulShutdownServer | null | undefined, options?: GracefulShutdownOptions): GracefulShutdownCleanup;
@@ -0,0 +1,81 @@
1
+ export const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 20_000;
2
+ let installation;
3
+ function timeoutMs(options) {
4
+ const configured = options.timeoutMs;
5
+ if (configured === undefined)
6
+ return DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS;
7
+ if (!Number.isFinite(configured) || configured < 0) {
8
+ return DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS;
9
+ }
10
+ return configured;
11
+ }
12
+ function deadline(ms) {
13
+ let timer;
14
+ return {
15
+ promise: new Promise((resolve) => {
16
+ timer = setTimeout(() => resolve(false), ms);
17
+ }),
18
+ cancel: () => {
19
+ if (timer !== undefined)
20
+ clearTimeout(timer);
21
+ },
22
+ };
23
+ }
24
+ async function shutdown(current, signal) {
25
+ let gracefulStop;
26
+ try {
27
+ gracefulStop = Promise.resolve(current.server.stop(false));
28
+ }
29
+ catch (error) {
30
+ gracefulStop = Promise.reject(error);
31
+ }
32
+ const consumerDrain = Promise.resolve().then(() => current.options.onShutdown?.(signal));
33
+ const drained = Promise.allSettled([gracefulStop, consumerDrain]).then(() => true);
34
+ const timeout = deadline(timeoutMs(current.options));
35
+ const completed = await Promise.race([drained, timeout.promise]);
36
+ timeout.cancel();
37
+ if (!completed) {
38
+ try {
39
+ void current.server.stop(true);
40
+ }
41
+ catch {
42
+ // The configured deadline is authoritative even if forced close fails.
43
+ }
44
+ }
45
+ process.exit(0);
46
+ }
47
+ function handleSignal(signal) {
48
+ const current = installation;
49
+ if (!current || current.shuttingDown)
50
+ return;
51
+ current.shuttingDown = true;
52
+ void shutdown(current, signal);
53
+ }
54
+ function handleSigterm() {
55
+ handleSignal("SIGTERM");
56
+ }
57
+ function handleSigint() {
58
+ handleSignal("SIGINT");
59
+ }
60
+ /**
61
+ * Install one process-wide graceful-shutdown handler for a Bun-compatible
62
+ * server. The first signal stops new accepts, drains active HTTP and consumer
63
+ * cleanup together, force-closes at the deadline, and exits successfully.
64
+ */
65
+ export function installGracefulShutdown(server, options = {}) {
66
+ if (!server)
67
+ return () => undefined;
68
+ if (installation)
69
+ return installation.cleanup;
70
+ const cleanup = () => {
71
+ if (!installation || installation.cleanup !== cleanup)
72
+ return;
73
+ process.off("SIGTERM", handleSigterm);
74
+ process.off("SIGINT", handleSigint);
75
+ installation = undefined;
76
+ };
77
+ installation = { cleanup, options, server, shuttingDown: false };
78
+ process.on("SIGTERM", handleSigterm);
79
+ process.on("SIGINT", handleSigint);
80
+ return cleanup;
81
+ }
@@ -22,6 +22,8 @@
22
22
  */
23
23
  import { type ReactNode } from "react";
24
24
  import type { AdditionalSession, FlowSessionState } from "../types";
25
+ export type { GracefulShutdownCleanup, GracefulShutdownOptions, GracefulShutdownServer, GracefulShutdownSignal, } from "../types";
26
+ export { DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS, installGracefulShutdown, } from "./graceful-shutdown";
25
27
  /**
26
28
  * The `beforeLoad` body: during SSR resolve the session server-side (via the
27
29
  * app's `getFlowSession` server fn, which appends rotated Set-Cookie values);
@@ -24,13 +24,14 @@ import { jsx as _jsx } from "react/jsx-runtime";
24
24
  import { useMemo } from "react";
25
25
  import { createFlow, createStaticFlow, getFlow } from "../client";
26
26
  import { FlowIdProvider } from "../react";
27
+ export { DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS, installGracefulShutdown, } from "./graceful-shutdown";
27
28
  /**
28
29
  * The `beforeLoad` body: during SSR resolve the session server-side (via the
29
30
  * app's `getFlowSession` server fn, which appends rotated Set-Cookie values);
30
31
  * in the browser snapshot the live singleton instead — no network.
31
32
  */
32
33
  export async function flowSessionContext(getSession) {
33
- if (typeof window === "undefined")
34
+ if (!("window" in globalThis))
34
35
  return { session: await getSession() };
35
36
  const flow = getFlow();
36
37
  const session = flow?.user && flow.jwt
@@ -45,7 +46,7 @@ export async function flowSessionContext(getSession) {
45
46
  * is fresh.
46
47
  */
47
48
  export function FlowRoot({ session, autoGuest, host, additionalAudiences, onAdditionalSessions, children, }) {
48
- const flow = useMemo(() => typeof window === "undefined"
49
+ const flow = useMemo(() => !("window" in globalThis)
49
50
  ? createStaticFlow(session, { host })
50
51
  : createFlow({
51
52
  initialState: session,
@@ -1,3 +1,4 @@
1
+ import { isNum, isRecord } from "./json";
1
2
  // Refresh a little before the JWT's `exp` so a token handed out to a caller
2
3
  // is still valid by the time it reaches the relying party, absorbing request
3
4
  // latency and minor client/server clock skew.
@@ -10,7 +11,8 @@ export function jwtExp(token) {
10
11
  try {
11
12
  const padded = (parts[1] ?? "").replace(/-/g, "+").replace(/_/g, "/");
12
13
  const payload = JSON.parse(atob(padded));
13
- return typeof payload.exp === "number" ? payload.exp : null;
14
+ const exp = isRecord(payload) ? payload.exp : undefined;
15
+ return isNum(exp) ? exp : null;
14
16
  }
15
17
  catch {
16
18
  return null;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Cosmetic items, the space they occupy on the model, and the equipped set a
3
+ * game server receives on `/api/session/verify`.
4
+ *
5
+ * Two region vocabularies live here and must never be collapsed into one:
6
+ * {@link EquipRegion} is *item vs item* (can these be worn together), while
7
+ * {@link BodyRegion} is *cosmetic vs body* (what geometry must stop drawing so
8
+ * nothing pokes through). TF2 keeps them separate — equip regions and
9
+ * bodygroups — and conflating them is what produces the clipping bugs.
10
+ *
11
+ * {@link CosmeticMaterial} is a third vocabulary and belongs to neither: it
12
+ * names a *surface* of the model rather than a space on it, and it is what a
13
+ * colour is applied to.
14
+ */
15
+ /**
16
+ * A space on the model an item occupies. Two items occupying the same region,
17
+ * or overlapping ones, cannot be equipped together.
18
+ *
19
+ * A deliberately small subset of TF2's 68: enough to express the composite
20
+ * case (`whole_head` swallowing the pieces of a head) that makes the rule
21
+ * non-trivial, without inventing regions no item claims yet.
22
+ */
23
+ export type EquipRegion = "whole_head" | "hat" | "hair" | "face" | "glasses" | "ears" | "torso" | "arms" | "back" | "legs" | "feet";
24
+ /**
25
+ * Every body area a cosmetic may suppress — the bodygroup half's whole
26
+ * vocabulary, head down.
27
+ *
28
+ * SOURCE OF TRUTH: the `regions` keys of
29
+ * `game/games/arena/player/luna_bodygroups.tres`, which is authored against
30
+ * the shipped model. This is a mirror, kept as a constant so auth can resolve
31
+ * a loadout without reaching into the game repo at runtime.
32
+ *
33
+ * WHEN THE MODEL IS RE-CUT: update this list to the new `regions` keys, then
34
+ * fix whatever `hides` in the catalog no longer resolves. `cosmetics.test.ts`
35
+ * fails on the first entry that names a region absent here, and the type below
36
+ * makes the same mistake a typecheck error — which is the whole point of
37
+ * mirroring the names into one constant. AUTH-210 is what happens without it:
38
+ * the catalog was written from a nine-region proposal in
39
+ * `docs/internal/cosmetics/bodygroups.md` section 6, the fifteen-region cut
40
+ * that actually shipped shared not one name with it, and every cosmetic
41
+ * silently suppressed nothing.
42
+ */
43
+ export declare const BODY_REGIONS: readonly ["head", "ears", "hair", "body_neck", "body_chest", "body_arm_upper_l", "body_arm_upper_r", "body_arm_lower_l", "body_arm_lower_r", "body_hand_l", "body_hand_r", "body_pelvis", "body_leg_upper_l", "body_leg_upper_r", "body_leg_lower_l", "body_leg_lower_r", "body_foot_l", "body_foot_r"];
44
+ /**
45
+ * A body area a cosmetic suppresses so the body cannot clip through it — the
46
+ * bodygroup half. Derived from {@link BODY_REGIONS} rather than written out a
47
+ * second time, so the list and the type cannot disagree.
48
+ */
49
+ export type BodyRegion = (typeof BODY_REGIONS)[number];
50
+ /**
51
+ * Every material the shipped model set exposes, body and cosmetic alike — the
52
+ * surfaces a colour can be applied to.
53
+ *
54
+ * SOURCE OF TRUTH: the glTF material names inside
55
+ * `game/games/arena/resources/models/LUNA12.7.glb` (`skin`, `brownhair`,
56
+ * `tshirt`, `shorts`) and the per-cosmetic GLBs beside it
57
+ * (`cosmetics/LUNA_boots.glb` names `Shoes`, `cosmetics/LUNA_glasses.glb`
58
+ * names `outline`). `Shoes` is capitalised because the model capitalises it: a
59
+ * tidied-up copy here would name a material no renderer can find, and the
60
+ * paint would silently land nowhere.
61
+ *
62
+ * WHEN THE MODEL IS RE-CUT: update this list to the new material names, then
63
+ * fix whatever `paintable` in the catalog no longer resolves. This is the same
64
+ * mirror-and-guard {@link BODY_REGIONS} is, kept for the same reason —
65
+ * AUTH-210 is what a hardcoded copy with no test looks like a month later.
66
+ */
67
+ export declare const COSMETIC_MATERIALS: readonly ["skin", "brownhair", "tshirt", "shorts", "Shoes", "outline"];
68
+ /**
69
+ * A material a cosmetic may expose for recolouring. Derived from
70
+ * {@link COSMETIC_MATERIALS} rather than written out a second time, so the
71
+ * list and the type cannot disagree.
72
+ */
73
+ export type CosmeticMaterial = (typeof COSMETIC_MATERIALS)[number];
74
+ /**
75
+ * One equipment slot. The slot name IS the key inside the `appearance` blob,
76
+ * so equipped state needs no table and no migration of its own.
77
+ *
78
+ * The vocabulary is complete rather than "the slots something is sold for":
79
+ * `hat` has no product today and is still a slot, because the wire format, the
80
+ * game's whitelist and the region rules all speak in slots, and a vocabulary
81
+ * that shrank whenever a slot emptied would churn the contract every time
82
+ * content lands.
83
+ */
84
+ export type CosmeticSlot = "hat" | "face" | "top" | "bottom" | "feet";
85
+ /** One item in the catalog. */
86
+ export interface CosmeticItem {
87
+ id: string;
88
+ slot: CosmeticSlot;
89
+ name: string;
90
+ /**
91
+ * ALWAYS plural, even for a single region, and the only shape there is: TF2
92
+ * carried a singular `equip_region` key alongside the plural one, and items
93
+ * that declared several regions through the singular form silently escaped
94
+ * enforcement (Source-1-Games#4678). A singular field is never added here.
95
+ */
96
+ regions: readonly EquipRegion[];
97
+ /**
98
+ * Body regions this item suppresses. Required rather than optional: a
99
+ * cosmetic that declares nothing is a content bug, not a default, so an
100
+ * author must write `[]` deliberately (an accessory that covers no skin)
101
+ * instead of forgetting the field on a cosmetic that does.
102
+ */
103
+ hides: readonly BodyRegion[];
104
+ /**
105
+ * The materials on this item a player may recolour. Plural for the reason
106
+ * {@link CosmeticItem.regions} is, and required for the reason
107
+ * {@link CosmeticItem.hides} is: `[]` is an item stating that nothing on it
108
+ * takes paint, while a missing field is an author who never considered the
109
+ * question. The fault is silence, not emptiness.
110
+ */
111
+ paintable: readonly CosmeticMaterial[];
112
+ }
113
+ /**
114
+ * One painted surface on an equipped item, as `/api/session/verify` hands it
115
+ * to a renderer: recolour this material on this item to this colour.
116
+ *
117
+ * A LIST per item rather than a `material -> colour` map, because a map is
118
+ * exactly the shape that cannot grow: GAME-178 paints a masked region of an
119
+ * item instead of its whole surface, which arrives as another field here and a
120
+ * SECOND entry naming the same material.
121
+ */
122
+ export interface CosmeticPaint {
123
+ material: CosmeticMaterial;
124
+ color: string;
125
+ }
126
+ /**
127
+ * One recolourable surface the equipped set exposes: which worn item, which of
128
+ * its materials, and the `appearance` key holding the colour.
129
+ */
130
+ export interface PaintSlot {
131
+ key: string;
132
+ slot: CosmeticSlot;
133
+ item: CosmeticItem;
134
+ material: CosmeticMaterial;
135
+ }
136
+ /** One equipped item as `/api/session/verify` hands it to a game server. */
137
+ export interface EquippedCosmetic {
138
+ id: string;
139
+ name: string;
140
+ regions: readonly EquipRegion[];
141
+ hides: readonly BodyRegion[];
142
+ /** The colours to apply, one entry per material the player has painted. */
143
+ paint: readonly CosmeticPaint[];
144
+ }
145
+ /** The verified equipped set, keyed by slot. An absent slot is empty. */
146
+ export type EquippedCosmetics = Partial<Record<CosmeticSlot, EquippedCosmetic>>;
147
+ /** The two items that cannot be worn together, and the region they fight over. */
148
+ export interface EquipConflict {
149
+ region: EquipRegion;
150
+ items: [string, string];
151
+ slots: [CosmeticSlot, CosmeticSlot];
152
+ }
153
+ /**
154
+ * Why a settings write was refused by its surface's cross-field rule. Returned
155
+ * from the one write path and rendered as the 409 body, so a client can mark
156
+ * the offending keys instead of guessing which of them lost.
157
+ */
158
+ export interface SettingsConflict {
159
+ code: string;
160
+ message: string;
161
+ keys: string[];
162
+ }