@flow-industries/id 0.11.0 → 0.12.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.
@@ -10,9 +10,9 @@ import { credentialToAddress } from "./session";
10
10
  *
11
11
  * On construction it kicks off two async tasks: rehydrating the credential
12
12
  * from IndexedDB (so signing works even before the JWT is refreshed) and,
13
- * unless `autoRestore: false`, attempting a silent JWT refresh through the
14
- * hidden dialog iframe. Both run in the background; consumers can subscribe
15
- * to state changes via `flow.subscribe`.
13
+ * unless `autoRestore: false`, a silent session resolve through the app's
14
+ * first-party session route. Both run in the background; consumers can
15
+ * subscribe to state changes via `flow.subscribe`.
16
16
  *
17
17
  * Most signing methods dynamically import their implementation modules so
18
18
  * apps that only use identity (no chain ops) don't pay for the viem/tempo
@@ -1,20 +1,19 @@
1
- import { clearBrowserCookie, cookieNamesFor, JWT_COOKIE_MAX_AGE_S, writeBrowserCookie, } from "../cookies";
1
+ import { DEFAULT_SESSION_PATH, isLocalHostname, resolveIdHost, } from "../id-host";
2
2
  import { isExpiring } from "../token-expiry";
3
3
  import { createDialogHost } from "./dialog-host";
4
4
  import { idb } from "./idb";
5
5
  import { METHODS } from "./methods";
6
- import { makeRefreshStore } from "./refresh-store";
7
6
  import { createRoomsApi } from "./rooms";
8
7
  import { credentialToAddress, restoreCredential, runLogin, runLogout, } from "./session";
9
8
  import { createStore, initialFlowState } from "./store";
10
- const DEFAULT_HOST = "https://id.flow.industries";
11
9
  /**
12
- * Runs `fn` while holding a cross-tab lock (Web Locks API) so concurrent tabs
13
- * of the same origin serialize guest creation: the first tab mints and sets the
14
- * shared cookie, later tabs then restore it instead of minting a duplicate
15
- * guest. Falls back to running `fn` directly where Web Locks is unavailable.
10
+ * Runs `fn` while holding a cross-tab lock (Web Locks API — origin-scoped by
11
+ * design, matching the origin-scoped session cookies) so concurrent tabs
12
+ * serialize session work: the first tab refreshes or mints, later tabs then
13
+ * see the result instead of racing it. Falls back to running `fn` directly
14
+ * where Web Locks is unavailable.
16
15
  */
