@flow-industries/id 0.23.0 → 0.23.2

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.
package/README.md CHANGED
@@ -53,7 +53,7 @@ app path; read the new session after navigation instead of continuing after
53
53
 
54
54
  `ProfileButton` and `flow.openAccount()` open `/account` in a normal new tab.
55
55
  The source app stays loaded and refreshes its session on refocus. Sign-in and
56
- own-account management no longer use an embedded dialog. See the
56
+ own-account management no longer use an embedded dialog. The remaining dialog supports signing, public profiles, and widgets. SDK `ConnectResponse` remains a deprecated type alias for source compatibility; sign-in itself returns through the first-party callback. See the
57
57
  [integration guide](docs/integration-guide.md) for server wiring and migration.
58
58
 
59
59
  ## Public profile overlays
@@ -143,3 +143,5 @@ The current OpenAPI artifact describes the service report boundary. The remainin
143
143
  account, room management, study, settings, session and passkey HTTP operations
144
144
  still need complete request/response descriptions and generated client coverage
145
145
  before the full HTTP contract work is complete.
146
+
147
+ Server deployments may set `FLOW_ID_API_URL` to a trusted internal HTTP(S) origin for API and JWKS requests, or pass `apiUrl` to server session/verification helpers. `issuerUrl` remains the public JWT issuer and browser navigation origin. The transport URL must contain no credentials, path, query, or fragment.
@@ -4,5 +4,5 @@ export declare function startBrowserSession(request: Request, audience: string,
4
4
  returnTo?: string;
5
5
  signUp?: boolean;
6
6
  accessKeyHash?: string;
7
- }): Promise<Response>;
8
- export declare function finishBrowserSession(request: Request, audience: string, issuerUrl: string): Promise<Response>;
7
+ }, apiUrl?: string): Promise<Response>;
8
+ export declare function finishBrowserSession(request: Request, audience: string, issuerUrl: string, apiUrl?: string): Promise<Response>;
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { browserSigningSchema } from "./browser-contract";
3
3
  import { clearCookieString, cookieNamesFor, parseCookieHeader, serializeCookie, } from "./cookies";
4
+ import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
4
5
  import { claimsToUser, sessionCookies } from "./session-core";
5
6
  import { verifyFlowJWT } from "./verify";
6
7
  const opaquePattern = /^[A-Za-z0-9_-]{43}$/;
@@ -60,7 +61,7 @@ export function safeReturnTo(value, audience, sessionPath = "/flow/session") {
60
61
  return null;
61
62
  }
62
63
  }
