@flow-industries/id 0.17.0 → 0.19.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.
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 +13 -2
  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 +18 -3
  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 +17 -0
  25. package/dist/sdk/json.js +5 -0
  26. package/dist/sdk/react/flow-widget.d.ts +4 -0
  27. package/dist/sdk/react/flow-widget.js +19 -4
  28. package/dist/sdk/react/hooks.d.ts +8 -4
  29. package/dist/sdk/react/hooks.js +1 -10
  30. package/dist/sdk/react/profile-button.d.ts +5 -0
  31. package/dist/sdk/react/profile-button.js +20 -4
  32. package/dist/sdk/server.js +2 -1
  33. package/dist/sdk/session-core.d.ts +1 -6
  34. package/dist/sdk/session-core.js +7 -4
  35. package/dist/sdk/session-route.d.ts +0 -20
  36. package/dist/sdk/session-route.js +25 -9
  37. package/dist/sdk/start/graceful-shutdown.d.ts +8 -0
  38. package/dist/sdk/start/graceful-shutdown.js +81 -0
  39. package/dist/sdk/start/index.d.ts +2 -0
  40. package/dist/sdk/start/index.js +3 -2
  41. package/dist/sdk/token-expiry.js +3 -1
  42. package/dist/sdk/types/cosmetics.d.ts +162 -0
  43. package/dist/sdk/types/cosmetics.js +78 -0
  44. package/dist/sdk/types/game.d.ts +58 -0
  45. package/dist/sdk/types/game.js +8 -0
  46. package/dist/sdk/types/index.d.ts +8 -4
  47. package/dist/sdk/types/index.js +2 -0
  48. package/dist/sdk/types/messenger.d.ts +1 -1
  49. package/dist/sdk/types/protocol.d.ts +6 -5
  50. package/dist/sdk/types/protocol.js +10 -4
  51. package/dist/sdk/types/room-events.d.ts +49 -1
  52. package/dist/sdk/types/rooms.d.ts +53 -3
  53. package/dist/sdk/types/rooms.js +0 -1
  54. package/dist/sdk/types/sdk.d.ts +22 -5
  55. package/dist/sdk/types/server.d.ts +18 -0
  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 +11 -8
  60. package/dist/sdk/client/refresh-store.d.ts +0 -22
  61. package/dist/sdk/client/refresh-store.js +0 -66
@@ -6,22 +6,32 @@
6
6
  * object with a method on it would throw a DataCloneError at runtime.
7
7
  */
8
8
  function normalizeValue(value) {
9
- if (Array.isArray(value))
9
+ /* The `as never`s below are all the same thing — this walks a value structurally and returns
10
+ a differently-shaped one (a stripped clone, or `undefined` for what cannot be cloned),
11
+ which a `<type> -> <type>` signature cannot express. */
12
+ if (Array.isArray(value)) {
13
+ // SAFETY: the walked copy replaces the array element-for-element.
10
14
  return value.map(normalizeValue);
11
- if (typeof value === "function")
15
+ }
16
+ if (value instanceof Function) {
17
+ // SAFETY: a function cannot be structured-cloned, so it is dropped.
12
18
  return undefined;
13
- if (typeof value !== "object" || value === null)
19
+ }
20
+ if (value === null || Object(value) !== value)
14
21
  return value;
15
22
  if (Object.getPrototypeOf(value) !== Object.prototype)
16
23
  try {
17
24
  return structuredClone(value);
18
25
  }
19
26
  catch {
27
+ // SAFETY: a value even structuredClone refuses is dropped.
20
28
  return undefined;
21
29
  }
22
30
  const normalized = {};
23
- for (const [k, v] of Object.entries(value))
31
+ for (const [k, v] of Object.entries(Object(value))) {
32
+ // SAFETY: the walked value is a structured-clonable primitive or plain object.
24
33
  normalized[k] = normalizeValue(v);
34
+ }
25
35
  return normalized;
26
36
  }
27
37
  export function from(messenger) {
@@ -65,11 +75,15 @@ export function fromWindow(w, options = {}) {
65
75
  async send(topic, payload, target) {
66
76
  const id = crypto.randomUUID();
67
77
  w.postMessage(normalizeValue({ id, payload, topic }), target ?? targetOrigin ?? "*");
78
+ /* SAFETY: Bridge types `send`'s result from the topic's response map; the envelope this
79
+ transport resolves with is the request echo, which that map cannot name. */
68
80
  return { id, payload, topic };
69
81
  },
70
82
  async sendAsync(topic, payload, target) {
71
83
  const { id } = await this.send(topic, payload, target);
72
- return new Promise((resolve) => this.on(topic, resolve, id));
84
+ return new Promise((resolve) =>
85
+ // SAFETY: `topic` is the same topic this method was called with.
86
+ this.on(topic, resolve, id));
73
87
  },
74
88
  });