17
- function withGuestLock(name, fn) {
16
+ function withOriginLock(name, fn) {
18
17
  const locks = globalThis.navigator?.locks;
19
18
  if (locks?.request)
20
19
  return locks.request(name, fn);
@@ -50,9 +49,9 @@ function resolveAccessKey(input) {
50
49
  *
51
50
  * On construction it kicks off two async tasks: rehydrating the credential
52
51
  * from IndexedDB (so signing works even before the JWT is refreshed) and,
53
- * unless `autoRestore: false`, attempting a silent JWT refresh through the
54
- * hidden dialog iframe. Both run in the background; consumers can subscribe
55
- * to state changes via `flow.subscribe`.
52
+ * unless `autoRestore: false`, a silent session resolve through the app's
53
+ * first-party session route. Both run in the background; consumers can
54
+ * subscribe to state changes via `flow.subscribe`.
56
55
  *
57
56
  * Most signing methods dynamically import their implementation modules so
58
57
  * apps that only use identity (no chain ops) don't pay for the viem/tempo
@@ -67,21 +66,17 @@ export function createFlow(options = {}) {
67
66
  // stale state. For genuine multi-instance scenarios (tests), call
68
67
  // resetFlow() first or pass explicit Flow instances.
69
68
  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().");
69
+ throw new Error("createFlow() is browser-only (IndexedDB, iframes, fetch with " +
70
+ "cookies). For server rendering, resolve state with resolveSession() " +
71
+ "from @flow-industries/id/server and render with createStaticFlow().");
73
72
  }
74
73
  if (currentFlow)
75
74
  return currentFlow;
76
- const host = (options.host ?? DEFAULT_HOST).replace(/\/+$/, "");
75
+ const host = resolveIdHost(options.host);
77
76
  const dialogUrl = `${host}/dialog/`;
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);
77
+ const sessionPath = options.sessionPath ?? DEFAULT_SESSION_PATH;
78
+ const rpId = options.rpId ??
79
+ (isLocalHostname(window.location.hostname) ? "localhost" : undefined);
85
80
  const chains = options.chains ?? [];
86
81
  const getChain = (chainId) => {
87
82
  if (chainId == null)
@@ -94,27 +89,6 @@ export function createFlow(options = {}) {
94
89
  };
95
90
  const accessKeyOptions = resolveAccessKey(options.accessKey);
96
91
  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
92
  // Seed synchronously so the first client render matches the SSR HTML —
119
93
  // hooks read the hydrated user instead of flashing signed-out while a
120
94
  // bootstrap refresh runs.
@@ -132,73 +106,90 @@ export function createFlow(options = {}) {
132
106
  return dialog;
133
107
  };
134
108
  /**
135
- * Mints a fresh JWT from the user's existing cookie session via the hidden
136
- * dialog iframe. The iframe is first-party to id.flow.industries so the
137
- * `flow_id.session_token` cookie is sent automatically — this is the only
138
- * way to read the session from a third-party app (browsers block reading
139
- * cross-origin cookies).
140
- *
141
- * Returns true if a session was found and state was populated, false if the
142
- * user has no active session (in which case the caller should fall back to
143
- * `flow.login()`). Never throws — restore failures are treated as "no session".
109
+ * Commits a session reported by the app's session route. `credential` and
110
+ * `address` keys are present only when the route rotated the token — the
111
+ * rotation response is authoritative for signing state; the JWT hot path
112
+ * knows neither, and overwriting would erase state restored from IDB.
144
113
  */
114
+ function commitSession(session) {
115
+ store.setState({
116
+ user: session.user,
117
+ jwt: session.jwt,
118
+ ...(session.credential !== undefined
119
+ ? { credential: session.credential, address: session.address ?? null }
120
+ : {}),
121
+ });
122
+ // A guest carries a null credential; never persist that — the IDB store is
123
+ // reserved for a real passkey credential and writing null would erase a
124
+ // previously stored one.
125
+ if (session.credential) {
126
+ void idb.set("flow.activeCredential", session.credential);
127
+ }
128
+ }
145
129
  /**
146
- * Mints a fresh session from the first-party refresh token: POSTs it as a
147
- * bearer to /api/session/refresh (no cookie, so iOS ITP can't block it),
148
- * commits the returned session, and persists the rotated token. Returns false
149
- * (never throws) when there's no token or the server rejects it — the caller
150
- * then falls back to ensureGuest()/login(). A 401 means the token is dead
151
- * (expired, revoked, or reuse-detected), so it's dropped.
130
+ * Resolves a fresh session from the app's own session route. The refresh
131
+ * token lives in an HttpOnly cookie only that route's server can read, so
132
+ * this is the sole silent-session path: the server rotates the token when
133
+ * the access JWT is expiring and answers with the minted session. Returns
134
+ * false (never throws) when the visitor is signed out or the route is
135
+ * unreachable — the caller then falls back to ensureGuest()/login().
152
136
  */
153
- async function refreshViaToken() {
154
- const token = refreshStore.get();
155
- if (!token)
137
+ async function refreshViaSession() {
138
+ try {
139
+ const res = await fetch(sessionPath, {
140
+ headers: { Accept: "application/json" },
141
+ });
142
+ if (!res.ok)
143
+ return false;
144
+ const { state } = (await res.json());
145
+ if (!state)
146
+ return false;
147
+ commitSession(state);
148
+ return true;
149
+ }
150
+ catch {
156
151
  return false;
152
+ }
153
+ }
154
+ /**
155
+ * One-time handoff of a freshly minted session (dialog login or guest
156
+ * mint) to the app's server, which verifies the JWT against the issuer's
157
+ * JWKS and sets the HttpOnly cookies — the refresh token is never stored
158
+ * where page JavaScript could read it back. Best-effort: without a
159
+ * reachable session route the in-memory session still works, it just
160
+ * can't survive a reload.
161
+ */
162
+ async function installSession(refreshToken, jwt) {
163
+ if (!refreshToken || !jwt)
164
+ return;
157
165
  try {
158
- const res = await fetch(`${host}/api/session/refresh`, {
166
+ const res = await fetch(sessionPath, {
159
167
  method: "POST",
160
- headers: { Authorization: `Bearer ${token}` },
168
+ headers: { "Content-Type": "application/json" },
169
+ body: JSON.stringify({ refreshToken, jwt }),
161
170
  });
162
171
  if (!res.ok) {
163
- if (res.status === 401)
164
- refreshStore.clear();
165
- return false;
166
- }
167
- const result = (await res.json());
168
- refreshStore.set(result.refreshToken);
169
- store.setState({
170
- user: result.user,
171
- jwt: result.jwt,
172
- credential: result.credential,
173
- address: result.address,
174
- });
175
- // A guest carries a null credential; never persist that — the IDB store is
176
- // reserved for a real passkey credential and writing null would erase a
177
- // previously stored one.
178
- if (result.credential) {
179
- await idb.set("flow.activeCredential", result.credential);
172
+ console.warn(`Flow ID: session route ${sessionPath} answered ${res.status}; ` +
173
+ "the session will not survive a reload");
180
174
  }
181
- return true;
182
175
  }
183
176
  catch {
184
- return false;
177
+ console.warn(`Flow ID: no session route at ${sessionPath}; ` +
178
+ "the session will not survive a reload");
185
179
  }
186
180
  }
187
- // Every refresh-token POST — boot, ensureGuest, and getToken — funnels through
188
- // this one lock so two callers can't present the same stored token at once.
189
- // Rotation is single-use: a token replayed after it rotated looks like theft,
190
- // tripping server-side reuse detection and revoking the whole lineage (which
191
- // would then 401 → clear → re-guest). The lock serializes refreshes across
192
- // tabs of the same origin, and the guard short-circuits a caller that arrives
193
- // after a concurrent holder already minted a fresh JWT, so no one re-presents
194
- // an already-rotated token. refreshViaToken re-reads the token fresh from
195
- // storage inside the lock, so a genuinely-second refresh uses the rotated one.
181
+ // Every session-route call — boot, ensureGuest, and getToken — funnels
182
+ // through this one lock so concurrent tabs don't fan out parallel
183
+ // rotations. The server dedupes and the auth server's grace window absorbs
184
+ // stragglers, but serializing here means the common case is one rotation,
185
+ // and the guard short-circuits a caller that arrives after a concurrent
186
+ // holder already minted a fresh JWT.
196
187
  async function doRefresh() {
197
- return withGuestLock(`flow.id.refresh:${host}`, async () => {
188
+ return withOriginLock("flow.id.refresh", async () => {
198
189
  const current = store.getSnapshot().jwt;
199
190
  if (current && !isExpiring(current))
200
191
  return true;
201
- return refreshViaToken();
192
+ return refreshViaSession();
202
193
  });
203
194
  }
204
195
  const refreshJwt = doRefresh;
@@ -247,10 +238,10 @@ export function createFlow(options = {}) {
247
238
  if (store.getSnapshot().user)
248
239
  return true;
249
240
  if (!guestInFlight) {
250
- guestInFlight = withGuestLock(`flow.id.guest:${host}`, async () => {
241
+ guestInFlight = withOriginLock("flow.id.guest", async () => {
251
242
  // Re-check under the lock: another tab may have minted the guest while
252
- // we waited. Refresh-first (from this origin's stored token) recovers an
253
- // existing session instead of minting a duplicate guest.
243
+ // we waited. Refresh-first (from this origin's session cookie)
244
+ // recovers an existing session instead of minting a duplicate guest.
254
245
  if (store.getSnapshot().user)
255
246
  return true;
256
247
  if (await doRefresh())
@@ -259,7 +250,6 @@ export function createFlow(options = {}) {
259
250
  const result = await getDialog().requestSilent(METHODS.guest, []);
260
251
  if (!result.user)
261
252
  return false;
262
- refreshStore.set(result.refreshToken);
263
253
  store.setState({
264
254
  user: result.user,
265
255
  jwt: result.jwt,
@@ -268,6 +258,7 @@ export function createFlow(options = {}) {
268
258
  // address already in state rather than stripping signing.
269
259
  ...(result.user.isGuest ? { credential: null, address: null } : {}),
270
260
  });
261
+ await installSession(result.refreshToken, result.jwt);
271
262
  return true;
272
263
  }
273
264
  catch {
@@ -303,7 +294,7 @@ export function createFlow(options = {}) {
303
294
  getState: () => store.getSnapshot(),
304
295
  getChain,
305
296
  getTransport,
306
- ...(options.rpId ? { rpId: options.rpId } : {}),
297
+ ...(rpId ? { rpId } : {}),
307
298
  ...(accessKeyOptions?.strict ? { strict: accessKeyOptions.strict } : {}),
308
299
  };
309
300
  }
@@ -351,7 +342,7 @@ export function createFlow(options = {}) {
351
342
  options: loginOpts,
352
343
  ...(extraCapabilities ? { extraCapabilities } : {}),
353
344
  });
354
- refreshStore.set(refreshToken);
345
+ await installSession(refreshToken, session.jwt);
355
346
  if (accessKeyModule && accessKeyPrep && webauthn) {
356
347
  await accessKeyModule.finalizeAccessKey({
357
348
  address: session.address,
@@ -369,24 +360,24 @@ export function createFlow(options = {}) {
369
360
  return session;
370
361
  }
371
362
  /**
372
- * Performs a full sign-out: tells the server to invalidate the cookie session
373
- * (so other Flow apps can't silently restore it), clears local credential and
374
- * access-key state, and resets the in-memory store.
363
+ * Performs a full sign-out: tells the id.flow.industries dialog to
364
+ * invalidate its cookie session (so other Flow apps can't silently restore
365
+ * it), tells the app's session route to revoke the refresh lineage and
366
+ * clear the HttpOnly cookies, then clears local credential and access-key
367
+ * state and resets the in-memory store.
375
368
  *
376
- * Server-side sign-out is best-effort — if the network call fails (e.g.,
369
+ * Both server-side steps are best-effort — if a network call fails (e.g.,
377
370
  * offline) we still clear local state so the UI reflects "signed out". The
378
- * cookie will eventually expire on its own.
371
+ * cookies and tokens eventually expire on their own.
379
372
  */
380
373
  async function logout() {
381
- const dialogHost = getDialog();
382
- try {
383
- await dialogHost.requestSilent(METHODS.signOut, []);
384
- }
385
- catch {
386
- // Server-side sign-out failed (offline?) — still clear local state.
387
- }
374
+ // Both server-side sign-outs are independent and best-effort — if either
375
+ // fails (offline?) local state still clears so the UI reads "signed out".
376
+ await Promise.allSettled([
377
+ getDialog().requestSilent(METHODS.signOut, []),
378
+ fetch(sessionPath, { method: "DELETE" }),
379
+ ]);
388
380
  await runLogout(store);
389
- refreshStore.clear();
390
381
  dialog?.close();
391
382
  // An autoGuest app is never truly "signed out" — it always wants at least a
392
383
  // guest session. Re-mint one so the UI (e.g. the profile widget pill) keeps
@@ -1,3 +1,4 @@
1
+ export { defaultIdHost, isLocalHostname } from "../id-host";
1
2
  export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, LoginOptions, MethodName, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
2
3
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
3
4
  export { createDialogHost } from "./dialog-host";
@@ -1,3 +1,4 @@
1
+ export { defaultIdHost, isLocalHostname } from "../id-host";
1
2
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
2
3
  export { createDialogHost } from "./dialog-host";
3
4
  export { METHODS } from "./methods";
@@ -1,6 +1,6 @@
1
+ import { resolveIdHost } from "../id-host";
1
2
  import { getFlow, requireFlow } from "./create-flow";
2
3
  import { bridgeToWindow, makeIframe } from "./iframe-host";
3
- const DEFAULT_HOST = "https://id.flow.industries";
4
4
  const POSITION_STYLE = {
5
5
  "top-right": { top: "0", right: "0" },
6
6
  "top-left": { top: "0", left: "0" },
@@ -31,7 +31,7 @@ export function createProfileButton(options) {
31
31
  if (typeof document === "undefined")
32
32
  return { destroy() { } };
33
33
  const flow = options.flow ?? getFlow() ?? requireFlow();
34
- const host = (options.host ?? flow.host ?? DEFAULT_HOST).replace(/\/+$/, "");
34
+ const host = resolveIdHost(options.host ?? flow.host);
35
35
  const hostOrigin = new URL(host).origin;
36
36
  const theme = options.theme ?? "light dark";
37
37
  let createdContainer = null;
@@ -1,23 +1,22 @@
1
1
  import type { RefreshCookieConfig } from "../types";
2
2
  /**
3
- * First-party persistence for the rotating refresh token, keyed by `host` so
4
- * pointing the SDK at a different Flow ID origin (dev vs prod) doesn't
5
- * collide. Two modes:
6
- *
7
- * - With a cookie config (`createFlow({ cookies: true })`) the first-party
8
- * cookie is the sole store. The app's own server reads and rotates it
9
- * during SSR, so any second copy only drifts behind on each rotation —
10
- * and presenting a superseded token looks like theft to the server's
11
- * reuse detection, which revokes the whole lineage (self-logout).
12
- *
13
- * - Without one (a consumer with no server to share the token with), it
14
- * lives in the app's own localStorage: origin-scoped, survives reloads,
15
- * and — unlike the old id.flow.industries cookie — never third-party, so
16
- * iOS ITP can't block it.
3
+ * First-party persistence for the rotating refresh token. It lives in the
4
+ * consumer app's OWN localStorage (origin-scoped), so it survives reloads and
5
+ * navigations and — unlike the old id.flow.industries cookie — is never a
6
+ * third-party cookie, so iOS ITP can't block it. Keyed by `host` so pointing
7
+ * the SDK at a different Flow ID origin (dev vs prod) doesn't collide.
17
8
  */
18
9
  export interface RefreshStore {
19
10
  get(): string | null;
20
11
  set(token: string): void;
21
12
  clear(): void;
22
13
  }
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
+ */
23
22
  export declare function makeRefreshStore(host: string, cookie?: RefreshCookieConfig): RefreshStore;
@@ -1,76 +1,66 @@
1
1
  import { clearBrowserCookie, REFRESH_COOKIE_MAX_AGE_S, readBrowserCookie, writeBrowserCookie, } from "../cookies";
2
- function localStorageStore(key) {
3
- return {
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) {
11
+ const key = `flow.id.refresh:${host}`;
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) {
30
+ // Never persist a missing token: localStorage.setItem coerces undefined
31
+ // to the literal "undefined", which later reads back as a bogus bearer
32
+ // and 401s as refresh_token_invalid. Fail safe by ignoring it instead.
33
+ if (!token)
34
+ return;
13
35
  try {
14
36
  localStorage.setItem(key, token);
15
37
  }
16
38
  catch { }
39
+ if (cookie) {
40
+ writeBrowserCookie(cookie.name, token, {
41
+ maxAge: REFRESH_COOKIE_MAX_AGE_S,
42
+ secure: cookie.secure,
43
+ });
44
+ }
17
45
  },
18
46
  clear() {
19
47
  try {
20
48
  localStorage.removeItem(key);
21
49
  }
22
50
  catch { }
51
+ if (cookie)
52
+ clearBrowserCookie(cookie.name, { secure: cookie.secure });
23
53
  },
24
54
  };
25
- }
26
- function cookieStore(cookie) {
27
- return {
28
- get() {
29
- return readBrowserCookie(document.cookie, cookie.name);
30
- },
31
- set(token) {
32
- writeBrowserCookie(cookie.name, token, {
33
- maxAge: REFRESH_COOKIE_MAX_AGE_S,
34
- secure: cookie.secure,
35
- });
36
- },
37
- clear() {
38
- clearBrowserCookie(cookie.name, { secure: cookie.secure });
39
- },
40
- };
41
- }
42
- /**
43
- * Never persist a missing token: both backends coerce undefined to the
44
- * literal "undefined", which later reads back as a bogus bearer and 401s as
45
- * refresh_token_invalid. Fail safe by ignoring it instead.
46
- */
47
- function withMissingTokenGuard(store) {
48
- return {
49
- ...store,
50
- set(token) {
51
- if (!token)
52
- return;
53
- store.set(token);
54
- },
55
- };
56
- }
57
- export function makeRefreshStore(host, cookie) {
58
- const legacy = localStorageStore(`flow.id.refresh:${host}`);
59
- if (!cookie)
60
- return withMissingTokenGuard(legacy);
61
- const store = cookieStore(cookie);
62
- // One-time takeover from the dual-write SDK (<= 0.10.0), which kept a
63
- // localStorage copy: promote it when no cookie exists yet (a session
64
- // predating the app's cookie mode), then drop the key — a leftover copy
65
- // goes stale on the next SSR rotation and replaying it trips lineage
66
- // revocation. Remove this block once every legacy token has rotated or
67
- // expired (REFRESH_COOKIE_MAX_AGE_S after 0.11.0 reaches all cookie-mode
68
- // apps).
69
- const legacyToken = legacy.get();
70
- if (legacyToken) {
71
- if (!store.get())
72
- store.set(legacyToken);
73
- legacy.clear();
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 { }
74
64
  }
75
- return withMissingTokenGuard(store);
65
+ return store;
76
66
  }
@@ -1,5 +1,5 @@
1
+ import { resolveIdHost } from "../id-host";
1
2
  import { createRoomsApi } from "./rooms";
2
- const DEFAULT_HOST = "https://id.flow.industries";
3
3
  function unavailable(method) {
4
4
  throw new Error(`flow.${method} is not available on a static Flow — it renders ` +
5
5
  "server-resolved state only. Interactive methods need the browser " +
@@ -16,7 +16,7 @@ function unavailable(method) {
16
16
  * API); anything interactive (login, dialog, signing) throws.
17
17
  */
18
18
  export function createStaticFlow(state, options = {}) {
19
- const host = (options.host ?? DEFAULT_HOST).replace(/\/+$/, "");
19
+ const host = resolveIdHost(options.host);
20
20
  const snapshot = Object.freeze({
21
21
  user: state?.user ?? null,
22
22
  jwt: state?.jwt ?? null,
@@ -1,8 +1,8 @@
1
1
  /**
2
- * First-party cookie names and (de)serialization shared by the browser SDK
3
- * (which persists 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.
2
+ * First-party session cookie names and (de)serialization. Cookies are owned
3
+ * exclusively by the app's server (the SSR resolver and the session route) —
4
+ * page JavaScript never reads or writes them, which is what lets them be
5
+ * HttpOnly. Isomorphic module: no browser globals at import time.
6
6
  */
7
7
  import type { FlowCookieNames } from "./types";
8
8
  export declare const JWT_COOKIE_MAX_AGE_S: number;
@@ -17,10 +17,10 @@ export declare const REFRESH_COOKIE_MAX_AGE_S: number;
17
17
  */
18
18
  export declare function cookieNamesFor(origin: string): FlowCookieNames;
19
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.
20
+ * Builds a Set-Cookie value. Host-only (no Domain), `SameSite=Lax`, `Path=/`,
21
+ * always `HttpOnly` (only the app's server touches these cookies — an XSS
22
+ * payload can never read the refresh token); `Secure` everywhere except
23
+ * plain-http localhost (Safari drops Secure cookies set over http).
24
24
  */
25
25
  export declare function serializeCookie(name: string, value: string, opts: {
26
26
  maxAge: number;
@@ -32,14 +32,3 @@ export declare function clearCookieString(name: string, opts: {
32
32
  }): string;
33
33
  /** Parses a Cookie request header into name → value; first occurrence wins (RFC 6265 practice). */
34
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;
@@ -1,8 +1,8 @@
1
1
  /**
2
- * First-party cookie names and (de)serialization shared by the browser SDK
3
- * (which persists 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.
2
+ * First-party session cookie names and (de)serialization. Cookies are owned
3
+ * exclusively by the app's server (the SSR resolver and the session route) —
4
+ * page JavaScript never reads or writes them, which is what lets them be
5
+ * HttpOnly. Isomorphic module: no browser globals at import time.
6
6
  */
7
7
  export const JWT_COOKIE_MAX_AGE_S = 60 * 60;
8
8
  export const REFRESH_COOKIE_MAX_AGE_S = 30 * 24 * 60 * 60;
@@ -34,14 +34,14 @@ export function cookieNamesFor(origin) {
34
34
  };
35
35
  }
36
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.
37
+ * Builds a Set-Cookie value. Host-only (no Domain), `SameSite=Lax`, `Path=/`,
38
+ * always `HttpOnly` (only the app's server touches these cookies — an XSS
39
+ * payload can never read the refresh token); `Secure` everywhere except
40
+ * plain-http localhost (Safari drops Secure cookies set over http).
41
41
  */
42
42
  export function serializeCookie(name, value, opts) {
43
43
  const secure = opts.secure ? "; Secure" : "";
44
- return `${name}=${value}; Path=/; SameSite=Lax; Max-Age=${opts.maxAge}${secure}`;
44
+ return `${name}=${value}; Path=/; SameSite=Lax; Max-Age=${opts.maxAge}; HttpOnly${secure}`;
45
45
  }
46
46
  /** Builds a Set-Cookie value that deletes the cookie. */
47
47
  export function clearCookieString(name, opts) {
@@ -63,19 +63,3 @@ export function parseCookieHeader(header) {
63
63
  }
64
64
  return out;
65
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
- }