@flow-industries/id 0.22.1 → 0.23.1

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,208 +1,145 @@
1
1
  import { resolveIdHost } from "../id-host";
2
2
  import { getFlow, requireFlow } from "./create-flow";
3
- import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, sendBridgeNotification, } from "./iframe-host";
4
- import { setOverlayFocus } from "./overlay-focus";
5
3
  const POSITION_STYLE = {
6
4
  "top-right": { top: "0", right: "0" },
7
5
  "top-left": { top: "0", left: "0" },
8
6
  "bottom-right": { bottom: "0", right: "0" },
9
7
  "bottom-left": { bottom: "0", left: "0" },
10
8
  };
11
- /**
12
- * Mounts the universal Flow ID "profile button" — a persistent inline iframe
13
- * showing the signed-in user's avatar + username (the collapsed pill). Clicking
14
- * it opens the profile dialog in a SEPARATE fullscreen overlay iframe, so the
15
- * pill stays in place behind the dialog's dimmed backdrop rather than morphing
16
- * into it. Both iframes ride the dialog SPA at `${host}/dialog/` (routes
17
- * `/dialog/profile-pill` and `/dialog/profile`) so the chrome matches the auth
18
- * dialog and the widget is first-party to Flow ID (reads `/api/me`, uploads
19
- * avatars with the cookie session).
20
- *
21
- * Resolves the Flow singleton automatically (createFlow() must have run), so
22
- * callers don't thread the instance through. No-op under SSR.
23
- */
9
+ /** Mount a native account button that opens Flow ID in a normal browser tab. */
24
10
  export function createProfileButton(options) {
25
11
  if (!("document" in globalThis))
26
12
  return { setTheme() { }, destroy() { } };
27
13
  const flow = options.flow ?? getFlow() ?? requireFlow();
28
14
  const host = resolveIdHost(options.host ?? flow.host);
29
- const hostOrigin = new URL(host).origin;
30
- // Mutable so `setTheme` recolors over the open bridge, and so a dialog opened
31
- // after a toggle is initialized with the theme the host is actually showing.
32
- let theme = options.theme ?? "light dark";
33
- let createdContainer = null;
34
- let container = options.container ?? null;
35
- if (!container) {
36
- const el = document.createElement("div");
37
- Object.assign(el.style, {
15
+ const createdContainer = options.container
16
+ ? null
17
+ : document.createElement("div");
18
+ const container = options.container ?? createdContainer;
19
+ if (!container)
20
+ return { setTheme() { }, destroy() { } };
21
+ if (createdContainer) {
22
+ Object.assign(createdContainer.style, {
38
23
  position: "fixed",
39
24
  zIndex: "2147483000",
40
- // Never steal input from the host (e.g. the Godot canvas) — only the
41
- // iframe re-enables pointer events for itself.
42
- pointerEvents: "none",
43
25
  padding: options.padding ?? "1.5rem",
44
26
  ...POSITION_STYLE[options.position ?? "top-right"],
45
27
  });
46
- document.body.appendChild(el);
47
- createdContainer = el;
48
- container = el;
49
- }
50
- function makeFrame(title) {
51
- const frame = makeIframe(`${host}/dialog/`, title);
52
- frame.dataset.flowProfile = "";
53
- frame.style.display = "block";
54
- return frame;
55
- }
56
- // A page may host several same-origin Flow iframes (this pill, this dialog,
57
- // the auth login dialog). Origin filtering can't tell them apart, so each
58
- // bridge is bound to its own iframe window — otherwise their `ready`/`close`
59
- // handshakes would cross-fire. Identity is pinned to the host origin so the
60
- // username is never broadcast with "*".
61
- function makeBridge(frame, route) {
62
- const bridge = bridgeToWindow(frame.contentWindow, {
63
- targetOrigin: hostOrigin,
64
- });
65
- sendBridgeNotification(bridge, "__internal", {
66
- type: "init",
67
- mode: "iframe",
68
- referrer: { title: document.title },
69
- theme: { colorScheme: theme },
70
- route,
71
- });
72
- return bridge;
73
- }
74
- // ----- the persistent pill (stays mounted; sizes to its own content) -----
75
- const pill = makeFrame("Flow ID profile button");
76
- let pillWidth = 0;
77
- let pillHeight = 0;
78
- function applyPillSize() {
79
- Object.assign(pill.style, {
80
- position: "static",
81
- width: `${pillWidth}px`,
82
- height: `${pillHeight}px`,
83
- pointerEvents: pillWidth > 0 ? "auto" : "none",
84
- });
28
+ document.body.appendChild(createdContainer);
85
29
  }
86
- applyPillSize();
87
- container.appendChild(pill);
88
- const pillBridge = makeBridge(pill, "/dialog/profile-pill");
89
- pillBridge.on("__internal", (payload) => {
90
- if (payload.type === "profile-resize") {
91
- pillWidth = payload.width;
92
- pillHeight = payload.height;
93
- applyPillSize();
94
- }
95
- else if (payload.type === "profile-expand") {
96
- showDialog();
97
- }
98
- else if (payload.type === "profile-token-request") {
99
- void answerTokenRequest(pillBridge, flow, payload.id);
100
- }
30
+ const button = document.createElement("button");
31
+ button.type = "button";
32
+ button.dataset.flowProfile = "";
33
+ Object.assign(button.style, {
34
+ display: "flex",
35
+ alignItems: "center",
36
+ gap: "8px",
37
+ border: "1px solid color-mix(in srgb, currentColor 18%, transparent)",
38
+ borderRadius: "999px",
39
+ padding: "10px 16px",
40
+ background: "Canvas",
41
+ color: "CanvasText",
42
+ font: "inherit",
43
+ cursor: "pointer",
44
+ pointerEvents: "auto",
45
+ maxWidth: "240px",
46
+ overflow: "hidden",
47
+ textOverflow: "ellipsis",
48
+ whiteSpace: "nowrap",
101
49
  });
102
- // ----- the dialog overlay (lazy; a separate fullscreen iframe layered ABOVE
103
- // the pill, so the pill shows through its dimmed backdrop) -----
104
- let dialog = null;
105
- let dialogBridge = null;
106
- function ensureDialog() {
107
- if (dialog)
50
+ const notice = document.createElement("span");
51
+ notice.setAttribute("role", "status");
52
+ Object.assign(notice.style, {
53
+ display: "block",
54
+ font: "12px sans-serif",
55
+ maxWidth: "240px",
56
+ });
57
+ const avatar = document.createElement("span");
58
+ Object.assign(avatar.style, {
59
+ display: "flex",
60
+ alignItems: "center",
61
+ justifyContent: "center",
62
+ width: "28px",
63
+ height: "28px",
64
+ borderRadius: "50%",
65
+ overflow: "hidden",
66
+ background: "color-mix(in srgb, currentColor 10%, transparent)",
67
+ flexShrink: "0",
68
+ });
69
+ const name = document.createElement("span");
70
+ button.append(avatar, name);
71
+ const fallback = document.createElement("a");
72
+ fallback.href = `${host}/account`;
73
+ fallback.target = "_blank";
74
+ fallback.rel = "noopener noreferrer";
75
+ fallback.textContent = "Open Flow ID";
76
+ fallback.hidden = true;
77
+ let lastUsername;
78
+ let profileRequest = null;
79
+ function update() {
80
+ const user = flow.getState().user;
81
+ name.textContent = user?.username ?? "Flow ID";
82
+ button.setAttribute("aria-label", `Open ${user?.username ?? "your"} Flow ID account in a new tab`);
83
+ if (user?.username === lastUsername)
108
84
  return;
109
- dialog = makeFrame("Flow ID profile");
110
- dialog.inert = true;
111
- Object.assign(dialog.style, OVERLAY_STYLE, {
112
- pointerEvents: "none",
113
- });
114
- document.body.appendChild(dialog);
115
- dialogBridge = makeBridge(dialog, "/dialog/profile");
116
- dialogBridge.on("close", () => hideDialog());
117
- dialogBridge.on("__internal", (payload) => {
118
- if (payload.type === "profile-login") {
119
- hideDialog();
120
- void flow
121
- .login(payload.mode === "signup" ? { signUp: true } : { signIn: true })
122
- .catch(() => { });
123
- }
124
- else if (payload.type === "profile-logout") {
125
- hideDialog();
126
- void flow.logout().catch(() => { });
127
- }
128
- else if (payload.type === "profile-token-request") {
129
- if (dialogBridge)
130
- void answerTokenRequest(dialogBridge, flow, payload.id);
131
- }
132
- });
133
- sendBridgeNotification(dialogBridge, "__internal", {
134
- type: "profile-identity",
135
- identity: hostIdentity(flow),
136
- });
137
- }
138
- // The dialog owns its open/close animation entirely; the host only signals
139
- // visibility and toggles hit-testing. The overlay iframe is NEVER set to
140
- // display:none — that pauses the iframe's requestAnimationFrame, so motion's
141
- // engine is asleep when the next open fires and the animation jumps straight
142
- // to its end (instant). Keeping it always-rendered, transparent and
143
- // click-through while hidden keeps every open/close animation reliable.
144
- function showDialog() {
145
- ensureDialog();
146
- if (!dialog)
85
+ lastUsername = user?.username;
86
+ profileRequest?.abort();
87
+ avatar.textContent = user?.username.slice(0, 1).toUpperCase() ?? "F";
88
+ if (!user || user.isGuest)
147
89
  return;
148
- dialog.style.pointerEvents = "auto";
149
- setOverlayFocus(dialog, true);
150
- sendBridgeNotification(dialogBridge, "__internal", {
151
- type: "dialog-shown",
152
- });
90
+ const controller = new AbortController();
91
+ profileRequest = controller;
92
+ void fetch(`${host}/api/users/${encodeURIComponent(user.username)}`, {
93
+ signal: controller.signal,
94
+ credentials: "omit",
95
+ })
96
+ .then(async (response) => {
97
+ if (!response.ok)
98
+ return;
99
+ // SAFETY: Flow ID owns the public profile endpoint and its optional image field.
100
+ const profile = (await response.json());
101
+ if (controller.signal.aborted || !profile.image)
102
+ return;
103
+ const image = document.createElement("img");
104
+ image.src = profile.image;
105
+ image.alt = "";
106
+ Object.assign(image.style, {
107
+ width: "100%",
108
+ height: "100%",
109
+ objectFit: "cover",
110
+ });
111
+ avatar.replaceChildren(image);
112
+ })
113
+ .catch(() => { });
153
114
  }
154
- function hideDialog() {
155
- if (!dialog)
156
- return;
157
- dialog.style.pointerEvents = "none";
158
- setOverlayFocus(dialog, false);
159
- sendBridgeNotification(dialogBridge, "__internal", {
160
- type: "dialog-hidden",
161
- });
115
+ button.addEventListener("click", () => {
116
+ const opened = flow.openAccount();
117
+ notice.textContent = opened
118
+ ? ""
119
+ : "Your browser blocked the new tab. Open Flow ID below to sign in.";
120
+ fallback.hidden = opened;
121
+ });
122
+ function refresh() {
123
+ lastUsername = undefined;
124
+ update();
162
125
  }
163
- // ----- keep both iframes' identity in sync with the Flow session -----
164
- // flow.subscribe fires on any store change (jwt/credential/address too), so
165
- // skip re-sending an identical identity (e.g. on every silent JWT refresh).
166
- let lastIdentityKey;
167
- function pushIdentity() {
168
- const identity = hostIdentity(flow);
169
- const key = identity ? `${identity.username}|${identity.isGuest}` : "none";
170
- if (key === lastIdentityKey)
171
- return;
172
- lastIdentityKey = key;
173
- sendBridgeNotification(pillBridge, "__internal", {
174
- type: "profile-identity",
175
- identity,
176
- });
177
- if (dialogBridge)
178
- sendBridgeNotification(dialogBridge, "__internal", {
179
- type: "profile-identity",
180
- identity,
181
- });
126
+ window.addEventListener("focus", refresh);
127
+ function setTheme(theme) {
128
+ button.style.colorScheme = theme;
182
129
  }
183
- pushIdentity();
184
- const unsubscribe = flow.subscribe(pushIdentity);
130
+ setTheme(options.theme ?? "light dark");
131
+ container.append(button, notice, fallback);
132
+ update();
133
+ const unsubscribe = flow.subscribe(update);
185
134
  return {
186
- setTheme(next) {
187
- if (next === theme)
188
- return;
189
- theme = next;
190
- sendBridgeNotification(pillBridge, "__internal", {
191
- type: "set-theme",
192
- theme: { colorScheme: next },
193
- });
194
- sendBridgeNotification(dialogBridge, "__internal", {
195
- type: "set-theme",
196
- theme: { colorScheme: next },
197
- });
198
- },
135
+ setTheme,
199
136
  destroy() {
200
- hideDialog();
201
137
  unsubscribe();
202
- pillBridge.destroy();
203
- dialogBridge?.destroy();
204
- pill.remove();
205
- dialog?.remove();
138
+ profileRequest?.abort();
139
+ window.removeEventListener("focus", refresh);
140
+ fallback.remove();
141
+ button.remove();
142
+ notice.remove();
206
143
  createdContainer?.remove();
207
144
  },
208
145
  };
@@ -1,4 +1,4 @@
1
- import type { Address, FlowCredential, FlowState, RunLoginParams, RunLoginResult, Store } from "../types";
1
+ import type { Address, FlowCredential, FlowState, Store } from "../types";
2
2
  /**
3
3
  * Reads the active credential from IndexedDB and seeds the in-memory store
4
4
  * with `{ credential, address }`. Run on every page load so signing operations
@@ -9,15 +9,6 @@ import type { Address, FlowCredential, FlowState, RunLoginParams, RunLoginResult
9
9
  * not an error.
10
10
  */
11
11
  export declare function restoreCredential(store: Store<FlowState>): Promise<void>;
12
- /**
13
- * Drives the interactive `wallet_connect` flow through the dialog and
14
- * commits the result to local state.
15
- *
16
- * Capabilities are intent only (signUp / signIn / signInHeadless / extras like
17
- * accessKeyHash). Remembering "which account did this user use last" is
18
- * the dialog's job — the client doesn't track or pass credentialIds.
19
- */
20
- export declare function runLogin(params: RunLoginParams): Promise<RunLoginResult>;
21
12
  /**
22
13
  * Clears local-only auth state: active credential in IDB, the access key for
23
14
  * the current address (if any), and the in-memory store. Does NOT touch the
@@ -2,7 +2,6 @@ import * as OxAddress from "ox/Address";
2
2
  import * as PublicKey from "ox/PublicKey";
3
3
  import { hex0x } from "../hex";
4
4
  import { idb } from "./idb";
5
- import { METHODS } from "./methods";
6
5
  import { initialFlowState } from "./store";
7
6
  const CREDENTIAL_KEY = "flow.activeCredential";
8
7
  /**
@@ -21,42 +20,6 @@ export async function restoreCredential(store) {
21
20
  const address = credentialToAddress(credential);
22
21
  store.setState({ credential, address });
23
22
  }
24
- /**
25
- * Drives the interactive `wallet_connect` flow through the dialog and
26
- * commits the result to local state.
27
- *
28
- * Capabilities are intent only (signUp / signIn / signInHeadless / extras like
29
- * accessKeyHash). Remembering "which account did this user use last" is
30
- * the dialog's job — the client doesn't track or pass credentialIds.
31
- */
32
- export async function runLogin(params) {
33
- const { dialog, store, options, extraCapabilities } = params;
34
- const signUp = Boolean(options?.signUp);
35
- const capabilities = {
36
- createAccount: signUp ? true : undefined,
37
- signIn: !signUp && options?.signIn ? true : undefined,
38
- signInHeadless: !signUp && options?.signInHeadless ? true : undefined,
39
- ...(extraCapabilities ?? {}),
40
- };
41
- /* SAFETY: the connect method's response shape, which the dialog route answers. */
42
- const result = (await dialog.request(METHODS.connect, [
43
- { capabilities },
44
- ]));
45
- const { jwt, refreshToken, user, credential, webauthn, additionalSessions } = result;
46
- // An email-only login carries no credential — mirror the guest handling:
47
- // nothing is persisted to IDB (reserved for a real passkey credential) and
48
- // the address stays null until a passkey is added.
49
- const address = credential ? credentialToAddress(credential) : null;
50
- if (credential)
51
- await idb.set(CREDENTIAL_KEY, credential);
52
- store.setState({ user, jwt, credential, address });
53
- return {
54
- session: { user, jwt, credential, address },
55
- refreshToken,
56
- webauthn,
57
- additionalSessions: additionalSessions ? additionalSessions : undefined,
58
- };
59
- }
60
23
  /**
61
24
  * Clears local-only auth state: active credential in IDB, the access key for
62
25
  * the current address (if any), and the in-memory store. Does NOT touch the
@@ -44,6 +44,7 @@ export function createStaticFlow(state, options = {}) {
44
44
  return snapshot.user?.isGuest === true;
45
45
  },
46
46
  login: () => unavailable("login"),
47
+ openAccount: () => unavailable("openAccount"),
47
48
  logout: () => unavailable("logout"),
48
49
  restore: async () => false,
49
50
  ensureGuest: () => unavailable("ensureGuest"),
@@ -0,0 +1,3 @@
1
+ /** Resolve a trusted server transport without changing the public JWT issuer. */
2
+ export declare function resolveIssuerApiUrl(issuerUrl: string, override?: string): string;
3
+ export declare function fetchIssuerApi(url: string, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,28 @@
1
+ /** Resolve a trusted server transport without changing the public JWT issuer. */
2
+ export function resolveIssuerApiUrl(issuerUrl, override) {
3
+ const configured = override ??
4
+ (globalThis.process !== undefined
5
+ ? process.env.FLOW_ID_API_URL
6
+ : undefined);
7
+ const url = new URL(configured ?? issuerUrl);
8
+ if (!["http:", "https:"].includes(url.protocol) ||
9
+ url.username ||
10
+ url.password ||
11
+ url.search ||
12
+ url.hash ||
13
+ url.pathname !== "/") {
14
+ throw new Error("Flow ID API URL must be an HTTP(S) origin without credentials, path, query, or fragment");
15
+ }
16
+ return url.origin;
17
+ }
18
+ export async function fetchIssuerApi(url, init) {
19
+ const response = await fetch(url, { ...init, redirect: "error" });
20
+ if (!response.ok) {
21
+ console.warn("Flow ID API request failed", {
22
+ path: new URL(url).pathname,
23
+ status: response.status,
24
+ contentType: response.headers.get("content-type")?.split(";")[0] ?? null,
25
+ });
26
+ }
27
+ return response;
28
+ }
@@ -28,7 +28,7 @@ export declare function useFlowId(): {
28
28
  address: `0x${string}` | null;
29
29
  isAuthenticated: boolean;
30
30
  isGuest: boolean;
31
- login: (options?: import("../types").LoginOptions) => Promise<import("../types").Session>;
31
+ login: (options?: import("../types").LoginOptions) => Promise<never>;
32
32
  logout: () => Promise<void>;
33
33
  restore: () => Promise<boolean>;
34
34
  ensureGuest: () => Promise<boolean>;
@@ -1,14 +1,3 @@
1
1
  import type { ProfileButtonProps } from "../types";
2
- /**
3
- * Drops the universal Flow ID profile button into a React tree. Renders a host
4
- * element (style it via `className` for placement/padding — e.g. top-right) that
5
- * the pill iframe mounts into; the iframe itself is sized to its content and
6
- * expands to the profile dialog on click. Resolves the Flow instance from a
7
- * `<FlowIdProvider>` or the createFlow() singleton.
8
- *
9
- * A changing `theme` recolors the mounted pill over its open bridge rather than
10
- * remounting it: `theme` is deliberately absent from the mount effect's deps,
11
- * because tearing the iframe down on a light/dark toggle reloads the widget and
12
- * drops the pill out of the host's layout mid-toggle.
13
- */
2
+ /** Mount the native account button with access to the current Flow session. */
14
3
  export declare function ProfileButton({ className, host, theme }: ProfileButtonProps): import("react/jsx-runtime").JSX.Element;
@@ -2,18 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useRef } from "react";
3
3
  import { createProfileButton } from "../client/profile-button";
4
4
  import { useFlow } from "./hooks";
5
- /**
6
- * Drops the universal Flow ID profile button into a React tree. Renders a host
7
- * element (style it via `className` for placement/padding — e.g. top-right) that
8
- * the pill iframe mounts into; the iframe itself is sized to its content and
9
- * expands to the profile dialog on click. Resolves the Flow instance from a
10
- * `<FlowIdProvider>` or the createFlow() singleton.
11
- *
12
- * A changing `theme` recolors the mounted pill over its open bridge rather than
13
- * remounting it: `theme` is deliberately absent from the mount effect's deps,
14
- * because tearing the iframe down on a light/dark toggle reloads the widget and
15
- * drops the pill out of the host's layout mid-toggle.
16
- */
5
+ /** Mount the native account button with access to the current Flow session. */
17
6
  export function ProfileButton({ className, host, theme }) {
18
7
  const flow = useFlow();
19
8
  const ref = useRef(null);
@@ -1,10 +1,11 @@
1
1
  import { resolveIdHost } from "./id-host";
2
+ import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
2
3
  import { resolveSessionCore } from "./session-core";
3
4
  export { createSessionHandler, handleSessionRequest } from "./session-route";
4
5
  export { verifyFlowJWT } from "./verify";
5
- async function fetchProfile(issuerUrl, jwt, audience) {
6
+ async function fetchProfile(apiUrl, jwt, audience) {
6
7
  try {
7
- const res = await fetch(`${issuerUrl}/api/session/verify`, {
8
+ const res = await fetchIssuerApi(`${apiUrl}/api/session/verify`, {
8
9
  method: "POST",
9
10
  headers: { "Content-Type": "application/json" },
10
11
  body: JSON.stringify({ token: jwt, audience }),
@@ -29,12 +30,13 @@ async function fetchProfile(issuerUrl, jwt, audience) {
29
30
  */
30
31
  export async function resolveSession(cookieHeader, opts) {
31
32
  const issuerUrl = resolveIdHost(opts.issuerUrl, opts.audience);
32
- const { session, claims, setCookies } = await resolveSessionCore(cookieHeader, opts.audience, issuerUrl);
33
+ const apiUrl = resolveIssuerApiUrl(issuerUrl, opts.apiUrl);
34
+ const { session, claims, setCookies } = await resolveSessionCore(cookieHeader, opts.audience, issuerUrl, apiUrl);
33
35
  const state = session
34
36
  ? { user: session.user, jwt: session.jwt, address: session.address ?? null }
35
37
  : null;
36
38
  const profile = state && opts.profile
37
- ? await fetchProfile(issuerUrl, state.jwt, opts.audience)
39
+ ? await fetchProfile(apiUrl, state.jwt, opts.audience)
38
40
  : undefined;
39
41
  return {
40
42
  state,
@@ -27,7 +27,7 @@ export type ResolvedSessionCore = {
27
27
  * happened) but resolves signed-out rather than trusting it. Returns "dead"
28
28
  * for a definitive rejection and null for a transient network failure.
29
29
  */
30
- export declare function refreshSessionCore(issuerUrl: string, audience: string, refreshToken: string): Promise<ResolvedSessionCore | "dead" | null>;
30
+ export declare function refreshSessionCore(issuerUrl: string, audience: string, refreshToken: string, apiUrl?: string): Promise<ResolvedSessionCore | "dead" | null>;
31
31
  /**
32
32
  * Resolves a session from a request's Cookie header.
33
33
  *
@@ -41,4 +41,4 @@ export declare function refreshSessionCore(issuerUrl: string, audience: string,
41
41
  * network failure leaves the cookies alone so a later request can retry.
42
42
  * 3. Neither cookie → signed-out `session: null`.
43
43
  */
44
- export declare function resolveSessionCore(cookieHeader: string | null | undefined, audience: string, issuerUrl: string): Promise<ResolvedSessionCore>;
44
+ export declare function resolveSessionCore(cookieHeader: string | null | undefined, audience: string, issuerUrl: string, apiUrl?: string): Promise<ResolvedSessionCore>;
@@ -5,6 +5,7 @@ import { errField } from "./driver-error";
5
5
  * `session-route.ts` compose it into the public entry points.
6
6
  */
7
7
  import { clearCookieString, cookieNamesFor, JWT_COOKIE_MAX_AGE_S, parseCookieHeader, REFRESH_COOKIE_MAX_AGE_S, serializeCookie, } from "./cookies";
8
+ import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
8
9
  import { isExpiring } from "./token-expiry";
9
10
  import { verifyFlowJWT } from "./verify";
10
11
  export function claimsToUser(claims) {
@@ -16,9 +17,9 @@ export function claimsToUser(claims) {
16
17
  isGuest: claims.guest !== false,
17
18
  };
18
19
  }
19
- async function refreshSession(issuerUrl, token) {
20
+ async function refreshSession(token, apiUrl) {
20
21
  try {
21
- const res = await fetch(`${issuerUrl}/api/session/refresh`, {
22
+ const res = await fetchIssuerApi(`${apiUrl}/api/session/refresh`, {
22
23
  method: "POST",
23
24
  headers: { Authorization: `Bearer ${token}` },
24
25
  });
@@ -42,11 +43,11 @@ async function refreshSession(issuerUrl, token) {
42
43
  // other machines).
43
44
  const REFRESH_RESULT_TTL_MS = 30_000;
44
45
  const refreshInFlight = new Map();
45
- function refreshOnce(issuerUrl, token) {
46
- const key = `${issuerUrl}\n${token}`;
46
+ function refreshOnce(issuerUrl, token, apiUrl) {
47
+ const key = `${issuerUrl}\n${apiUrl}\n${token}`;
47
48
  let flight = refreshInFlight.get(key);
48
49
  if (!flight) {
49
- flight = refreshSession(issuerUrl, token);
50
+ flight = refreshSession(token, apiUrl);
50
51
  refreshInFlight.set(key, flight);
51
52
  void flight.finally(() => {
52
53
  const timer = setTimeout(() => refreshInFlight.delete(key), REFRESH_RESULT_TTL_MS);
@@ -100,14 +101,14 @@ export function clearingCookies(audience) {
100
101
  * happened) but resolves signed-out rather than trusting it. Returns "dead"
101
102
  * for a definitive rejection and null for a transient network failure.
102
103
  */
103
- export async function refreshSessionCore(issuerUrl, audience, refreshToken) {
104
- const result = await refreshOnce(issuerUrl, refreshToken);
104
+ export async function refreshSessionCore(issuerUrl, audience, refreshToken, apiUrl = resolveIssuerApiUrl(issuerUrl)) {
105
+ const result = await refreshOnce(issuerUrl, refreshToken, apiUrl);
105
106
  if (result === "dead" || !result)
106
107
  return result;
107
108
  const successorCookies = sessionCookies(audience, result.jwt, result.refreshToken);
108
109
  let claims;
109
110
  try {
110
- claims = await verifyFlowJWT(result.jwt, { audience, issuerUrl });
111
+ claims = await verifyFlowJWT(result.jwt, { audience, issuerUrl, apiUrl });
111
112
  }
112
113
  catch (err) {
113
114
  if (isJwtVerificationFailure(err)) {
@@ -143,13 +144,13 @@ export async function refreshSessionCore(issuerUrl, audience, refreshToken) {
143
144
  * network failure leaves the cookies alone so a later request can retry.
144
145
  * 3. Neither cookie → signed-out `session: null`.
145
146
  */
146
- export async function resolveSessionCore(cookieHeader, audience, issuerUrl) {
147
+ export async function resolveSessionCore(cookieHeader, audience, issuerUrl, apiUrl = resolveIssuerApiUrl(issuerUrl)) {
147
148
  const names = cookieNamesFor(audience);
148
149
  const cookies = parseCookieHeader(cookieHeader);
149
150
  const jwt = cookies[names.jwt];
150
151
  if (jwt && !isExpiring(jwt)) {
151
152
  try {
152
- const claims = await verifyFlowJWT(jwt, { audience, issuerUrl });
153
+ const claims = await verifyFlowJWT(jwt, { audience, issuerUrl, apiUrl });
153
154
  return {
154
155
  session: { user: claimsToUser(claims), jwt },
155
156
  claims,
@@ -165,7 +166,7 @@ export async function resolveSessionCore(cookieHeader, audience, issuerUrl) {
165
166
  const refreshToken = cookies[names.refresh];
166
167
  if (!refreshToken)
167
168
  return { session: null, claims: null, setCookies: [] };
168
- const refreshed = await refreshSessionCore(issuerUrl, audience, refreshToken);
169
+ const refreshed = await refreshSessionCore(issuerUrl, audience, refreshToken, apiUrl);
169
170
  if (refreshed === "dead") {
170
171
  return {
171
172
  session: null,