@flow-industries/id 0.8.1 → 0.10.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.
@@ -1,35 +1,13 @@
1
+ import { clearBrowserCookie, cookieNamesFor, JWT_COOKIE_MAX_AGE_S, writeBrowserCookie, } from "../cookies";
2
+ import { isExpiring } from "../token-expiry";
1
3
  import { createDialogHost } from "./dialog-host";
2
4
  import { idb } from "./idb";
3
5
  import { METHODS } from "./methods";
4
6
  import { makeRefreshStore } from "./refresh-store";
7
+ import { createRoomsApi } from "./rooms";
5
8
  import { credentialToAddress, restoreCredential, runLogin, runLogout, } from "./session";
6
9
  import { createStore, initialFlowState } from "./store";
7
10
  const DEFAULT_HOST = "https://id.flow.industries";
8
- // Refresh a little before the JWT's `exp` so a token handed out by getToken()
9
- // is still valid by the time it reaches the relying party, absorbing request
10
- // latency and minor client/server clock skew.
11
- const EXPIRY_SKEW_MS = 60_000;
12
- /** Reads the `exp` (seconds since epoch) from a JWT, or null if unreadable. */
13
- function jwtExp(token) {
14
- const parts = token.split(".");
15
- if (parts.length !== 3)
16
- return null;
17
- try {
18
- const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
19
- const payload = JSON.parse(atob(padded));
20
- return typeof payload.exp === "number" ? payload.exp : null;
21
- }
22
- catch {
23
- return null;
24
- }
25
- }
26
- /** True if the token is missing an exp, already expired, or within the skew. */
27
- function isExpiring(token) {
28
- const exp = jwtExp(token);
29
- if (exp === null)
30
- return true;
31
- return Date.now() >= exp * 1000 - EXPIRY_SKEW_MS;
32
- }
33
11
  /**
34
12
  * Runs `fn` while holding a cross-tab lock (Web Locks API) so concurrent tabs
35
13
  * of the same origin serialize guest creation: the first tab mints and sets the
@@ -88,11 +66,22 @@ export function createFlow(options = {}) {
88
66
  // and IDB writes while components/hooks that captured it keep reading
89
67
  // stale state. For genuine multi-instance scenarios (tests), call
90
68
  // resetFlow() first or pass explicit Flow instances.
69
+ if (typeof window === "undefined") {
70
+ throw new Error("createFlow() is browser-only (localStorage, IndexedDB, iframes). " +
71
+ "For server rendering, resolve state with resolveSession() from " +
72
+ "@flow-industries/id/server and render with createStaticFlow().");
73
+ }
91
74
  if (currentFlow)
92
75
  return currentFlow;
93
76
  const host = (options.host ?? DEFAULT_HOST).replace(/\/+$/, "");
94
77
  const dialogUrl = `${host}/dialog/`;
95
- const refreshStore = makeRefreshStore(host);
78
+ const cookieNames = options.cookies
79
+ ? cookieNamesFor(window.location.origin)
80
+ : null;
81
+ const cookiesSecure = window.location.protocol === "https:";
82
+ const refreshStore = makeRefreshStore(host, cookieNames
83
+ ? { name: cookieNames.refresh, secure: cookiesSecure }
84
+ : undefined);
96
85
  const chains = options.chains ?? [];
97
86
  const getChain = (chainId) => {
98
87
  if (chainId == null)
@@ -105,6 +94,37 @@ export function createFlow(options = {}) {
105
94
  };
106
95
  const accessKeyOptions = resolveAccessKey(options.accessKey);
107
96
  const store = createStore({ ...initialFlowState });
97
+ // One subscription mirrors the JWT into its first-party cookie on every
98
+ // commit path — refresh, guest mint, login, hydration — and clears it when
99
+ // logout resets the store. Keeping the mirror here (not in each path) means
100
+ // no mint site can forget it.
101
+ if (cookieNames) {
102
+ let mirroredJwt = null;
103
+ store.subscribe((s) => {
104
+ if (s.jwt === mirroredJwt)
105
+ return;
106
+ mirroredJwt = s.jwt;
107
+ if (s.jwt) {
108
+ writeBrowserCookie(cookieNames.jwt, s.jwt, {
109
+ maxAge: JWT_COOKIE_MAX_AGE_S,
110
+ secure: cookiesSecure,
111
+ });
112
+ }
113
+ else {
114
+ clearBrowserCookie(cookieNames.jwt, { secure: cookiesSecure });
115
+ }
116
+ });
117
+ }
118
+ // Seed synchronously so the first client render matches the SSR HTML —
119
+ // hooks read the hydrated user instead of flashing signed-out while a
120
+ // bootstrap refresh runs.
121
+ if (options.initialState) {
122
+ store.setState({
123
+ user: options.initialState.user,
124
+ jwt: options.initialState.jwt,
125
+ address: options.initialState.address ?? null,
126
+ });
127
+ }
108
128
  let dialog = null;
109
129
  const getDialog = () => {
110
130
  if (!dialog)
@@ -261,6 +281,11 @@ export function createFlow(options = {}) {
261
281
  }
262
282
  void (async () => {
263
283
  await restoreCredential(store);
284
+ // A fresh hydrated JWT means the server already refreshed this request —
285
+ // running doRefresh() here would rotate a second time for nothing.
286
+ const hydrated = store.getSnapshot().jwt;
287
+ if (hydrated && !isExpiring(hydrated))
288
+ return;
264
289
  if (options.autoRestore !== false) {
265
290
  const restored = await doRefresh();
266
291
  // ensureGuest re-checks (refresh-first) under a cross-tab lock before
@@ -309,14 +334,16 @@ export function createFlow(options = {}) {
309
334
  }
310
335
  // When the current session is a guest, forward its access token so a
311
336
  // sign-up upgrades that guest in place even where the guest cookie is
312
- // unreadable (the iOS top-level sign-up popup). The server verifies it
313
- // before honoring it, so an app that never becomes a guest sends nothing.
314
- const snapshot = store.getSnapshot();
315
- if (snapshot.user?.isGuest && snapshot.jwt) {
316
- extraCapabilities = {
317
- ...(extraCapabilities ?? {}),
318
- guestToken: snapshot.jwt,
319
- };
337
+ // unreadable (the iOS top-level sign-up popup). Mint via getToken() rather
338
+ // than reading store.jwt directly: the cached JWT can be up to an hour stale
339
+ // on a long-idle tab, and an expired token fails server verification — the
340
+ // guest would then be registered as a fresh account instead of upgraded.
341
+ // An app that never becomes a guest forwards nothing.
342
+ if (store.getSnapshot().user?.isGuest) {
343
+ const guestToken = await getToken();
344
+ if (guestToken) {
345
+ extraCapabilities = { ...(extraCapabilities ?? {}), guestToken };
346
+ }
320
347
  }
321
348
  const { session, webauthn, refreshToken } = await runLogin({
322
349
  dialog: dialogHost,
@@ -392,6 +419,7 @@ export function createFlow(options = {}) {
392
419
  ensureGuest,
393
420
  refreshJwt,
394
421
  getToken,
422
+ rooms: createRoomsApi(host, getToken),
395
423
  signMessage: async (args) => {
396
424
  const mod = await import("./signing");
397
425
  return mod.signMessage(buildSigningContext(), args);
@@ -1,4 +1,3 @@
1
- import * as Messenger from "../dialog/remote/Messenger";
2
1
  import { bridgeToWindow, makeIframe } from "./iframe-host";
3
2
  const HIDDEN_STYLE = {
4
3
  position: "fixed",
@@ -1,5 +1,7 @@
1
- export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowState, FlowUser, LoginOptions, MethodName, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
1
+ export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, LoginOptions, MethodName, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
2
2
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
3
3
  export { createDialogHost } from "./dialog-host";
4
4
  export { METHODS } from "./methods";
5
5
  export { createProfileButton } from "./profile-button";
6
+ export { createRoomsApi, RoomsRequestError } from "./rooms";
7
+ export { createStaticFlow } from "./static-flow";
@@ -2,3 +2,5 @@ export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
2
2
  export { createDialogHost } from "./dialog-host";
3
3
  export { METHODS } from "./methods";
4
4
  export { createProfileButton } from "./profile-button";
5
+ export { createRoomsApi, RoomsRequestError } from "./rooms";
6
+ export { createStaticFlow } from "./static-flow";
@@ -1,4 +1,3 @@
1
- import * as Messenger from "../dialog/remote/Messenger";
2
1
  import { getFlow, requireFlow } from "./create-flow";
3
2
  import { bridgeToWindow, makeIframe } from "./iframe-host";
4
3
  const DEFAULT_HOST = "https://id.flow.industries";
@@ -1,3 +1,4 @@
1
+ import type { RefreshCookieConfig } from "../types";
1
2
  /**
2
3
  * First-party persistence for the rotating refresh token. It lives in the
3
4
  * consumer app's OWN localStorage (origin-scoped), so it survives reloads and
@@ -10,4 +11,12 @@ export interface RefreshStore {
10
11
  set(token: string): void;
11
12
  clear(): void;
12
13
  }
13
- export declare function makeRefreshStore(host: string): RefreshStore;
14
+ /**
15
+ * With a cookie config (`createFlow({ cookies: true })`) the token is
16
+ * additionally mirrored into a first-party cookie so the app's own server can
17
+ * refresh the session during SSR. Reads prefer the cookie: the only writer
18
+ * that updates one side without the other is that server (Set-Cookie only),
19
+ * and its value is always a successor of whatever localStorage still holds;
20
+ * every client-side write hits both in the same call.
21
+ */
22
+ export declare function makeRefreshStore(host: string, cookie?: RefreshCookieConfig): RefreshStore;
@@ -1,13 +1,30 @@
1
- export function makeRefreshStore(host) {
1
+ import { clearBrowserCookie, REFRESH_COOKIE_MAX_AGE_S, readBrowserCookie, writeBrowserCookie, } from "../cookies";
2
+ /**
3
+ * With a cookie config (`createFlow({ cookies: true })`) the token is
4
+ * additionally mirrored into a first-party cookie so the app's own server can
5
+ * refresh the session during SSR. Reads prefer the cookie: the only writer
6
+ * that updates one side without the other is that server (Set-Cookie only),
7
+ * and its value is always a successor of whatever localStorage still holds;
8
+ * every client-side write hits both in the same call.
9
+ */
10
+ export function makeRefreshStore(host, cookie) {
2
11
  const key = `flow.id.refresh:${host}`;
3
- return {
12
+ const readLocal = () => {
13
+ try {
14
+ return localStorage.getItem(key);
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ };
20
+ const readCookie = () => {
21
+ if (!cookie || typeof document === "undefined")
22
+ return null;
23
+ return readBrowserCookie(document.cookie, cookie.name);
24
+ };
25
+ const store = {
4
26
  get() {
5
- try {
6
- return localStorage.getItem(key);
7
- }
8
- catch {
9
- return null;
10
- }
27
+ return readCookie() ?? readLocal();
11
28
  },
12
29
  set(token) {
13
30
  // Never persist a missing token: localStorage.setItem coerces undefined
@@ -19,12 +36,31 @@ export function makeRefreshStore(host) {
19
36
  localStorage.setItem(key, token);
20
37
  }
21
38
  catch { }
39
+ if (cookie) {
40
+ writeBrowserCookie(cookie.name, token, {
41
+ maxAge: REFRESH_COOKIE_MAX_AGE_S,
42
+ secure: cookie.secure,
43
+ });
44
+ }
22
45
  },
23
46
  clear() {
24
47
  try {
25
48
  localStorage.removeItem(key);
26
49
  }
27
50
  catch { }
51
+ if (cookie)
52
+ clearBrowserCookie(cookie.name, { secure: cookie.secure });
28
53
  },
29
54
  };
55
+ // One-time reconcile so exactly one canonical value exists before the first
56
+ // refresh: an SSR rotation updates only the cookie, leaving localStorage one
57
+ // generation behind — presenting that stale token would look like reuse.
58
+ const fromCookie = readCookie();
59
+ if (fromCookie && fromCookie !== readLocal()) {
60
+ try {
61
+ localStorage.setItem(key, fromCookie);
62
+ }
63
+ catch { }
64
+ }
65
+ return store;
30
66
  }
@@ -0,0 +1,14 @@
1
+ import type { RoomsApi } from "../types";
2
+ /** Thrown on any non-2xx rooms response; `reason` carries the machine-readable
3
+ * cause when the API provides one (e.g. "banned", "private"). */
4
+ export declare class RoomsRequestError extends Error {
5
+ readonly status: number;
6
+ readonly reason?: string | undefined;
7
+ constructor(status: number, message: string, reason?: string | undefined);
8
+ }
9
+ /**
10
+ * Thin typed client for the room registry, bound to a host and the Flow
11
+ * token supplier. Public reads work signed-out; mutations attach the caller's
12
+ * bearer and fail fast with a 401-shaped error when no session exists.
13
+ */
14
+ export declare function createRoomsApi(host: string, getToken: () => Promise<string | null>): RoomsApi;
@@ -0,0 +1,86 @@
1
+ /** Thrown on any non-2xx rooms response; `reason` carries the machine-readable
2
+ * cause when the API provides one (e.g. "banned", "private"). */
3
+ export class RoomsRequestError extends Error {
4
+ status;
5
+ reason;
6
+ constructor(status, message, reason) {
7
+ super(message);
8
+ this.status = status;
9
+ this.reason = reason;
10
+ this.name = "RoomsRequestError";
11
+ }
12
+ }
13
+ /**
14
+ * Thin typed client for the room registry, bound to a host and the Flow
15
+ * token supplier. Public reads work signed-out; mutations attach the caller's
16
+ * bearer and fail fast with a 401-shaped error when no session exists.
17
+ */
18
+ export function createRoomsApi(host, getToken) {
19
+ async function request(path, options = {}) {
20
+ const headers = {};
21
+ const token = await getToken();
22
+ if (token)
23
+ headers.Authorization = `Bearer ${token}`;
24
+ else if (options.authRequired) {
25
+ throw new RoomsRequestError(401, "Sign in first");
26
+ }
27
+ if (options.body !== undefined)
28
+ headers["Content-Type"] = "application/json";
29
+ const res = await fetch(`${host}/api/rooms${path}`, {
30
+ method: options.method ?? "GET",
31
+ headers,
32
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
33
+ });
34
+ const payload = (await res.json().catch(() => ({})));
35
+ if (!res.ok) {
36
+ throw new RoomsRequestError(res.status, typeof payload.error === "string"
37
+ ? payload.error
38
+ : `Rooms request failed (${res.status})`, typeof payload.reason === "string" ? payload.reason : undefined);
39
+ }
40
+ return payload;
41
+ }
42
+ const slugPath = (slug) => `/${encodeURIComponent(slug)}`;
43
+ const act = (slug, action, body) => request(`${slugPath(slug)}/${action}`, {
44
+ method: "POST",
45
+ body,
46
+ authRequired: true,
47
+ }).then(() => undefined);
48
+ return {
49
+ list(options) {
50
+ const params = new URLSearchParams();
51
+ if (options?.cursor)
52
+ params.set("cursor", options.cursor);
53
+ if (options?.limit)
54
+ params.set("limit", String(options.limit));
55
+ const query = params.size ? `?${params}` : "";
56
+ return request(query);
57
+ },
58
+ get: (slug) => request(slugPath(slug)),
59
+ mine: () => request("/mine", { authRequired: true }),
60
+ create: (input) => request("", {
61
+ method: "POST",
62
+ body: input,
63
+ authRequired: true,
64
+ }),
65
+ update: (slug, patch) => request(`${slugPath(slug)}/update`, {
66
+ method: "POST",
67
+ body: patch,
68
+ authRequired: true,
69
+ }),
70
+ remove: (slug) => act(slug, "delete", {}),
71
+ join: (slug) => act(slug, "join", {}),
72
+ leave: (slug) => act(slug, "leave", {}),
73
+ members: (slug) => request(`${slugPath(slug)}/members`, { authRequired: true }),
74
+ presence: (slug) => request(`${slugPath(slug)}/presence`),
75
+ settings: (slug, surface) => request(`${slugPath(slug)}/settings/${encodeURIComponent(surface)}`),
76
+ saveSettings: (slug, surface, settings) => request(`${slugPath(slug)}/settings/${encodeURIComponent(surface)}`, { method: "POST", body: { settings }, authRequired: true }),
77
+ transfer: (slug, userId) => act(slug, "transfer", { userId }),
78
+ claim: (slug) => act(slug, "claim", {}),
79
+ setRole: (slug, userId, role) => act(slug, "role", { userId, role }),
80
+ kick: (slug, userId) => act(slug, "kick", { userId }),
81
+ ban: (slug, userId, options) => act(slug, "ban", { userId, ...options }),
82
+ unban: (slug, userId) => act(slug, "unban", { userId }),
83
+ mute: (slug, userId, options) => act(slug, "mute", { userId, ...options }),
84
+ unmute: (slug, userId) => act(slug, "unmute", { userId }),
85
+ };
86
+ }
@@ -0,0 +1,14 @@
1
+ import type { Flow, FlowSessionState } from "../types";
2
+ /**
3
+ * A frozen, per-request Flow for server rendering: hooks and components read
4
+ * the state `resolveSession()` resolved for THIS request, so the SSR HTML
5
+ * matches what the hydrated client (seeded via `createFlow({ initialState })`)
6
+ * renders first. Never touches the module singleton — concurrent requests
7
+ * must not share auth state.
8
+ *
9
+ * Reads work (`user`, `jwt`, `getToken`, `getState`, `rooms` — a plain fetch
10
+ * API); anything interactive (login, dialog, signing) throws.
11
+ */
12
+ export declare function createStaticFlow(state: FlowSessionState | null, options?: {
13
+ host?: string;
14
+ }): Flow;
@@ -0,0 +1,65 @@
1
+ import { createRoomsApi } from "./rooms";
2
+ const DEFAULT_HOST = "https://id.flow.industries";
3
+ function unavailable(method) {
4
+ throw new Error(`flow.${method} is not available on a static Flow — it renders ` +
5
+ "server-resolved state only. Interactive methods need the browser " +
6
+ "client from createFlow().");
7
+ }
8
+ /**
9
+ * A frozen, per-request Flow for server rendering: hooks and components read
10
+ * the state `resolveSession()` resolved for THIS request, so the SSR HTML
11
+ * matches what the hydrated client (seeded via `createFlow({ initialState })`)
12
+ * renders first. Never touches the module singleton — concurrent requests
13
+ * must not share auth state.
14
+ *
15
+ * Reads work (`user`, `jwt`, `getToken`, `getState`, `rooms` — a plain fetch
16
+ * API); anything interactive (login, dialog, signing) throws.
17
+ */
18
+ export function createStaticFlow(state, options = {}) {
19
+ const host = (options.host ?? DEFAULT_HOST).replace(/\/+$/, "");
20
+ const snapshot = Object.freeze({
21
+ user: state?.user ?? null,
22
+ jwt: state?.jwt ?? null,
23
+ credential: null,
24
+ address: state?.address ?? null,
25
+ });
26
+ const getToken = async () => snapshot.jwt;
27
+ return {
28
+ get user() {
29
+ return snapshot.user;
30
+ },
31
+ get jwt() {
32
+ return snapshot.jwt;
33
+ },
34
+ get credential() {
35
+ return snapshot.credential;
36
+ },
37
+ get address() {
38
+ return snapshot.address;
39
+ },
40
+ get isAuthenticated() {
41
+ return snapshot.user !== null;
42
+ },
43
+ get isGuest() {
44
+ return snapshot.user?.isGuest === true;
45
+ },
46
+ login: () => unavailable("login"),
47
+ logout: () => unavailable("logout"),
48
+ restore: async () => false,
49
+ ensureGuest: () => unavailable("ensureGuest"),
50
+ refreshJwt: async () => false,
51
+ getToken,
52
+ rooms: createRoomsApi(host, getToken),
53
+ signMessage: () => unavailable("signMessage"),
54
+ signTypedData: () => unavailable("signTypedData"),
55
+ sendTransaction: () => unavailable("sendTransaction"),
56
+ sendCalls: () => unavailable("sendCalls"),
57
+ walletClient: () => unavailable("walletClient"),
58
+ subscribe: () => () => { },
59
+ getState: () => snapshot,
60
+ get dialog() {
61
+ return unavailable("dialog");
62
+ },
63
+ host,
64
+ };
65
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * First-party cookie names and (de)serialization shared by the browser SDK
3
+ * (which mirrors the session into cookies) and the server helper (which reads
4
+ * them per-request to render auth-aware UI without a client round-trip).
5
+ * Isomorphic: no browser globals at import time.
6
+ */
7
+ import type { FlowCookieNames } from "./types";
8
+ export declare const JWT_COOKIE_MAX_AGE_S: number;
9
+ export declare const REFRESH_COOKIE_MAX_AGE_S: number;
10
+ /**
11
+ * Cookie names for an app origin. On https the `__Host-` prefix makes the
12
+ * browser enforce Secure + Path=/ + host-only, so no sibling subdomain can
13
+ * plant a shadowing cookie with `Domain=` (session fixation). On localhost
14
+ * (plain http, where `__Host-` is unavailable) the port is folded into the
15
+ * name instead — cookie jars ignore ports, so without the suffix parallel
16
+ * dev stacks on different ports would overwrite each other's sessions.
17
+ */
18
+ export declare function cookieNamesFor(origin: string): FlowCookieNames;
19
+ /**
20
+ * Builds a Set-Cookie value. Host-only (no Domain), `SameSite=Lax`, `Path=/`;
21
+ * `Secure` everywhere except plain-http localhost (Safari drops Secure
22
+ * cookies set over http). Never HttpOnly — the browser SDK reads and writes
23
+ * these same cookies.
24
+ */
25
+ export declare function serializeCookie(name: string, value: string, opts: {
26
+ maxAge: number;
27
+ secure: boolean;
28
+ }): string;
29
+ /** Builds a Set-Cookie value that deletes the cookie. */
30
+ export declare function clearCookieString(name: string, opts: {
31
+ secure: boolean;
32
+ }): string;
33
+ /** Parses a Cookie request header into name → value; first occurrence wins (RFC 6265 practice). */
34
+ export declare function parseCookieHeader(header: string | null | undefined): Record<string, string>;
35
+ /** Reads one cookie from a `document.cookie` string. */
36
+ export declare function readBrowserCookie(cookieString: string, name: string): string | null;
37
+ /** Writes a cookie in the browser; no-op outside it. */
38
+ export declare function writeBrowserCookie(name: string, value: string, opts: {
39
+ maxAge: number;
40
+ secure: boolean;
41
+ }): void;
42
+ /** Deletes a cookie in the browser; no-op outside it. */
43
+ export declare function clearBrowserCookie(name: string, opts: {
44
+ secure: boolean;
45
+ }): void;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * First-party cookie names and (de)serialization shared by the browser SDK
3
+ * (which mirrors the session into cookies) and the server helper (which reads
4
+ * them per-request to render auth-aware UI without a client round-trip).
5
+ * Isomorphic: no browser globals at import time.
6
+ */
7
+ export const JWT_COOKIE_MAX_AGE_S = 60 * 60;
8
+ export const REFRESH_COOKIE_MAX_AGE_S = 30 * 24 * 60 * 60;
9
+ /**
10
+ * Cookie names for an app origin. On https the `__Host-` prefix makes the
11
+ * browser enforce Secure + Path=/ + host-only, so no sibling subdomain can
12
+ * plant a shadowing cookie with `Domain=` (session fixation). On localhost
13
+ * (plain http, where `__Host-` is unavailable) the port is folded into the
14
+ * name instead — cookie jars ignore ports, so without the suffix parallel
15
+ * dev stacks on different ports would overwrite each other's sessions.
16
+ */
17
+ export function cookieNamesFor(origin) {
18
+ let prefix = "";
19
+ let suffix = "";
20
+ try {
21
+ const url = new URL(origin);
22
+ if (url.protocol === "https:")
23
+ prefix = "__Host-";
24
+ const local = url.hostname === "localhost" ||
25
+ url.hostname === "127.0.0.1" ||
26
+ url.hostname === "[::1]";
27
+ if (local && url.port)
28
+ suffix = `_p${url.port}`;
29
+ }
30
+ catch { }
31
+ return {
32
+ jwt: `${prefix}flow_id.jwt${suffix}`,
33
+ refresh: `${prefix}flow_id.refresh${suffix}`,
34
+ };
35
+ }
36
+ /**
37
+ * Builds a Set-Cookie value. Host-only (no Domain), `SameSite=Lax`, `Path=/`;
38
+ * `Secure` everywhere except plain-http localhost (Safari drops Secure
39
+ * cookies set over http). Never HttpOnly — the browser SDK reads and writes
40
+ * these same cookies.
41
+ */
42
+ export function serializeCookie(name, value, opts) {
43
+ const secure = opts.secure ? "; Secure" : "";
44
+ return `${name}=${value}; Path=/; SameSite=Lax; Max-Age=${opts.maxAge}${secure}`;
45
+ }
46
+ /** Builds a Set-Cookie value that deletes the cookie. */
47
+ export function clearCookieString(name, opts) {
48
+ return serializeCookie(name, "", { maxAge: 0, secure: opts.secure });
49
+ }
50
+ /** Parses a Cookie request header into name → value; first occurrence wins (RFC 6265 practice). */
51
+ export function parseCookieHeader(header) {
52
+ const out = {};
53
+ if (!header)
54
+ return out;
55
+ for (const part of header.split(";")) {
56
+ const eq = part.indexOf("=");
57
+ if (eq === -1)
58
+ continue;
59
+ const name = part.slice(0, eq).trim();
60
+ if (!name || name in out)
61
+ continue;
62
+ out[name] = part.slice(eq + 1).trim();
63
+ }
64
+ return out;
65
+ }
66
+ /** Reads one cookie from a `document.cookie` string. */
67
+ export function readBrowserCookie(cookieString, name) {
68
+ return parseCookieHeader(cookieString)[name] ?? null;
69
+ }
70
+ /** Writes a cookie in the browser; no-op outside it. */
71
+ export function writeBrowserCookie(name, value, opts) {
72
+ if (typeof document === "undefined")
73
+ return;
74
+ document.cookie = serializeCookie(name, value, opts);
75
+ }
76
+ /** Deletes a cookie in the browser; no-op outside it. */
77
+ export function clearBrowserCookie(name, opts) {
78
+ if (typeof document === "undefined")
79
+ return;
80
+ document.cookie = clearCookieString(name, opts);
81
+ }
@@ -1,4 +1,4 @@
1
- import type { Flow, FlowState } from "../types";
1
+ import type { Flow, FlowState, RoomDetail, RoomPresenceSnapshot, RoomSummary } from "../types";
2
2
  /**
3
3
  * Returns the Flow instance to use. Resolution order:
4
4
  * 1. The instance from a `<FlowIdProvider>` ancestor (if any)
@@ -35,3 +35,28 @@ export declare function useFlowId(): {
35
35
  refreshJwt: () => Promise<boolean>;
36
36
  getToken: () => Promise<string | null>;
37
37
  };
38
+ /**
39
+ * Fetches the public room list once (call `reload` to refresh). Pagination
40
+ * stays manual: pass the returned `nextCursor` back through `flow.rooms.list`.
41
+ */
42
+ export declare function useRooms(): {
43
+ rooms: RoomSummary[];
44
+ nextCursor: string | null;
45
+ loading: boolean;
46
+ error: Error | null;
47
+ reload: () => void;
48
+ };
49
+ /**
50
+ * Fetches one room's detail, and optionally polls its live presence when
51
+ * `presencePollMs` is set (polling until the presence store grows a push
52
+ * channel). Re-fetches when the slug changes; call `reload` to refresh.
53
+ */
54
+ export declare function useRoom(slug: string, options?: {
55
+ presencePollMs?: number;
56
+ }): {
57
+ room: RoomDetail | null;
58
+ presence: RoomPresenceSnapshot | null;
59
+ loading: boolean;
60
+ error: Error | null;
61
+ reload: () => void;
62
+ };
@@ -1,6 +1,7 @@
1
- import { useContext, useSyncExternalStore } from "react";
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
5
  /**
5
6
  * Returns the Flow instance to use. Resolution order:
6
7
  * 1. The instance from a `<FlowIdProvider>` ancestor (if any)
@@ -53,3 +54,79 @@ export function useFlowId() {
53
54
  getToken: flow.getToken,
54
55
  };
55
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
+ export function useRooms() {
62
+ const flow = useFlow();
63
+ const [result, setResult] = useState(null);
64
+ const [error, setError] = useState(null);
65
+ const load = useCallback(() => {
66
+ flow.rooms
67
+ .list()
68
+ .then((list) => {
69
+ setResult(list);
70
+ setError(null);
71
+ })
72
+ .catch((err) => setError(asError(err)));
73
+ }, [flow]);
74
+ useEffect(load, [load]);
75
+ return {
76
+ rooms: result?.rooms ?? [],
77
+ nextCursor: result?.nextCursor ?? null,
78
+ loading: result === null && error === null,
79
+ error,
80
+ reload: load,
81
+ };
82
+ }
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
+ export function useRoom(slug, options) {
89
+ const flow = useFlow();
90
+ const pollMs = options?.presencePollMs ?? 0;
91
+ const [room, setRoom] = useState(null);
92
+ const [presence, setPresence] = useState(null);
93
+ const [error, setError] = useState(null);
94
+ const loadRoom = useCallback(() => {
95
+ flow.rooms
96
+ .get(slug)
97
+ .then((detail) => {
98
+ setRoom(detail);
99
+ setError(null);
100
+ })
101
+ .catch((err) => setError(asError(err)));
102
+ }, [flow, slug]);
103
+ const loadPresence = useCallback(() => {
104
+ flow.rooms
105
+ .presence(slug)
106
+ .then(setPresence)
107
+ .catch(() => { });
108
+ }, [flow, slug]);
109
+ useEffect(() => {
110
+ setRoom(null);
111
+ setError(null);
112
+ loadRoom();
113
+ }, [loadRoom]);
114
+ useEffect(() => {
115
+ loadPresence();
116
+ if (pollMs <= 0)
117
+ return;
118
+ const timer = setInterval(loadPresence, pollMs);
119
+ return () => clearInterval(timer);
120
+ }, [loadPresence, pollMs]);
121
+ const reload = useCallback(() => {
122
+ loadRoom();
123
+ loadPresence();
124
+ }, [loadRoom, loadPresence]);
125
+ return {
126
+ room,
127
+ presence,
128
+ loading: room === null && error === null,
129
+ error,
130
+ reload,
131
+ };
132
+ }
@@ -1,4 +1,4 @@
1
1
  export type { FlowIdProviderProps, ProfileButtonProps } from "../types";
2
- export { useFlow, useFlowId, useFlowState } from "./hooks";
2
+ export { useFlow, useFlowId, useFlowState, useRoom, useRooms } from "./hooks";
3
3
  export { ProfileButton } from "./profile-button";
4
4
  export { FlowIdProvider } from "./provider";
@@ -1,3 +1,3 @@
1
- export { useFlow, useFlowId, useFlowState } from "./hooks";
1
+ export { useFlow, useFlowId, useFlowState, useRoom, useRooms } from "./hooks";
2
2
  export { ProfileButton } from "./profile-button";
3
3
  export { FlowIdProvider } from "./provider";
@@ -0,0 +1,23 @@
1
+ import type { ResolvedFlowSession, ResolveSessionOptions } from "./types";
2
+ export type { FlowSessionState, ResolvedFlowSession, ResolveSessionOptions, } from "./types";
3
+ export { verifyFlowJWT } from "./verify";
4
+ /**
5
+ * Resolves the visitor's Flow session from the request's Cookie header — the
6
+ * server-side counterpart of `createFlow({ cookies: true })`, for rendering
7
+ * auth-aware UI without a client round-trip.
8
+ *
9
+ * Resolution order:
10
+ * 1. A still-fresh JWT cookie verifies locally against the issuer's JWKS
11
+ * (cached per process) — the hot path, no auth-server call, no cookies
12
+ * to set.
13
+ * 2. Otherwise a refresh cookie is presented to `/api/session/refresh`,
14
+ * which ROTATES it — append every entry of `setCookies` to the response
15
+ * or the client is left holding a dead predecessor. A definitive
16
+ * rejection (401/403) yields clearing cookies instead; a network failure
17
+ * leaves the cookies alone so a later request can retry.
18
+ * 3. Neither cookie → signed-out `state: null`.
19
+ *
20
+ * Feed `state` into the SSR payload and `createFlow({ initialState })` so the
21
+ * hydrated client renders it without a second refresh.
22
+ */
23
+ export declare function resolveSession(cookieHeader: string | null | undefined, opts: ResolveSessionOptions): Promise<ResolvedFlowSession>;
@@ -0,0 +1,200 @@
1
+ import { clearCookieString, cookieNamesFor, JWT_COOKIE_MAX_AGE_S, parseCookieHeader, REFRESH_COOKIE_MAX_AGE_S, serializeCookie, } from "./cookies";
2
+ import { isExpiring } from "./token-expiry";
3
+ import { verifyFlowJWT } from "./verify";
4
+ export { verifyFlowJWT } from "./verify";
5
+ const DEFAULT_ISSUER_URL = "https://id.flow.industries";
6
+ function claimsToUser(claims) {
7
+ return {
8
+ id: claims.sub,
9
+ username: typeof claims.username === "string" ? claims.username : "",
10
+ // Fail-safe: only an explicit `false` marks a full account; a missing or
11
+ // mangled claim must never grant full privileges.
12
+ isGuest: claims.guest !== false,
13
+ };
14
+ }
15
+ async function refreshSession(issuerUrl, token) {
16
+ try {
17
+ const res = await fetch(`${issuerUrl}/api/session/refresh`, {
18
+ method: "POST",
19
+ headers: { Authorization: `Bearer ${token}` },
20
+ });
21
+ if (res.status === 401 || res.status === 403)
22
+ return "dead";
23
+ if (!res.ok)
24
+ return null;
25
+ return (await res.json());
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ // Dedupe a same-instance SSR fan-out (parallel requests carrying the same
32
+ // cookie) into one rotation. Settled results are retained briefly so a
33
+ // request that lands after the rotation but before the browser applied the
34
+ // Set-Cookie is served the same successor instead of re-presenting a dead
35
+ // token; the auth server's grace window covers presenters this map can't see
36
+ // (other replicas, other machines).
37
+ const REFRESH_RESULT_TTL_MS = 30_000;
38
+ const refreshInFlight = new Map();
39
+ function refreshOnce(issuerUrl, token) {
40
+ const key = `${issuerUrl}\n${token}`;
41
+ let flight = refreshInFlight.get(key);
42
+ if (!flight) {
43
+ flight = refreshSession(issuerUrl, token);
44
+ refreshInFlight.set(key, flight);
45
+ void flight.finally(() => {
46
+ const timer = setTimeout(() => refreshInFlight.delete(key), REFRESH_RESULT_TTL_MS);
47
+ timer.unref?.();
48
+ });
49
+ }
50
+ return flight;
51
+ }
52
+ // jose verification-class failures (bad signature, expired, wrong audience,
53
+ // unknown key) mean the token itself is unacceptable; anything else — JWKS
54
+ // fetch failure, timeouts — is transient infrastructure trouble, and falling
55
+ // through to a rotation there would turn a JWKS outage into a rotation storm
56
+ // against the same struggling server.
57
+ function isJwtVerificationFailure(err) {
58
+ const code = err?.code;
59
+ if (typeof code !== "string")
60
+ return false;
61
+ return (code.startsWith("ERR_JWT") ||
62
+ code.startsWith("ERR_JWS") ||
63
+ code === "ERR_JWKS_NO_MATCHING_KEY" ||
64
+ code === "ERR_JWKS_MULTIPLE_MATCHING_KEYS");
65
+ }
66
+ async function fetchProfile(issuerUrl, jwt, audience) {
67
+ try {
68
+ const res = await fetch(`${issuerUrl}/api/session/verify`, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify({ token: jwt, audience }),
72
+ });
73
+ if (!res.ok)
74
+ return undefined;
75
+ const data = (await res.json());
76
+ return data.payload?.profile ?? undefined;
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
82
+ /**
83
+ * Resolves the visitor's Flow session from the request's Cookie header — the
84
+ * server-side counterpart of `createFlow({ cookies: true })`, for rendering
85
+ * auth-aware UI without a client round-trip.
86
+ *
87
+ * Resolution order:
88
+ * 1. A still-fresh JWT cookie verifies locally against the issuer's JWKS
89
+ * (cached per process) — the hot path, no auth-server call, no cookies
90
+ * to set.
91
+ * 2. Otherwise a refresh cookie is presented to `/api/session/refresh`,
92
+ * which ROTATES it — append every entry of `setCookies` to the response
93
+ * or the client is left holding a dead predecessor. A definitive
94
+ * rejection (401/403) yields clearing cookies instead; a network failure
95
+ * leaves the cookies alone so a later request can retry.
96
+ * 3. Neither cookie → signed-out `state: null`.
97
+ *
98
+ * Feed `state` into the SSR payload and `createFlow({ initialState })` so the
99
+ * hydrated client renders it without a second refresh.
100
+ */
101
+ export async function resolveSession(cookieHeader, opts) {
102
+ const issuerUrl = (opts.issuerUrl ?? DEFAULT_ISSUER_URL).replace(/\/+$/, "");
103
+ const names = cookieNamesFor(opts.audience);
104
+ const secure = opts.audience.startsWith("https:");
105
+ const cookies = parseCookieHeader(cookieHeader);
106
+ const jwt = cookies[names.jwt];
107
+ if (jwt && !isExpiring(jwt)) {
108
+ try {
109
+ const claims = await verifyFlowJWT(jwt, {
110
+ audience: opts.audience,
111
+ issuerUrl,
112
+ });
113
+ const state = {
114
+ user: claimsToUser(claims),
115
+ jwt,
116
+ address: null,
117
+ };
118
+ const profile = opts.profile
119
+ ? await fetchProfile(issuerUrl, jwt, opts.audience)
120
+ : undefined;
121
+ return {
122
+ state,
123
+ claims,
124
+ setCookies: [],
125
+ ...(profile ? { profile } : {}),
126
+ };
127
+ }
128
+ catch (err) {
129
+ if (!isJwtVerificationFailure(err)) {
130
+ return { state: null, claims: null, setCookies: [] };
131
+ }
132
+ }
133
+ }
134
+ const refreshToken = cookies[names.refresh];
135
+ if (!refreshToken)
136
+ return { state: null, claims: null, setCookies: [] };
137
+ const result = await refreshOnce(issuerUrl, refreshToken);
138
+ if (result === "dead") {
139
+ return {
140
+ state: null,
141
+ claims: null,
142
+ setCookies: [
143
+ clearCookieString(names.jwt, { secure }),
144
+ clearCookieString(names.refresh, { secure }),
145
+ ],
146
+ };
147
+ }
148
+ if (!result)
149
+ return { state: null, claims: null, setCookies: [] };
150
+ const successorCookies = [
151
+ serializeCookie(names.jwt, result.jwt, {
152
+ maxAge: JWT_COOKIE_MAX_AGE_S,
153
+ secure,
154
+ }),
155
+ serializeCookie(names.refresh, result.refreshToken, {
156
+ maxAge: REFRESH_COOKIE_MAX_AGE_S,
157
+ secure,
158
+ }),
159
+ ];
160
+ // Never trust a minted session unverified: the refresh endpoint binds the
161
+ // JWT to the token ROW's audience, so a cookie planted by a sibling
162
+ // subdomain (or a misconfigured audience) would otherwise resolve into a
163
+ // wrong-app session. Verification failure ⇒ clear the cookies; a transient
164
+ // JWKS failure still hands the successor over (the rotation already
165
+ // happened) but renders signed-out rather than trusting it.
166
+ let claims;
167
+ try {
168
+ claims = await verifyFlowJWT(result.jwt, {
169
+ audience: opts.audience,
170
+ issuerUrl,
171
+ });
172
+ }
173
+ catch (err) {
174
+ if (isJwtVerificationFailure(err)) {
175
+ return {
176
+ state: null,
177
+ claims: null,
178
+ setCookies: [
179
+ clearCookieString(names.jwt, { secure }),
180
+ clearCookieString(names.refresh, { secure }),
181
+ ],
182
+ };
183
+ }
184
+ return { state: null, claims: null, setCookies: successorCookies };
185
+ }
186
+ const state = {
187
+ user: result.user,
188
+ jwt: result.jwt,
189
+ address: result.address,
190
+ };
191
+ const profile = opts.profile
192
+ ? await fetchProfile(issuerUrl, result.jwt, opts.audience)
193
+ : undefined;
194
+ return {
195
+ state,
196
+ claims,
197
+ setCookies: successorCookies,
198
+ ...(profile ? { profile } : {}),
199
+ };
200
+ }
@@ -0,0 +1,5 @@
1
+ export declare const EXPIRY_SKEW_MS = 60000;
2
+ /** Reads the `exp` (seconds since epoch) from a JWT, or null if unreadable. */
3
+ export declare function jwtExp(token: string): number | null;
4
+ /** True if the token is missing an exp, already expired, or within the skew. */
5
+ export declare function isExpiring(token: string): boolean;
@@ -0,0 +1,25 @@
1
+ // Refresh a little before the JWT's `exp` so a token handed out to a caller
2
+ // is still valid by the time it reaches the relying party, absorbing request
3
+ // latency and minor client/server clock skew.
4
+ export const EXPIRY_SKEW_MS = 60_000;
5
+ /** Reads the `exp` (seconds since epoch) from a JWT, or null if unreadable. */
6
+ export function jwtExp(token) {
7
+ const parts = token.split(".");
8
+ if (parts.length !== 3)
9
+ return null;
10
+ try {
11
+ const padded = (parts[1] ?? "").replace(/-/g, "+").replace(/_/g, "/");
12
+ const payload = JSON.parse(atob(padded));
13
+ return typeof payload.exp === "number" ? payload.exp : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /** True if the token is missing an exp, already expired, or within the skew. */
20
+ export function isExpiring(token) {
21
+ const exp = jwtExp(token);
22
+ if (exp === null)
23
+ return true;
24
+ return Date.now() >= exp * 1000 - EXPIRY_SKEW_MS;
25
+ }
@@ -8,7 +8,7 @@ export type AuthOutcome = "success" | "failure" | "info";
8
8
  export type AuthMode = "sign-up" | "sign-in";
9
9
  /** Client-only funnel steps reported via the `/api/events` beacon. */
10
10
  export type FunnelStep = "mode_selected" | "email_entered" | "ceremony_started" | "done_shown";
11
- export type AuthEventName = "auth.username.checked" | "auth.signup.succeeded" | "auth.signup.failed" | "auth.otp.sent" | "auth.otp.verified" | "auth.otp.failed" | "auth.email.verify.sent" | "auth.email.verify.succeeded" | "auth.email.verify.failed" | "auth.email.changed" | "auth.avatar.upload.succeeded" | "auth.avatar.upload.failed" | "auth.challenge.issued" | "auth.signin.succeeded" | "auth.signin.failed" | "auth.restore.succeeded" | "auth.restore.failed" | "auth.restore.no_session" | "auth.refresh.succeeded" | "auth.refresh.failed" | "auth.refresh.reuse" | "auth.jwt.verified" | "auth.jwt.rejected" | "auth.signout" | "auth.audience.rejected" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.done_shown";
11
+ export type AuthEventName = "auth.username.checked" | "auth.signup.succeeded" | "auth.signup.failed" | "auth.otp.sent" | "auth.otp.verified" | "auth.otp.failed" | "auth.email.verify.sent" | "auth.email.verify.succeeded" | "auth.email.verify.failed" | "auth.email.changed" | "auth.avatar.upload.succeeded" | "auth.avatar.upload.failed" | "auth.challenge.issued" | "auth.signin.succeeded" | "auth.signin.failed" | "auth.restore.succeeded" | "auth.restore.failed" | "auth.restore.no_session" | "auth.refresh.succeeded" | "auth.refresh.failed" | "auth.refresh.reuse" | "auth.refresh.grace" | "auth.jwt.verified" | "auth.jwt.rejected" | "auth.signout" | "auth.audience.rejected" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.done_shown";
12
12
  export type AuthErrorCode = "username_taken" | "credential_taken" | "email_taken" | "email_not_verified" | "no_email" | "image_upload_forbidden" | "image_upload_rate_limited" | "invalid_image_type" | "image_too_large" | "image_upload_failed" | "otp_invalid" | "otp_expired" | "otp_attempts_exceeded" | "otp_resend_cooldown" | "otp_resend_limit" | "otp_global_limit" | "otp_send_failed" | "challenge_expired" | "unknown_credential" | "invalid_assertion_type" | "invalid_assertion_origin" | "user_verification_required" | "invalid_signature" | "user_not_found" | "missing_audience" | "audience_not_allowed" | "audience_mismatch" | "no_session" | "no_passkey" | "refresh_token_invalid" | "refresh_token_expired" | "refresh_reuse_detected" | "guest_rate_limited" | "guest_global_limit" | "guest_username_exhausted" | "already_upgraded" | "malformed_token" | "unknown_key" | "verification_failed" | "internal_error";
13
13
  /** One flat record per event = one row in the `auth_events` stream. */
14
14
  export interface AuthEventRecord {
@@ -3,7 +3,9 @@ export type { BoundaryError, DialogCustomFeatures, DialogCustomLabels, DialogErr
3
3
  export type { AuthErrorCode, AuthEventName, AuthEventRecord, AuthMode, AuthOutcome, BeaconBody, FunnelStep, } from "./events";
4
4
  export type { Bridge, BridgeParameters, FlowAccount, FlowRemote, FlowRemoteConfig, FromWindowOptions, MessageResponse, Messenger, OneOf, Payload, QueuedRequest, ReadyOptions, RemoteFlowState, RemoteState, Schema, Storage, Topic, WithReady, } from "./messenger";
5
5
  export type { Call, ConnectCapabilities, ConnectRequest, ConnectResponse, GuestRequest, GuestResponse, MethodName, MethodParams, MethodResult, RestoreResponse, RpcRequest, SendCallsParams, SendCallsRequest, SendCallsResponse, SendTransactionParams, SendTransactionRequest, SendTransactionResponse, SignMessageRequest, SignMessageResponse, SignOutRequest, SignOutResponse, SignTypedDataRequest, SignTypedDataResponse, TransactionArgs, TypedData, TypedDataDomain, TypedDataField, } from "./protocol";
6
- export { isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
7
- export type { AccessKeyOptions, AccessKeyPreparation, Address, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowIdProviderProps, FlowState, Listener, LoginOptions, MountProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
6
+ export { getConnectCapabilities, isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
7
+ export type { CreateRoomInput, MyRooms, RoomChannel, RoomDetail, RoomList, RoomMemberEntry, RoomMembers, RoomOccupancy, RoomOwner, RoomPresenceEntry, RoomPresenceSnapshot, RoomRestrictionKind, RoomRole, RoomSummary, RoomSurfaceSettings, RoomsApi, RoomVisibility, UpdateRoomInput, VerifyRoomContext, } from "./rooms";
8
+ export type { AccessKeyOptions, AccessKeyPreparation, Address, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, FlowState, Listener, LoginOptions, MountProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, RefreshCookieConfig, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
9
+ export type { ResolvedFlowSession, ResolveSessionOptions } from "./server";
8
10
  export type { CoinAsset, IdentifiedTx, TxApprove, TxConvert, TxSend, TxSwap, } from "./tx";
9
11
  export type { ActionDayContext, ActionEventKind, ActionSessionState, ActiveActionResponse, LevelProgress, PublicProfile, XpGrantResult, XpRecentGrant, XpSummary, } from "./xp";
@@ -1 +1 @@
1
- export { isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
1
+ export { getConnectCapabilities, isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
@@ -131,6 +131,12 @@ export type SendCallsParams = {
131
131
  chainId?: string;
132
132
  capabilities?: Record<string, unknown>;
133
133
  };
134
+ /**
135
+ * Unwraps the connect capabilities from a wallet_connect RPC request (they ride
136
+ * as the first param), or undefined. Several dialog surfaces read them (mode,
137
+ * accessKeyHash, guestToken), so the cast lives here rather than being repeated.
138
+ */
139
+ export declare function getConnectCapabilities(request: RpcRequest | undefined): ConnectCapabilities | undefined;
134
140
  export declare function isPersonalSignParams(params: readonly unknown[] | undefined): params is readonly [string, string];
135
141
  export declare function isSendTransactionParams(params: readonly unknown[] | undefined): params is readonly [SendTransactionParams];
136
142
  export declare function isSendCallsParams(params: readonly unknown[] | undefined): params is readonly [SendCallsParams];
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Unwraps the connect capabilities from a wallet_connect RPC request (they ride
3
+ * as the first param), or undefined. Several dialog surfaces read them (mode,
4
+ * accessKeyHash, guestToken), so the cast lives here rather than being repeated.
5
+ */
6
+ export function getConnectCapabilities(request) {
7
+ return request?.params?.[0]?.capabilities;
8
+ }
1
9
  export function isPersonalSignParams(params) {
2
10
  if (!params || params.length < 2)
3
11
  return false;
@@ -0,0 +1,129 @@
1
+ /** Room registry types shared by the server routes and the client SDK. */
2
+ export type RoomVisibility = "public" | "unlisted" | "private";
3
+ /**
4
+ * Owner is not a member-row role: `room.ownerId` is the single authority.
5
+ * Member rows carry moderator|member.
6
+ */
7
+ export type RoomRole = "owner" | "moderator" | "member";
8
+ export type RoomChannel = "game" | "voice" | "text";
9
+ export type RoomRestrictionKind = "ban" | "mute";
10
+ export interface RoomOwner {
11
+ id: string;
12
+ username: string;
13
+ }
14
+ /** Live occupant counts per channel, staleness-filtered at read time. */
15
+ export interface RoomOccupancy {
16
+ game: number;
17
+ voice: number;
18
+ text: number;
19
+ }
20
+ export interface RoomSummary {
21
+ id: string;
22
+ slug: string;
23
+ displayName: string;
24
+ description: string | null;
25
+ owner: RoomOwner | null;
26
+ visibility: RoomVisibility;
27
+ isSystem: boolean;
28
+ occupancy: RoomOccupancy;
29
+ createdAt: string;
30
+ }
31
+ export interface RoomDetail extends RoomSummary {
32
+ /** The caller's role in this room, when the request carried a valid bearer. */
33
+ viewerRole: RoomRole | null;
34
+ memberCount: number;
35
+ }
36
+ export interface RoomMemberEntry {
37
+ userId: string;
38
+ username: string;
39
+ role: RoomRole;
40
+ joinedAt: string;
41
+ }
42
+ export interface RoomMembers {
43
+ owner: RoomOwner | null;
44
+ members: RoomMemberEntry[];
45
+ }
46
+ export interface CreateRoomInput {
47
+ slug: string;
48
+ displayName?: string;
49
+ description?: string;
50
+ visibility?: RoomVisibility;
51
+ }
52
+ export interface UpdateRoomInput {
53
+ slug?: string;
54
+ displayName?: string;
55
+ description?: string;
56
+ visibility?: RoomVisibility;
57
+ }
58
+ export interface MyRooms {
59
+ owned: RoomSummary[];
60
+ member: RoomSummary[];
61
+ }
62
+ export interface RoomList {
63
+ rooms: RoomSummary[];
64
+ nextCursor: string | null;
65
+ }
66
+ export interface RoomPresenceEntry {
67
+ userId: string;
68
+ username: string;
69
+ joinedAt: string;
70
+ }
71
+ export interface RoomPresenceSnapshot {
72
+ roomId: string;
73
+ slug: string;
74
+ channels: Record<RoomChannel, RoomPresenceEntry[]>;
75
+ }
76
+ /**
77
+ * Room admission context returned by POST /api/session/verify when the caller
78
+ * passes a `room` slug: whether the verified subject may enter, and with which
79
+ * role and per-room game settings. `settings` is delivered only through this
80
+ * server-to-server path — surface blobs may hold secret-ish values.
81
+ */
82
+ export interface VerifyRoomContext {
83
+ ok: boolean;
84
+ reason?: "not_found" | "banned" | "private_not_member";
85
+ id: string | null;
86
+ slug: string;
87
+ visibility: RoomVisibility | null;
88
+ role: RoomRole | null;
89
+ muted: boolean;
90
+ settings: Record<string, number | boolean | string>;
91
+ }
92
+ export interface RoomSurfaceSettings {
93
+ surface: string;
94
+ version: number;
95
+ settings: Record<string, number | boolean | string>;
96
+ }
97
+ /** The room-registry client exposed as `flow.rooms`. Public reads work
98
+ * signed-out; everything else attaches the caller's bearer token. */
99
+ export interface RoomsApi {
100
+ list(options?: {
101
+ cursor?: string;
102
+ limit?: number;
103
+ }): Promise<RoomList>;
104
+ get(slug: string): Promise<RoomDetail>;
105
+ mine(): Promise<MyRooms>;
106
+ create(input: CreateRoomInput): Promise<RoomDetail>;
107
+ update(slug: string, patch: UpdateRoomInput): Promise<RoomDetail>;
108
+ remove(slug: string): Promise<void>;
109
+ join(slug: string): Promise<void>;
110
+ leave(slug: string): Promise<void>;
111
+ members(slug: string): Promise<RoomMembers>;
112
+ presence(slug: string): Promise<RoomPresenceSnapshot>;
113
+ settings(slug: string, surface: string): Promise<RoomSurfaceSettings>;
114
+ saveSettings(slug: string, surface: string, settings: Record<string, unknown>): Promise<RoomSurfaceSettings>;
115
+ transfer(slug: string, userId: string): Promise<void>;
116
+ claim(slug: string): Promise<void>;
117
+ setRole(slug: string, userId: string, role: "moderator" | "member"): Promise<void>;
118
+ kick(slug: string, userId: string): Promise<void>;
119
+ ban(slug: string, userId: string, options?: {
120
+ reason?: string;
121
+ expiresInSeconds?: number;
122
+ }): Promise<void>;
123
+ unban(slug: string, userId: string): Promise<void>;
124
+ mute(slug: string, userId: string, options?: {
125
+ reason?: string;
126
+ expiresInSeconds?: number;
127
+ }): Promise<void>;
128
+ unmute(slug: string, userId: string): Promise<void>;
129
+ }
@@ -0,0 +1 @@
1
+ /** Room registry types shared by the server routes and the client SDK. */
@@ -3,6 +3,7 @@ import type { Chain, Hex, PrepareTransactionRequestParameters, SendTransactionPa
3
3
  import type { SendCallsParameters } from "viem/actions";
4
4
  import type { FlowCredential, FlowUser, WebAuthnSignature } from "./auth";
5
5
  import type { ConnectCapabilities, ConnectResponse } from "./protocol";
6
+ import type { RoomsApi } from "./rooms";
6
7
  export type Address = `0x${string}`;
7
8
  /**
8
9
  * Phases viem invokes a chain's `prepareTransactionRequest` hook at. Inlined
@@ -70,6 +71,22 @@ export type CreateFlowOptions = {
70
71
  * data keyed on the Flow user id survives. See `ensureGuest`.
71
72
  */
72
73
  autoGuest?: boolean;
74
+ /**
75
+ * If true, the JWT and the rotating refresh token are mirrored into
76
+ * first-party cookies on the app's own origin so the app's server can
77
+ * resolve the session per-request (see `@flow-industries/id/server`).
78
+ * Off by default — a backend that never reads them shouldn't start
79
+ * receiving refresh tokens on every request.
80
+ */
81
+ cookies?: boolean;
82
+ /**
83
+ * Session state resolved server-side (via `resolveSession`) to seed the
84
+ * store synchronously, so the first client render matches the SSR HTML and
85
+ * no bootstrap refresh runs while the hydrated JWT is still fresh. Pass
86
+ * `null` for a known signed-out visitor; omit for the default client-only
87
+ * bootstrap.
88
+ */
89
+ initialState?: FlowSessionState | null;
73
90
  };
74
91
  export type FlowState = {
75
92
  user: FlowUser | null;
@@ -77,6 +94,30 @@ export type FlowState = {
77
94
  credential: FlowCredential | null;
78
95
  address: Address | null;
79
96
  };
97
+ /**
98
+ * The auth state a server can resolve per-request from the first-party
99
+ * cookies: identity + access token, but never the credential (passkeys live
100
+ * in the browser's IndexedDB only).
101
+ */
102
+ export type FlowSessionState = {
103
+ user: FlowUser;
104
+ jwt: string;
105
+ /**
106
+ * Null whenever the session came from a locally-verified JWT — the common
107
+ * SSR hot path — because the claim set carries no address. The client
108
+ * restores it from the stored passkey credential after hydration; never
109
+ * server-render wallet UI from this field.
110
+ */
111
+ address: Address | null;
112
+ };
113
+ export type FlowCookieNames = {
114
+ jwt: string;
115
+ refresh: string;
116
+ };
117
+ export type RefreshCookieConfig = {
118
+ name: string;
119
+ secure: boolean;
120
+ };
80
121
  export type LoginOptions = {
81
122
  mode?: "iframe" | "popup";
82
123
  signUp?: boolean;
@@ -205,6 +246,8 @@ export type Flow = {
205
246
  walletClient(args?: {
206
247
  chainId?: number;
207
248
  }): Promise<WalletClient>;
249
+ /** Typed client for the room registry (browse, membership, settings, moderation). */
250
+ rooms: RoomsApi;
208
251
  subscribe(listener: Listener<FlowState>): () => void;
209
252
  getState(): FlowState;
210
253
  dialog: DialogHost;
@@ -0,0 +1,28 @@
1
+ import type { VerifiedFlowJWT } from "./auth";
2
+ import type { FlowSessionState } from "./sdk";
3
+ import type { PublicProfile } from "./xp";
4
+ export type ResolveSessionOptions = {
5
+ /** The calling app's origin, e.g. `https://flow.game` — must match the JWT `aud`. */
6
+ audience: string;
7
+ /** Flow ID origin; defaults to `https://id.flow.industries`. */
8
+ issuerUrl?: string;
9
+ /**
10
+ * Also fetch the public profile (avatar, tier, level) for a resolved
11
+ * session via `/api/session/verify` — one extra round-trip; off by default.
12
+ */
13
+ profile?: boolean;
14
+ };
15
+ export type ResolvedFlowSession = {
16
+ /** Resolved session, or null for a signed-out visitor. */
17
+ state: FlowSessionState | null;
18
+ /** Verified JWT claims when the session came from a locally-verified JWT. */
19
+ claims: VerifiedFlowJWT | null;
20
+ /**
21
+ * Set-Cookie values to append to the response: rotation successors after a
22
+ * server-side refresh, or expired-cookie clears after a dead token. Empty on
23
+ * the JWT-valid hot path.
24
+ */
25
+ setCookies: string[];
26
+ /** Public profile, only when requested via `profile: true` and resolvable. */
27
+ profile?: PublicProfile;
28
+ };
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "main": "./dist/sdk/client/index.js",
5
5
  "module": "./dist/sdk/client/index.js",
6
6
  "types": "./dist/sdk/client/index.d.ts",
@@ -29,6 +29,10 @@
29
29
  "./verify": {
30
30
  "types": "./dist/sdk/verify.d.ts",
31
31
  "import": "./dist/sdk/verify.js"
32
+ },
33
+ "./server": {
34
+ "types": "./dist/sdk/server.d.ts",
35
+ "import": "./dist/sdk/server.js"
32
36
  }
33
37
  },
34
38
  "files": [