@flow-industries/id 0.11.0 → 0.13.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,106 @@ 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 delivery of any additional-audience sessions a mint returned
156
+ * (AUTH-39). Handed straight to the consumer's callback and dropped — the
157
+ * SDK retains nothing; the consumer forwards each to its audience's own
158
+ * origin (the embed bootstrap) to be sealed HttpOnly there.
159
+ */
160
+ function deliverAdditionalSessions(sessions) {
161
+ if (!sessions?.length || !options.onAdditionalSessions)
162
+ return;
163
+ try {
164
+ options.onAdditionalSessions(sessions);
165
+ }
166
+ catch (err) {
167
+ console.warn("Flow ID: onAdditionalSessions handler threw", err);
168
+ }
169
+ }
170
+ /**
171
+ * One-time handoff of a freshly minted session (dialog login or guest
172
+ * mint) to the app's server, which verifies the JWT against the issuer's
173
+ * JWKS and sets the HttpOnly cookies — the refresh token is never stored
174
+ * where page JavaScript could read it back. Best-effort: without a
175
+ * reachable session route the in-memory session still works, it just
176
+ * can't survive a reload.
177
+ */
178
+ async function installSession(refreshToken, jwt) {
179
+ if (!refreshToken || !jwt)
180
+ return;
157
181
  try {
158
- const res = await fetch(`${host}/api/session/refresh`, {
182
+ const res = await fetch(sessionPath, {
159
183
  method: "POST",
160
- headers: { Authorization: `Bearer ${token}` },
184
+ headers: { "Content-Type": "application/json" },
185
+ body: JSON.stringify({ refreshToken, jwt }),
161
186
  });
162
187
  if (!res.ok) {
163
- if (res.status === 401)
164
- refreshStore.clear();
165
- return false;
188
+ console.warn(`Flow ID: session route ${sessionPath} answered ${res.status}; ` +
189
+ "the session will not survive a reload");
166
190
  }
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);
180
- }
181
- return true;
182
191
  }
183
192
  catch {
184
- return false;
193
+ console.warn(`Flow ID: no session route at ${sessionPath}; ` +
194
+ "the session will not survive a reload");
185
195
  }
186
196
  }
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.
197
+ // Every session-route call — boot, ensureGuest, and getToken — funnels
198
+ // through this one lock so concurrent tabs don't fan out parallel
199
+ // rotations. The server dedupes and the auth server's grace window absorbs
200
+ // stragglers, but serializing here means the common case is one rotation,
201
+ // and the guard short-circuits a caller that arrives after a concurrent
202
+ // holder already minted a fresh JWT.
196
203
  async function doRefresh() {
197
- return withGuestLock(`flow.id.refresh:${host}`, async () => {
204
+ return withOriginLock("flow.id.refresh", async () => {
198
205
  const current = store.getSnapshot().jwt;
199
206
  if (current && !isExpiring(current))
200
207
  return true;
201
- return refreshViaToken();
208
+ return refreshViaSession();
202
209
  });
203
210
  }
204
211
  const refreshJwt = doRefresh;
@@ -247,19 +254,20 @@ export function createFlow(options = {}) {
247
254
  if (store.getSnapshot().user)
248
255
  return true;
249
256
  if (!guestInFlight) {
250
- guestInFlight = withGuestLock(`flow.id.guest:${host}`, async () => {
257
+ guestInFlight = withOriginLock("flow.id.guest", async () => {
251
258
  // 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.
259
+ // we waited. Refresh-first (from this origin's session cookie)
260
+ // recovers an existing session instead of minting a duplicate guest.
254
261
  if (store.getSnapshot().user)
255
262
  return true;
256
263
  if (await doRefresh())
257
264
  return true;
258
265
  try {
259
- const result = await getDialog().requestSilent(METHODS.guest, []);
266
+ const result = await getDialog().requestSilent(METHODS.guest, options.additionalAudiences?.length
267
+ ? [{ additionalAudiences: options.additionalAudiences }]
268
+ : []);
260
269
  if (!result.user)
261
270
  return false;
262
- refreshStore.set(result.refreshToken);
263
271
  store.setState({
264
272
  user: result.user,
265
273
  jwt: result.jwt,
@@ -268,6 +276,8 @@ export function createFlow(options = {}) {
268
276
  // address already in state rather than stripping signing.
269
277
  ...(result.user.isGuest ? { credential: null, address: null } : {}),
270
278
  });
279
+ await installSession(result.refreshToken, result.jwt);
280
+ deliverAdditionalSessions(result.additionalSessions);
271
281
  return true;
272
282
  }
273
283
  catch {
@@ -303,7 +313,7 @@ export function createFlow(options = {}) {
303
313
  getState: () => store.getSnapshot(),
304
314
  getChain,
305
315
  getTransport,
306
- ...(options.rpId ? { rpId: options.rpId } : {}),
316
+ ...(rpId ? { rpId } : {}),
307
317
  ...(accessKeyOptions?.strict ? { strict: accessKeyOptions.strict } : {}),
308
318
  };
309
319
  }
@@ -345,13 +355,20 @@ export function createFlow(options = {}) {
345
355
  extraCapabilities = { ...(extraCapabilities ?? {}), guestToken };
346
356
  }
347
357
  }
348
- const { session, webauthn, refreshToken } = await runLogin({
358
+ if (options.additionalAudiences?.length) {
359
+ extraCapabilities = {
360
+ ...(extraCapabilities ?? {}),
361
+ additionalAudiences: options.additionalAudiences,
362
+ };
363
+ }
364
+ const { session, webauthn, refreshToken, additionalSessions } = await runLogin({
349
365
  dialog: dialogHost,
350
366
  store,
351
367
  options: loginOpts,
352
368
  ...(extraCapabilities ? { extraCapabilities } : {}),
353
369
  });
354
- refreshStore.set(refreshToken);
370
+ await installSession(refreshToken, session.jwt);
371
+ deliverAdditionalSessions(additionalSessions);
355
372
  if (accessKeyModule && accessKeyPrep && webauthn) {
356
373
  await accessKeyModule.finalizeAccessKey({
357
374
  address: session.address,
@@ -369,24 +386,24 @@ export function createFlow(options = {}) {
369
386
  return session;
370
387
  }
371
388
  /**
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.
389
+ * Performs a full sign-out: tells the id.flow.industries dialog to
390
+ * invalidate its cookie session (so other Flow apps can't silently restore
391
+ * it), tells the app's session route to revoke the refresh lineage and
392
+ * clear the HttpOnly cookies, then clears local credential and access-key
393
+ * state and resets the in-memory store.
375
394
  *
376
- * Server-side sign-out is best-effort — if the network call fails (e.g.,
395
+ * Both server-side steps are best-effort — if a network call fails (e.g.,
377
396
  * offline) we still clear local state so the UI reflects "signed out". The
378
- * cookie will eventually expire on its own.
397
+ * cookies and tokens eventually expire on their own.
379
398
  */
380
399
  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
- }
400
+ // Both server-side sign-outs are independent and best-effort — if either
401
+ // fails (offline?) local state still clears so the UI reads "signed out".
402
+ await Promise.allSettled([
403
+ getDialog().requestSilent(METHODS.signOut, []),
404
+ fetch(sessionPath, { method: "DELETE" }),
405
+ ]);
388
406
  await runLogout(store);
389
- refreshStore.clear();
390
407
  dialog?.close();
391
408
  // An autoGuest app is never truly "signed out" — it always wants at least a
392
409
  // guest session. Re-mint one so the UI (e.g. the profile widget pill) keeps
@@ -1,4 +1,5 @@
1
- export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, LoginOptions, MethodName, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
1
+ export { defaultIdHost, isLocalHostname } from "../id-host";
2
+ export type { AccessKeyOptions, AdditionalSession, 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";
4
5
  export { METHODS } from "./methods";
@@ -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
  }
@@ -40,7 +40,7 @@ export async function runLogin(params) {
40
40
  const result = (await dialog.request(METHODS.connect, [
41
41
  { capabilities },
42
42
  ]));
43
- const { jwt, refreshToken, user, credential, webauthn } = result;
43
+ const { jwt, refreshToken, user, credential, webauthn, additionalSessions } = result;
44
44
  const address = credentialToAddress(credential);
45
45
  await idb.set(CREDENTIAL_KEY, credential);
46
46
  store.setState({ user, jwt, credential, address });
@@ -48,6 +48,7 @@ export async function runLogin(params) {
48
48
  session: { user, jwt, credential, address },
49
49
  refreshToken,
50
50
  webauthn,
51
+ ...(additionalSessions ? { additionalSessions } : {}),
51
52
  };
52
53
  }
53
54
  /**
@@ -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;