75
89
  }
@@ -128,6 +142,10 @@ export function bridge(parameters) {
128
142
  * `window` doesn't exist. All sends resolve to undefined and never deliver.
129
143
  * Lets the SDK be imported from anywhere without crashing on module load.
130
144
  */
145
+ /* Off-browser there is no peer to answer, so every send resolves to nothing while still
146
+ satisfying Bridge's per-topic response types. */
147
+ // SAFETY: the no-op's contract — nothing is ever delivered or answered.
148
+ const settle = () => Promise.resolve(undefined);
131
149
  export function noop() {
132
150
  return {
133
151
  destroy() { },
@@ -135,14 +153,8 @@ export function noop() {
135
153
  return () => { };
136
154
  },
137
155
  ready() { },
138
- send() {
139
- return Promise.resolve(undefined);
140
- },
141
- sendAsync() {
142
- return Promise.resolve(undefined);
143
- },
144
- waitForReady() {
145
- return Promise.resolve(undefined);
146
- },
156
+ send: settle,
157
+ sendAsync: settle,
158
+ waitForReady: settle,
147
159
  };
148
160
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Postgres driver errors are not typed by the driver, and the wrapper nests differently across
3
+ * postgres-js and PGlite. These read the fields we branch on without asserting a shape onto the
4
+ * error, and return "" / undefined when the field is absent.
5
+ */
6
+ /** Reads a named string field off a driver error. */
7
+ export declare function errField(cause: unknown, key: string): string;
8
+ export declare function errCause(cause: unknown): object | undefined;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Postgres driver errors are not typed by the driver, and the wrapper nests differently across
3
+ * postgres-js and PGlite. These read the fields we branch on without asserting a shape onto the
4
+ * error, and return "" / undefined when the field is absent.
5
+ */
6
+ /** Reads a named string field off a driver error. */
7
+ export function errField(cause, key) {
8
+ const value = Object.entries(Object(cause)).find(([k]) => k === key)?.[1];
9
+ return String(value) === value ? value : "";
10
+ }
11
+ /** The nested driver error, when the wrapper carries one. */
12
+ /** `Object(x) === x` holds only for objects, so this tests without narrowing a representation. */
13
+ const isObject = (cause) => Object(cause) === cause;
14
+ export function errCause(cause) {
15
+ const value = Object.entries(Object(cause)).find(([k]) => k === "cause")?.[1];
16
+ return isObject(value) ? value : undefined;
17
+ }
@@ -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,17 @@
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 isBool: (v: RawValue) => v is boolean;
15
+ export declare const isNum: (v: RawValue) => v is number;
16
+ export declare const isRecord: (v: RawValue) => v is RawRecord;
17
+ export declare const isStrings: (v: RawValue) => v is string[];
@@ -0,0 +1,5 @@
1
+ export const isString = (v) => String(v) === v;
2
+ export const isBool = (v) => v === true || v === false;
3
+ export const isNum = (v) => Number(v) === v && Number.isFinite(v);
4
+ export const isRecord = (v) => v !== null && v !== undefined && !Array.isArray(v) && Object(v) === v;
5
+ export const isStrings = (v) => Array.isArray(v) && v.every((entry) => isString(entry));
@@ -8,5 +8,9 @@ import type { FlowWidgetProps } from "../types";
8
8
  * Prefer this over embedding the widget's URL in your own `<iframe>`: only a
9
9
  * frame mounted this way completes the handshake that lets it authenticate as
10
10
  * the signed-in user from a cross-site page (AUTH-171).
11
+ *
12
+ * A changing `theme` recolors the mounted widget over its open bridge rather
13
+ * than remounting it — see `<ProfileButton>` for why the frame must survive a
14
+ * light/dark toggle.
11
15
  */
12
16
  export declare function FlowWidget({ widget, className, host, theme, }: FlowWidgetProps): import("react/jsx-runtime").JSX.Element;
@@ -11,10 +11,16 @@ import { useFlow } from "./hooks";
11
11
  * Prefer this over embedding the widget's URL in your own `<iframe>`: only a
12
12
  * frame mounted this way completes the handshake that lets it authenticate as
13
13
  * the signed-in user from a cross-site page (AUTH-171).
14
+ *
15
+ * A changing `theme` recolors the mounted widget over its open bridge rather
16
+ * than remounting it — see `<ProfileButton>` for why the frame must survive a
17
+ * light/dark toggle.
14
18
  */
15
19
  export function FlowWidget({ widget, className, host, theme, }) {
16
20
  const flow = useFlow();
17
21
  const ref = useRef(null);
22
+ const handleRef = useRef(null);
23
+ const themeRef = useRef(theme);
18
24
  useEffect(() => {
19
25
  const container = ref.current;
20
26
  if (!container)
@@ -23,15 +29,24 @@ export function FlowWidget({ widget, className, host, theme, }) {
23
29
  widget,
24
30
  container,
25
31
  flow,
26
- ...(host ? { host } : {}),
27
- ...(theme ? { theme } : {}),
32
+ host: host ? host : undefined,
33
+ theme: themeRef.current ? themeRef.current : undefined,
28
34
  });
35
+ handleRef.current = handle;
29
36
  if (handle.frame) {
30
37
  handle.frame.style.width = "100%";
31
38
  handle.frame.style.height = "100%";
32
39
  handle.frame.style.display = "block";
33
40
  }
34
- return () => handle.destroy();
35
- }, [widget, flow, host, theme]);
41
+ return () => {
42
+ handleRef.current = null;
43
+ handle.destroy();
44
+ };
45
+ }, [widget, flow, host]);
46
+ useEffect(() => {
47
+ themeRef.current = theme;
48
+ if (theme)
49
+ handleRef.current?.setTheme(theme);
50
+ }, [theme]);
36
51
  return _jsx("div", { ref: ref, className: className });
