@flow-industries/id 0.10.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,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 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.
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 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.
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
- }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared defaults for the first-party session plumbing. The browser client,
3
+ * the SSR resolver, and the session route all resolve the Flow ID origin and
4
+ * the app-local session path from here, so consumer apps agree on both
5
+ * without carrying any per-app configuration.
6
+ */
7
+ export declare const DEFAULT_ISSUER_URL = "https://id.flow.industries";
8
+ /** Flow ID origin of a local auth stack (`bun run dev` in the auth repo). */
9
+ export declare const LOCAL_ISSUER_URL = "http://localhost:5175";
10
+ /**
11
+ * Path of the first-party session route every app serves from its own origin
12
+ * (GET resolve, POST install, DELETE sign-out). The browser client and the
13
+ * route registration must agree on it; override via `createFlow({ sessionPath })`
14
+ * plus the route's `path` option only when an app cannot claim this path.
15
+ */
16
+ export declare const DEFAULT_SESSION_PATH = "/flow/session";
17
+ export declare function isLocalHostname(hostname: string): boolean;
18
+ /**
19
+ * The app's public origin — the JWT audience and what cookie names derive
20
+ * from. The `APP_ORIGIN` env var wins (mandatory behind a TLS-terminating
21
+ * proxy, where the request origin is the internal http one); dev falls back
22
+ * to the request's own origin.
23
+ */
24
+ export declare function defaultAudience(requestOrigin: string): string;
25
+ /**
26
+ * Resolves the Flow ID origin for the current runtime: the `FLOW_ID_HOST`
27
+ * env var wins on servers, a localhost app talks to the local auth stack,
28
+ * everything else uses production. Pass the app origin when resolving on
29
+ * behalf of a request (servers have no `window`).
30
+ */
31
+ export declare function defaultIdHost(appOrigin?: string): string;
32
+ /**
33
+ * An explicit host override or the runtime default, normalized (no trailing
34
+ * slash) so callers can append paths without producing `//api/...` URLs.
35
+ */
36
+ export declare function resolveIdHost(override?: string | null, appOrigin?: string): string;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared defaults for the first-party session plumbing. The browser client,
3
+ * the SSR resolver, and the session route all resolve the Flow ID origin and
4
+ * the app-local session path from here, so consumer apps agree on both
5
+ * without carrying any per-app configuration.
6
+ */
7
+ export const DEFAULT_ISSUER_URL = "https://id.flow.industries";
8
+ /** Flow ID origin of a local auth stack (`bun run dev` in the auth repo). */
9
+ export const LOCAL_ISSUER_URL = "http://localhost:5175";
10
+ /**
11
+ * Path of the first-party session route every app serves from its own origin
12
+ * (GET resolve, POST install, DELETE sign-out). The browser client and the
13
+ * route registration must agree on it; override via `createFlow({ sessionPath })`
14
+ * plus the route's `path` option only when an app cannot claim this path.
15
+ */
16
+ export const DEFAULT_SESSION_PATH = "/flow/session";
17
+ export function isLocalHostname(hostname) {
18
+ return (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]");
19
+ }
20
+ /**
21
+ * The app's public origin — the JWT audience and what cookie names derive
22
+ * from. The `APP_ORIGIN` env var wins (mandatory behind a TLS-terminating
23
+ * proxy, where the request origin is the internal http one); dev falls back
24
+ * to the request's own origin.
25
+ */
26
+ export function defaultAudience(requestOrigin) {
27
+ return ((typeof process !== "undefined" ? process.env?.APP_ORIGIN : undefined) ??
28
+ requestOrigin);
29
+ }
30
+ /**
31
+ * Resolves the Flow ID origin for the current runtime: the `FLOW_ID_HOST`
32
+ * env var wins on servers, a localhost app talks to the local auth stack,
33
+ * everything else uses production. Pass the app origin when resolving on
34
+ * behalf of a request (servers have no `window`).
35
+ */
36
+ export function defaultIdHost(appOrigin) {
37
+ if (typeof process !== "undefined" && process.env?.FLOW_ID_HOST) {
38
+ return process.env.FLOW_ID_HOST;
39
+ }
40
+ const origin = appOrigin ??
41
+ (typeof window !== "undefined" ? window.location.origin : undefined);
42
+ if (origin) {
43
+ try {
44
+ if (isLocalHostname(new URL(origin).hostname))
45
+ return LOCAL_ISSUER_URL;
46
+ }
47
+ catch { }
48
+ }
49
+ return DEFAULT_ISSUER_URL;
50
+ }
51
+ /**
52
+ * An explicit host override or the runtime default, normalized (no trailing
53
+ * slash) so callers can append paths without producing `//api/...` URLs.
54
+ */
55
+ export function resolveIdHost(override, appOrigin) {
56
+ return (override ?? defaultIdHost(appOrigin)).replace(/\/+$/, "");
57
+ }
@@ -1,23 +1,13 @@
1
1
  import type { ResolvedFlowSession, ResolveSessionOptions } from "./types";
2
- export type { FlowSessionState, ResolvedFlowSession, ResolveSessionOptions, } from "./types";
2
+ export { createSessionHandler, handleSessionRequest } from "./session-route";
3
+ export type { FlowSessionState, ResolvedFlowSession, ResolveSessionOptions, SessionRouteOptions, SessionRouteResponse, SessionRouteSession, } from "./types";
3
4
  export { verifyFlowJWT } from "./verify";
4
5
  /**
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.
6
+ * Resolves the visitor's Flow session from the request's Cookie header for
7
+ * SSR: render auth-aware UI without a client round-trip, then feed `state`
8
+ * into the SSR payload and `createFlow({ initialState })` so the hydrated
9
+ * client renders it without a second refresh. Append every entry of
10
+ * `setCookies` to the response or the client is left holding a dead
11
+ * predecessor (a server-side refresh ROTATES the token).
22
12
  */
23
13
  export declare function resolveSession(cookieHeader: string | null | undefined, opts: ResolveSessionOptions): Promise<ResolvedFlowSession>;