63
- export async function startBrowserSession(request, audience, issuerUrl, body) {
64
+ export async function startBrowserSession(request, audience, issuerUrl, body, apiUrl = resolveIssuerApiUrl(issuerUrl)) {
64
65
  const cookies = parseCookieHeader(request.headers.get("Cookie"));
65
66
  const refresh = cookies[cookieNamesFor(audience).refresh];
66
67
  const headers = new Headers({ "Content-Type": "application/json" });
@@ -74,7 +75,7 @@ export async function startBrowserSession(request, audience, issuerUrl, body) {
74
75
  if (body.action === "account") {
75
76
  if (!refresh)
76
77
  return reply({ error: "signed_out" }, 401);
77
- const response = await fetch(`${issuerUrl}/api/auth/browser/account-handoff`, { method: "POST", headers, body: JSON.stringify({ audience }) });
78
+ const response = await fetchIssuerApi(`${apiUrl}/api/auth/browser/account-handoff`, { method: "POST", headers, body: JSON.stringify({ audience }) });
78
79
  if (!response.ok)
79
80
  return reply({ error: "account_unavailable" }, response.status);
80
81
  const { handoff } = z
@@ -88,7 +89,7 @@ export async function startBrowserSession(request, audience, issuerUrl, body) {
88
89
  const state = secret();
89
90
  const verifier = secret();
90
91
  const callback = `${audience}${new URL(request.url).pathname}`;
91
- const response = await fetch(`${issuerUrl}/api/auth/browser/transaction`, {
92
+ const response = await fetchIssuerApi(`${apiUrl}/api/auth/browser/transaction`, {
92
93
  method: "POST",
93
94
  headers,
94
95
  body: JSON.stringify({
@@ -121,7 +122,7 @@ export async function startBrowserSession(request, audience, issuerUrl, body) {
121
122
  }),
122
123
  ]);
123
124
  }
124
- export async function finishBrowserSession(request, audience, issuerUrl) {
125
+ export async function finishBrowserSession(request, audience, issuerUrl, apiUrl = resolveIssuerApiUrl(issuerUrl)) {
125
126
  const url = new URL(request.url);
126
127
  const state = url.searchParams.get("flow_state") ?? "";
127
128
  if (!opaquePattern.test(state))
@@ -154,7 +155,7 @@ export async function finishBrowserSession(request, audience, issuerUrl) {
154
155
  if (!opaquePattern.test(code))
155
156
  return reply({ error: "invalid_login_code" }, 400);
156
157
  try {
157
- const result = await fetch(`${issuerUrl}/api/auth/browser/token`, {
158
+ const result = await fetchIssuerApi(`${apiUrl}/api/auth/browser/token`, {
158
159
  method: "POST",
159
160
  headers: { "Content-Type": "application/json" },
160
161
  body: JSON.stringify({
@@ -170,7 +171,11 @@ export async function finishBrowserSession(request, audience, issuerUrl) {
170
171
  message: "Return to your app and start sign-in again.",
171
172
  }, 400, [clear]);
172
173
  const session = exchangeSchema.parse(await result.json());
173
- const claims = await verifyFlowJWT(session.jwt, { audience, issuerUrl });
174
+ const claims = await verifyFlowJWT(session.jwt, {
175
+ audience,
176
+ issuerUrl,
177
+ apiUrl,
178
+ });
174
179
  if (!claimsToUser(claims).id)
175
180
  throw new Error("Invalid session");
176
181
  for (const value of sessionCookies(audience, session.jwt, session.refreshToken))
@@ -72,7 +72,7 @@ export function createFlow(options = {}) {
72
72
  if (currentFlow)
73
73
  return currentFlow;
74
74
  const host = resolveIdHost(options.host);
75
- const dialogUrl = `${host}/dialog/?v=2`;
75
+ const dialogUrl = `${host}/dialog/`;
76
76
  const sessionPath = options.sessionPath ?? DEFAULT_SESSION_PATH;
77
77
  const rpId = options.rpId ??
78
78
  (isLocalHostname(window.location.hostname) ? "localhost" : undefined);
@@ -1,19 +1,7 @@
1
1
  import type { CreateDialogHostOptions, DialogHost } from "../types";
2
2
  /**
3
- * Creates a host that owns the dialog iframe (or popup) and exposes an RPC
4
- * channel into the Flow auth dialog at `id.flow.industries`.
5
- *
6
- * The iframe is mounted lazily on first use, kept hidden until `open()` is
7
- * called, and reused across requests so a single passkey-bound iframe handles
8
- * login, signing, and silent restore. The iframe is required (not just
9
- * `fetch`) because it's first-party to id.flow.industries and is the only way
10
- * to read the cross-origin cookie session in third-party-cookie-blocking
11
- * browsers (Safari, Firefox).
12
- *
13
- * Returned methods:
14
- * - `request()` — open the dialog UI and dispatch an RPC (interactive flows)
15
- * - `requestSilent()` — dispatch without showing the dialog (silent restore)
16
- * - `open()`/`close()` — manual visibility control
17
- * - `destroy()` — tear down the iframe and abort pending requests
3
+ * Creates the retained signing dialog host. Its iframe mounts lazily and is
4
+ * reused across signing requests. Authentication uses the first-party session
5
+ * handler and top-level authorization page independently.
18
6
  */
19
7
  export declare function createDialogHost(options: CreateDialogHostOptions): DialogHost;
@@ -16,21 +16,9 @@ const VISIBLE_STYLE = {
16
16
  background: "transparent",
17
17
  };
18
18
  /**
19
- * Creates a host that owns the dialog iframe (or popup) and exposes an RPC
20
- * channel into the Flow auth dialog at `id.flow.industries`.
21
- *
22
- * The iframe is mounted lazily on first use, kept hidden until `open()` is
23
- * called, and reused across requests so a single passkey-bound iframe handles
24
- * login, signing, and silent restore. The iframe is required (not just
25
- * `fetch`) because it's first-party to id.flow.industries and is the only way
26
- * to read the cross-origin cookie session in third-party-cookie-blocking
27
- * browsers (Safari, Firefox).
28
- *
29
- * Returned methods:
30
- * - `request()` — open the dialog UI and dispatch an RPC (interactive flows)
31
- * - `requestSilent()` — dispatch without showing the dialog (silent restore)
32
- * - `open()`/`close()` — manual visibility control
33
- * - `destroy()` — tear down the iframe and abort pending requests
19
+ * Creates the retained signing dialog host. Its iframe mounts lazily and is
20
+ * reused across signing requests. Authentication uses the first-party session
21
+ * handler and top-level authorization page independently.
34
22
  */
35
23
  export function createDialogHost(options) {
36
24
  const { host, container = document.body } = options;
@@ -187,17 +175,15 @@ export function createDialogHost(options) {
187
175
  });
188
176
  });
189
177
  }
190
- /** Opens the dialog UI and dispatches an interactive RPC (login, sign, etc.). */
178
+ /** Opens the dialog UI and dispatches a signing RPC. */
191
179
  async function request(method, params) {
192
180
  ensureFrame();
193
181
  open();
194
182
  return dispatchRequest(method, params);
195
183
  }
196
184
  /**
197
- * Dispatches an RPC without showing the dialog UI. Used for signInHeadless flows
198
- * (silent restore, refresh, sign-out) where the dialog route runs invisibly
199
- * and resolves immediately. The iframe is still mounted so postMessage
200
- * works, but its `display` stays `none`.
185
+ * @deprecated Use request() for signing. Retained for callers that manage
186
+ * visibility manually; this does not support background authentication.
201
187
  */
202
188
  async function requestSilent(method, params) {
203
189
  ensureFrame();
@@ -47,7 +47,7 @@ export function createFlowWidget(options) {
47
47
  const hostOrigin = new URL(host).origin;
48
48
  let theme = options.theme ?? "light dark";
49
49
  const route = WIDGET_ROUTE[options.widget];
50
- const frame = makeIframe(`${host}${route}?v=2`, WIDGET_TITLE[options.widget]);
50
+ const frame = makeIframe(`${host}${route}`, WIDGET_TITLE[options.widget]);
51
51
  frame.style.background = "transparent";
52
52
  const container = options.container ?? document.body;
53
53
  // Appended before bridging: `contentWindow` is null until the frame is in the
@@ -1,9 +1,6 @@
1
1
  export declare const METHODS: {
2
- readonly connect: "wallet_connect";
3
2
  readonly signMessage: "personal_sign";
4
3
  readonly signTypedData: "eth_signTypedData";
5
4
  readonly sendTransaction: "eth_sendTransaction";
6
5
  readonly sendCalls: "wallet_sendCalls";
7
- readonly guest: "wallet_guest";
8
- readonly signOut: "wallet_signout";
9
6
  };
@@ -1,9 +1,6 @@
1
1
  export const METHODS = {
2
- connect: "wallet_connect",
3
2
  signMessage: "personal_sign",
4
3
  signTypedData: "eth_signTypedData",
5
4
  sendTransaction: "eth_sendTransaction",
6
5
  sendCalls: "wallet_sendCalls",
7
- guest: "wallet_guest",
8
- signOut: "wallet_signout",
9
6
  };
@@ -30,7 +30,7 @@ function createViewer(options) {
30
30
  setOverlayFocus(frame, ready && visible);
31
31
  }
32
32
  function mount(username) {
33
- const el = makeIframe(`${host}/dialog/?v=2`, "Flow ID profile");
33
+ const el = makeIframe(`${host}/dialog/`, "Flow ID profile");
34
34
  el.dataset.flowProfileView = "";
35
35
  el.inert = true;
36
36
  Object.assign(el.style, OVERLAY_STYLE, { pointerEvents: "none" });
@@ -43,10 +43,6 @@ function createViewer(options) {
43
43
  targetOrigin: hostOrigin,
44
44
  });
45
45
  bridge = opened;
46
- // The dialog SPA has no deep-link history fallback, so the frame loads at
47
- // `/dialog/` and `init` asks it to land on the profile. Sending the route
48
- // here rather than as a follow-up message keeps the sign-in root from ever
49
- // mounting, even for the frame it takes to navigate away from it.
50
46
  sendBridgeNotification(opened, "__internal", {
51
47
  type: "init",
52
48
  mode: "iframe",
@@ -63,21 +59,7 @@ function createViewer(options) {
63
59
  .catch(() => { });
64
60
  opened.on("close", () => close());
65
61
  opened.on("__internal", (payload) => {
66
- // A viewer looking at their own profile can reach the account view from
67
- // here, which offers the same sign-in and sign-out the pill's dialog
68
- // does. Those run on the host — it owns the session — so dropping them
69
- // would leave the buttons doing nothing at all.
70
- if (payload.type === "profile-login") {
71
- close();
72
- void flow
73
- .login(payload.mode === "signup" ? { signUp: true } : { signIn: true })
74
- .catch(() => { });
75
- }
76
- else if (payload.type === "profile-logout") {
77
- close();
78
- void flow.logout().catch(() => { });
79
- }
80
- else if (payload.type === "profile-token-request") {
62
+ if (payload.type === "profile-token-request") {
81
63
  void answerTokenRequest(opened, flow, payload.id);
82
64
  }
83
65
  });
@@ -13,7 +13,7 @@ export declare function restoreCredential(store: Store<FlowState>): Promise<void
13
13
  * Clears local-only auth state: active credential in IDB, the access key for
14
14
  * the current address (if any), and the in-memory store. Does NOT touch the
15
15
  * server cookie session — the caller (createFlow.logout) handles that
16
- * separately via `wallet_signout`.
16
+ * separately through the first-party session handler.
17
17
  */
18
18
  export declare function runLogout(store: Store<FlowState>): Promise<void>;
19
19
  /**
@@ -24,7 +24,7 @@ export async function restoreCredential(store) {
24
24
  * Clears local-only auth state: active credential in IDB, the access key for
25
25
  * the current address (if any), and the in-memory store. Does NOT touch the
26
26
  * server cookie session — the caller (createFlow.logout) handles that
27
- * separately via `wallet_signout`.
27
+ * separately through the first-party session handler.
28
28
  */
29
29
  export async function runLogout(store) {
30
30
  const state = store.getSnapshot();
@@ -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
+ }
@@ -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,
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { browserCompletionSchema } from "./browser-contract";
3
3
  import { finishBrowserSession, startBrowserSession, } from "./browser-session-route";
4
4
  import { clearCookieString } from "./cookies";
5
+ import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
5
6
  /**
6
7
  * The first-party session route every consumer app serves from its own
7
8
  * origin — the only path between a browser and the refresh token. The
@@ -82,9 +83,10 @@ function isCrossSite(request, audience) {
82
83
  export async function handleSessionRequest(request, opts = {}) {
83
84
  const audience = opts.audience ?? defaultAudience(new URL(request.url).origin);
84
85
  const issuerUrl = resolveIdHost(opts.issuerUrl, audience);
86
+ const apiUrl = resolveIssuerApiUrl(issuerUrl, opts.apiUrl);
85
87
  const callback = new URL(request.url);
86
88
  if (request.method === "GET" && callback.searchParams.has("flow_state")) {
87
- return finishBrowserSession(request, audience, issuerUrl);
89
+ return finishBrowserSession(request, audience, issuerUrl, apiUrl);
88
90
  }
89
91
  if (isCrossSite(request, audience)) {
90
92
  return json({ state: null }, 403);
@@ -95,14 +97,14 @@ export async function handleSessionRequest(request, opts = {}) {
95
97
  const token = parseCookieHeader(request.headers.get("Cookie"))[cookieNamesFor(audience).refresh];
96
98
  if (!token)
97
99
  return json({ state: null }, 200);
98
- const result = await refreshSessionCore(issuerUrl, audience, token);
100
+ const result = await refreshSessionCore(issuerUrl, audience, token, apiUrl);
99
101
  if (result === "dead")
100
102
  return json({ state: null }, 200, clearingCookies(audience));
101
103
  if (!result)
102
104
  return json({ state: null }, 502);
103
105
  return json({ state: result.session }, 200, result.setCookies);
104
106
  }
105
- const { session, setCookies } = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl);
107
+ const { session, setCookies } = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl, apiUrl);
106
108
  const completionName = `${cookieNamesFor(audience).refresh}.completion`;
107
109
  const raw = parseCookieHeader(request.headers.get("Cookie"))[completionName];
108
110
  let loginCompletion;
@@ -129,14 +131,14 @@ export async function handleSessionRequest(request, opts = {}) {
129
131
  return await startBrowserSession(request, audience, issuerUrl, {
130
132
  ...body,
131
133
  action: body.action,
132
- });
134
+ }, apiUrl);
133
135
  }
134
136
  catch {
135
137
  return json({ state: null }, 502);
136
138
  }
137
139
  }
138
140
  if (body?.action === "guest") {
139
- const existing = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl);
141
+ const existing = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl, apiUrl);
140
142
  if (existing.session)
141
143
  return json({ state: existing.session }, 200, existing.setCookies);
142
144
  const previousRefresh = parseCookieHeader(request.headers.get("Cookie"))[cookieNamesFor(audience).refresh];
@@ -153,7 +155,7 @@ export async function handleSessionRequest(request, opts = {}) {
153
155
  if (value)
154
156
  headers.set(name, value);
155
157
  }
156
- const result = await fetch(`${issuerUrl}/api/auth/passkey/guest`, {
158
+ const result = await fetchIssuerApi(`${apiUrl}/api/auth/passkey/guest`, {
157
159
  method: "POST",
158
160
  headers,
159
161
  body: JSON.stringify({ audience }),
@@ -162,7 +164,11 @@ export async function handleSessionRequest(request, opts = {}) {
162
164
  return json({ state: null }, result.status);
163
165
  // SAFETY: the configured issuer returns this session token pair, verified below.
164
166
  const data = (await result.json());
165
- const claims = await verifyFlowJWT(data.jwt, { audience, issuerUrl });
167
+ const claims = await verifyFlowJWT(data.jwt, {
168
+ audience,
169
+ issuerUrl,
170
+ apiUrl,
171
+ });
166
172
  return json({
167
173
  state: {
168
174
  user: claimsToUser(claims),
@@ -188,7 +194,7 @@ export async function handleSessionRequest(request, opts = {}) {
188
194
  let additionalSessions = [];
189
195
  if (token && audiences.length > 0) {
190
196
  try {
191
- const res = await fetch(`${issuerUrl}/api/session/additional`, {
197
+ const res = await fetchIssuerApi(`${apiUrl}/api/session/additional`, {
192
198
  method: "POST",
193
199
  headers: {
194
200
  "Content-Type": "application/json",
@@ -216,7 +222,11 @@ export async function handleSessionRequest(request, opts = {}) {
216
222
  // for another app from being installed here; a failed install must not
217
223
  // clear cookies that may hold a live session.
218
224
  try {
219
- const claims = await verifyFlowJWT(jwt, { audience, issuerUrl });
225
+ const claims = await verifyFlowJWT(jwt, {
226
+ audience,
227
+ issuerUrl,
228
+ apiUrl,
229
+ });
220
230
  return json({ state: { user: claimsToUser(claims), jwt } }, 200, sessionCookies(audience, jwt, refreshToken));
221
231
  }
222
232
  catch (err) {
@@ -230,7 +240,7 @@ export async function handleSessionRequest(request, opts = {}) {
230
240
  // an unreachable issuer just leaves the lineage to expire on its own.
231
241
  if (token) {
232
242
  try {
233
- const response = await fetch(`${issuerUrl}/api/session/revoke`, {
243
+ const response = await fetchIssuerApi(`${apiUrl}/api/session/revoke`, {
234
244
  method: "POST",
235
245
  headers: { Authorization: `Bearer ${token}` },
236
246
  });
@@ -99,6 +99,8 @@ export type PasskeyPluginOptions = {
99
99
  export type VerifyOptions = {
100
100
  audience: string;
101
101
  issuerUrl?: string;
102
+ /** Trusted server API origin; defaults to FLOW_ID_API_URL, then the public issuer. */
103
+ apiUrl?: string;
102
104
  };
103
105
  /**
104
106
  * One entry in the signed-in user's session list (`GET /api/account/sessions`).
@@ -11,8 +11,8 @@ export { GAME_LIFECYCLE_EVENT_NAMES } from "./game";
11
11
  export type { AccountProfileData } from "./landing";
12
12
  export type { Bridge, BridgeParameters, FlowAccount, FlowRemote, FlowRemoteConfig, FromWindowOptions, MessageResponse, Messenger, OneOf, Payload, QueuedRequest, ReadyOptions, RemoteFlowState, RemoteState, Schema, Storage, Topic, WithReady, } from "./messenger";
13
13
  export type { OtpVerificationFailure, OtpVerificationResult, VerificationAdapter, } from "./otp";
14
- export type { Call, ConnectCapabilities, ConnectRequest, ConnectResponse, GuestRequest, GuestResponse, MethodName, MethodParams, MethodResult, RestoreResponse, RpcRequest, SendCallsParams, SendCallsRequest, SendCallsResponse, SendTransactionParams, SendTransactionRequest, SendTransactionResponse, SignMessageRequest, SignMessageResponse, SignOutRequest, SignOutResponse, SignTypedDataRequest, SignTypedDataResponse, TransactionArgs, TypedData, TypedDataDomain, TypedDataField, } from "./protocol";
15
- export { getConnectCapabilities, isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
14
+ export type { Call, ConnectCapabilities, ConnectResponse, MethodName, MethodParams, MethodResult, RestoreResponse, RpcRequest, SendCallsParams, SendCallsRequest, SendCallsResponse, SendTransactionParams, SendTransactionRequest, SendTransactionResponse, SignMessageRequest, SignMessageResponse, SignTypedDataRequest, SignTypedDataResponse, TransactionArgs, TypedData, TypedDataDomain, TypedDataField, } from "./protocol";
15
+ export { isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
16
16
  export type { ReportOperation, ReportRequest, ReportResponse, ReportsApi, ReportTransport, } from "./reports";
17
17
  export type { CreateRoomEventInput, RoomEventArchive, RoomEventAttendance, RoomEventFrequency, RoomEventInstance, RoomEventInterest, RoomEventInterestState, RoomEventList, RoomEventOccurrence, RoomEventRecurrence, RoomEventRoomRef, RoomEventRun, RoomEventSeries, RoomEventStatus, SetInterestInput, UpdateRoomEventInput, } from "./room-events";
18
18
  export type { CreateRoomInput, GlobalRole, ModerationSubject, MyRooms, PlayerPositionReport, RoleChangeVerdict, RoomCapabilities, RoomCapability, RoomChannel, RoomDetail, RoomList, RoomMemberEntry, RoomMembers, RoomOccupancy, RoomOwner, RoomPlayerPosition, RoomPresenceEntry, RoomPresenceSnapshot, RoomRestrictionKind, RoomRole, RoomSummary, RoomSurfaceSettings, RoomsApi, RoomVisibility, StaffEntry, UpdateRoomInput, VerifyRoomContext, Viewer, VoiceDecisionReason, VoiceDecisionSubject, VoiceRoomDecision, } from "./rooms";
@@ -1,3 +1,3 @@
1
1
  export { BODY_REGIONS, COSMETIC_MATERIALS } from "./cosmetics";
2
2
  export { GAME_LIFECYCLE_EVENT_NAMES } from "./game";
3
- export { getConnectCapabilities, isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
3
+ export { isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
@@ -100,11 +100,6 @@ export type Schema = [
100
100
  type: "profile-token";
101
101
  id: string;
102
102
  token: string | null;
103
- } | {
104
- type: "profile-login";
105
- mode: "signin" | "signup";
106
- } | {
107
- type: "profile-logout";
108
103
  } | {
109
104
  type: "dialog-shown";
110
105
  } | {
@@ -1,18 +1,16 @@
1
1
  import type { RawRecord, RawValue } from "../json";
2
2
  import type { AdditionalSession, FlowCredential, FlowUser, WebAuthnSignature } from "./auth";
3
3
  import type { Address } from "./sdk";
4
- export type MethodName = "wallet_connect" | "personal_sign" | "eth_signTypedData" | "eth_sendTransaction" | "wallet_sendCalls" | "wallet_guest" | "wallet_signout";
4
+ export type MethodName = "personal_sign" | "eth_signTypedData" | "eth_sendTransaction" | "wallet_sendCalls";
5
5
  export type ConnectCapabilities = {
6
6
  createAccount?: boolean;
7
7
  signIn?: boolean;
8
8
  accessKeyHash?: string;
9
+ /** @deprecated Sign-in always uses the dedicated top-level page. */
9
10
  signInHeadless?: boolean;
10
11
  credentialId?: string;
11
12
  /**
12
- * The current guest's access JWT, forwarded so a sign-up upgrades that guest
13
- * in place even where the guest cookie is unreadable (the iOS top-level
14
- * sign-up popup). The server verifies it before letting it stand in for the
15
- * cookie session; see the register endpoint.
13
+ * @deprecated Guest proof is managed by the first-party session handler.
16
14
  */
17
15
  guestToken?: string;
18
16
  /**
@@ -22,9 +20,7 @@ export type ConnectCapabilities = {
22
20
  */
23
21
  additionalAudiences?: readonly string[];
24
22
  };
25
- export type ConnectRequest = [{
26
- capabilities?: ConnectCapabilities;
27
- }];
23
+ /** @deprecated Login navigates; use SessionRouteResponse for completed sessions. */
28
24
  export type ConnectResponse = {
29
25
  jwt: string;
30
26
  /** First-party rotating refresh token; the SDK persists it in the app origin. */
@@ -83,43 +79,17 @@ export type RestoreResponse = {
83
79
  credential: FlowCredential | null;
84
80
  address: Address | null;
85
81
  };
86
- export type GuestRequest = [] | [{
87
- additionalAudiences?: readonly string[];
88
- }];
89
- /**
90
- * A guest has no passkey and no derivable wallet address yet, so `credential`
91
- * and `address` are always null — the shape mirrors RestoreResponse so the SDK
92
- * can commit either to the same store fields.
93
- */
94
- export type GuestResponse = {
95
- jwt: string;
96
- refreshToken: string;
97
- user: FlowUser;
98
- credential: null;
99
- address: null;
100
- additionalSessions?: AdditionalSession[];
101
- };
102
- export type SignOutRequest = [];
103
- export type SignOutResponse = {
104
- ok: true;
105
- };
106
82
  export type MethodParams = {
107
- wallet_connect: ConnectRequest;
108
83
  personal_sign: SignMessageRequest;
109
84
  eth_signTypedData: SignTypedDataRequest;
110
85
  eth_sendTransaction: SendTransactionRequest;
111
86
  wallet_sendCalls: SendCallsRequest;
112
- wallet_guest: GuestRequest;
113
- wallet_signout: SignOutRequest;
114
87
  };
115
88
  export type MethodResult = {
116
- wallet_connect: ConnectResponse;
117
89
  personal_sign: SignMessageResponse;
118
90
  eth_signTypedData: SignTypedDataResponse;
119
91
  eth_sendTransaction: SendTransactionResponse;
120
92
  wallet_sendCalls: SendCallsResponse;
121
- wallet_guest: GuestResponse;
122
- wallet_signout: SignOutResponse;
123
93
  };
124
94
  export type RpcRequest = {
125
95
  id: string | number;
@@ -143,12 +113,6 @@ export type SendCallsParams = {
143
113
  chainId?: string;
144
114
  capabilities?: RawRecord;
145
115
  };
146
- /**
147
- * Unwraps the connect capabilities from a wallet_connect RPC request (they ride
148
- * as the first param), or undefined. Several dialog surfaces read them (mode,
149
- * accessKeyHash, guestToken), so the cast lives here rather than being repeated.
150
- */
151
- export declare function getConnectCapabilities(request: RpcRequest | undefined): ConnectCapabilities | undefined;
152
116
  export declare function isPersonalSignParams(params: readonly unknown[] | undefined): params is readonly [string, string];
153
117
  export declare function isSendTransactionParams(params: readonly unknown[] | undefined): params is readonly [SendTransactionParams];
154
118
  export declare function isSendCallsParams(params: readonly unknown[] | undefined): params is readonly [SendCallsParams];
@@ -1,15 +1,3 @@
1
- /**
2
- * Unwraps the connect capabilities from a wallet_connect RPC request (they ride
3
- * as the first param), or undefined. Several dialog surfaces read them (mode,
4
- * accessKeyHash, guestToken), so the cast lives here rather than being repeated.
5
- */
6
- export function getConnectCapabilities(request) {
7
- const first = request?.params?.[0];
8
- const found = Object.entries(Object(first)).find(([k]) => k === "capabilities")?.[1];
9
- /* SAFETY: the field is read off this app's own wallet_connect params, whose capabilities
10
- block is defined by ConnectCapabilities; a missing one answers undefined. */
11
- return found;
12
- }
13
1
  export function isPersonalSignParams(params) {
14
2
  if (!params || params.length < 2)
15
3
  return false;
@@ -90,8 +90,8 @@ export type CreateFlowOptions = {
90
90
  */
91
91
  initialState?: FlowSessionState | null;
92
92
  /**
93
- * Extra audiences every dialog mint (login, guest) requests refresh tokens
94
- * for, on top of the app's own (AUTH-39). Only audiences the auth server
93
+ * Extra audiences to receive sessions after the app session is installed.
94
+ * Only audiences the auth server
95
95
  * explicitly allowlists for this origin succeed — anything else fails the
96
96
  * mint. The minted sessions are delivered once via `onAdditionalSessions`
97
97
  * and never stored; the app forwards each to its own origin (e.g. the
@@ -229,7 +229,7 @@ export type ProfileButtonHandle = {
229
229
  export type OpenProfileOptions = {
230
230
  /**
231
231
  * Called once when the overlay closes, including its close button, backdrop,
232
- * Escape, closeProfile(), or a sign-in/sign-out handoff. Runs after the
232
+ * Escape, closeProfile(), or an own-account tab handoff. Runs after the
233
233
  * overlay stops accepting input, without waiting for the exit animation.
234
234
  * The latest successful open replaces this callback; omitting it clears the
235
235
  * previous callback. Rejected opens and repeated closes do not notify.
@@ -314,6 +314,7 @@ export type DialogHost = {
314
314
  close: () => void;
315
315
  destroy: () => void;
316
316
  request: <T = unknown>(method: string, params?: readonly unknown[]) => Promise<T>;
317
+ /** @deprecated Manual signing-dialog visibility only; authentication redirects. */
317
318
  requestSilent: <T = unknown>(method: string, params?: readonly unknown[]) => Promise<T>;
318
319
  messenger: import("./messenger").Messenger;
319
320
  };
@@ -6,6 +6,8 @@ export type ResolveSessionOptions = {
6
6
  audience: string;
7
7
  /** Flow ID origin; defaults to `https://id.flow.industries`. */
8
8
  issuerUrl?: string;
9
+ /** Trusted server API origin; defaults to FLOW_ID_API_URL, then the public issuer. */
10
+ apiUrl?: string;
9
11
  /**
10
12
  * Also fetch the public profile (avatar, tier, level) for a resolved
11
13
  * session via `/api/session/verify` — one extra round-trip; off by default.
@@ -36,6 +38,8 @@ export type SessionRouteOptions = {
36
38
  audience?: string;
37
39
  /** Flow ID origin; defaults per `defaultIdHost` (`FLOW_ID_HOST` on servers). */
38
40
  issuerUrl?: string;
41
+ /** Trusted server API origin; defaults to FLOW_ID_API_URL, then the public issuer. */
42
+ apiUrl?: string;
39
43
  /** Route path for `createSessionHandler`'s own path check. */
40
44
  path?: string;
41
45
  };
@@ -1,4 +1,5 @@
1
- import { createRemoteJWKSet, jwtVerify } from "jose";
1
+ import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
2
+ import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
2
3
  const jwksCache = new Map();
3
4
  const DEFAULT_ISSUER_URL = "https://id.flow.industries";
4
5
  /**
@@ -22,10 +23,14 @@ const DEFAULT_ISSUER_URL = "https://id.flow.industries";
22
23
  */
23
24
  export async function verifyFlowJWT(token, opts) {
24
25
  const issuerUrl = opts.issuerUrl ?? DEFAULT_ISSUER_URL;
25
- let jwks = jwksCache.get(issuerUrl);
26
+ const apiUrl = resolveIssuerApiUrl(issuerUrl, opts.apiUrl);
27
+ const cacheKey = `${issuerUrl}\n${apiUrl}`;
28
+ let jwks = jwksCache.get(cacheKey);
26
29
  if (!jwks) {
27
- jwks = createRemoteJWKSet(new URL(`${issuerUrl}/api/auth/jwks`));
28
- jwksCache.set(issuerUrl, jwks);
30
+ jwks = createRemoteJWKSet(new URL(`${apiUrl}/api/auth/jwks`), {
31
+ [customFetch]: fetchIssuerApi,
32
+ });
33
+ jwksCache.set(cacheKey, jwks);
29
34
  }
30
35
  const { payload } = await jwtVerify(token, jwks, {
31
36
  issuer: issuerUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.23.0",
3
+ "version": "0.23.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -66,10 +66,9 @@
66
66
  "scripts": {
67
67
  "dev": "./dev.sh",
68
68
  "build:dialog": "bunx vite build",
69
- "build:legacy-dialog": "bunx vite build --config vite.legacy.config.ts",
70
69
  "build:playground": "bunx vite build --config vite.playground.config.ts",
71
70
  "build:landing": "bunx vite build --config vite.landing.config.ts",
72
- "build": "bun run build:legacy-dialog && bunx vite build && bunx vite build --config vite.playground.config.ts && bunx vite build --config vite.landing.config.ts",
71
+ "build": "bunx vite build && bunx vite build --config vite.playground.config.ts && bunx vite build --config vite.landing.config.ts",
73
72
  "build:sdk": "tsc --project tsconfig.sdk.json",
74
73
  "prepublishOnly": "bun run build:sdk",
75
74
  "start": "bun run src/index.ts",
@@ -85,7 +84,7 @@
85
84
  "test:sdk-package": "bash scripts/test-sdk-package.sh",
86
85
  "format": "biome format --write",
87
86
  "check": "biome check --write",
88
- "typecheck": "tsr generate && (cd src/legacy-dialog && bunx tsr generate) && tsc --noEmit && tsc -p tsconfig.legacy.json --noEmit",
87
+ "typecheck": "tsr generate && tsc --noEmit",
89
88
  "contracts:generate": "bun scripts/generate-contracts.ts",
90
89
  "contracts:check": "bun scripts/generate-contracts.ts --check && bun scripts/check-contract-compatibility.ts",
91
90
  "db:check": "bun scripts/db-check.ts",