37
52
  }
@@ -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;
@@ -5,5 +5,10 @@ import type { ProfileButtonProps } from "../types";
5
5
  * the pill iframe mounts into; the iframe itself is sized to its content and
6
6
  * expands to the profile dialog on click. Resolves the Flow instance from a
7
7
  * `<FlowIdProvider>` or the createFlow() singleton.
8
+ *
9
+ * A changing `theme` recolors the mounted pill over its open bridge rather than
10
+ * remounting it: `theme` is deliberately absent from the mount effect's deps,
11
+ * because tearing the iframe down on a light/dark toggle reloads the widget and
12
+ * drops the pill out of the host's layout mid-toggle.
8
13
  */
9
14
  export declare function ProfileButton({ className, host, theme }: ProfileButtonProps): import("react/jsx-runtime").JSX.Element;
@@ -8,10 +8,17 @@ import { useFlow } from "./hooks";
8
8
  * the pill iframe mounts into; the iframe itself is sized to its content and
9
9
  * expands to the profile dialog on click. Resolves the Flow instance from a
10
10
  * `<FlowIdProvider>` or the createFlow() singleton.
11
+ *
12
+ * A changing `theme` recolors the mounted pill over its open bridge rather than
13
+ * remounting it: `theme` is deliberately absent from the mount effect's deps,
14
+ * because tearing the iframe down on a light/dark toggle reloads the widget and
15
+ * drops the pill out of the host's layout mid-toggle.
11
16
  */
12
17
  export function ProfileButton({ className, host, theme }) {
13
18
  const flow = useFlow();
14
19
  const ref = useRef(null);
20
+ const handleRef = useRef(null);
21
+ const themeRef = useRef(theme);
15
22
  useEffect(() => {
16
23
  const container = ref.current;
17
24
  if (!container)
@@ -19,10 +26,19 @@ export function ProfileButton({ className, host, theme }) {
19
26
  const handle = createProfileButton({
20
27
  container,
21
28
  flow,
22
- ...(host ? { host } : {}),
23
- ...(theme ? { theme } : {}),
29
+ host: host ? host : undefined,
30
+ theme: themeRef.current ? themeRef.current : undefined,
24
31
  });
25
- return () => handle.destroy();
26
- }, [flow, host, theme]);
32
+ handleRef.current = handle;
33
+ return () => {
34
+ handleRef.current = null;
35
+ handle.destroy();
36
+ };
37
+ }, [flow, host]);
38
+ useEffect(() => {
39
+ themeRef.current = theme;
40
+ if (theme)
41
+ handleRef.current?.setTheme(theme);
42
+ }, [theme]);
27
43
  return _jsx("div", { ref: ref, className: className });
28
44
  }
@@ -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);