@flow-industries/id 0.22.0 → 0.23.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.
Files changed (44) hide show
  1. package/README.md +69 -8
  2. package/contracts/v1/openapi.json +818 -0
  3. package/contracts/v1/reports.json +188 -0
  4. package/contracts/v1/sdk-exports.json +1165 -0
  5. package/dist/sdk/browser-contract.d.ts +30 -0
  6. package/dist/sdk/browser-contract.js +19 -0
  7. package/dist/sdk/browser-session-route.d.ts +8 -0
  8. package/dist/sdk/browser-session-route.js +196 -0
  9. package/dist/sdk/client/create-flow.js +176 -166
  10. package/dist/sdk/client/dialog-host.js +11 -6
  11. package/dist/sdk/client/flow-widget.js +4 -4
  12. package/dist/sdk/client/idb.d.ts +1 -0
  13. package/dist/sdk/client/idb.js +24 -0
  14. package/dist/sdk/client/iframe-host.d.ts +2 -1
  15. package/dist/sdk/client/iframe-host.js +11 -1
  16. package/dist/sdk/client/index.d.ts +2 -0
  17. package/dist/sdk/client/index.js +1 -0
  18. package/dist/sdk/client/open-profile.js +19 -8
  19. package/dist/sdk/client/profile-button.d.ts +1 -13
  20. package/dist/sdk/client/profile-button.js +114 -170
  21. package/dist/sdk/client/reports.d.ts +9 -0
  22. package/dist/sdk/client/reports.js +30 -0
  23. package/dist/sdk/client/session.d.ts +1 -10
  24. package/dist/sdk/client/session.js +0 -37
  25. package/dist/sdk/client/static-flow.js +1 -0
  26. package/dist/sdk/contracts/http.d.ts +155 -0
  27. package/dist/sdk/contracts/http.js +91 -0
  28. package/dist/sdk/contracts/reports.d.ts +130 -0
  29. package/dist/sdk/contracts/reports.js +67 -0
  30. package/dist/sdk/dialog/remote/Messenger.d.ts +3 -0
  31. package/dist/sdk/dialog/remote/Messenger.js +9 -3
  32. package/dist/sdk/react/hooks.d.ts +1 -1
  33. package/dist/sdk/react/profile-button.d.ts +1 -12
  34. package/dist/sdk/react/profile-button.js +1 -12
  35. package/dist/sdk/session-route.js +101 -4
  36. package/dist/sdk/types/dialog.d.ts +0 -3
  37. package/dist/sdk/types/index.d.ts +2 -1
  38. package/dist/sdk/types/landing.d.ts +1 -0
  39. package/dist/sdk/types/reports.d.ts +7 -0
  40. package/dist/sdk/types/reports.js +0 -0
  41. package/dist/sdk/types/sdk.d.ts +7 -21
  42. package/dist/sdk/types/server.d.ts +6 -0
  43. package/dist/sdk/wagmi/index.js +0 -3
  44. package/package.json +18 -9
@@ -1,7 +1,7 @@
1
1
  import { resolveIdHost } from "../id-host";
2
2
  import { isValidUsername } from "../usernames";
3
3
  import { getFlow, requireFlow } from "./create-flow";
4
- import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, } from "./iframe-host";
4
+ import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, sendBridgeNotification, } from "./iframe-host";
5
5
  import { setOverlayFocus } from "./overlay-focus";
6
6
  // One overlay per page, kept for the page's lifetime. The alternative — a
7
7
  // handle per call site — would put a second fullscreen iframe over the host the
@@ -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/`, "Flow ID profile");
33
+ const el = makeIframe(`${host}/dialog/?v=2`, "Flow ID profile");
34
34
  el.dataset.flowProfileView = "";
35
35
  el.inert = true;
36
36
  Object.assign(el.style, OVERLAY_STYLE, { pointerEvents: "none" });
@@ -47,7 +47,7 @@ function createViewer(options) {
47
47
  // `/dialog/` and `init` asks it to land on the profile. Sending the route
48
48
  // here rather than as a follow-up message keeps the sign-in root from ever
49
49
  // mounting, even for the frame it takes to navigate away from it.
50
- void opened.send("__internal", {
50
+ sendBridgeNotification(opened, "__internal", {
51
51
  type: "init",
52
52
  mode: "iframe",
53
53
  referrer: { title: document.title },
@@ -90,11 +90,14 @@ function createViewer(options) {
90
90
  // re-pointed in place. Tearing it down and rebuilding would reload the
91
91
  // dialog and lose the open animation on every profile after the first.
92
92
  if (mounted)
93
- void live.send("__internal", { type: "profile-view", username });
93
+ sendBridgeNotification(live, "__internal", {
94
+ type: "profile-view",
95
+ username,
96
+ });
94
97
  // Pushed on every open rather than tracked with a subscription: it is only
95
98
  // read while a profile is on screen, and sending it here means the "this is
96
99
  // you" link is decided from the session as it stands at that moment.
97
- void live.send("__internal", {
100
+ sendBridgeNotification(live, "__internal", {
98
101
  type: "profile-identity",
99
102
  identity: hostIdentity(flow),
100
103
  });
@@ -102,7 +105,7 @@ function createViewer(options) {
102
105
  onClose = nextOnClose;
103
106
  visible = true;
104
107
  applyHitTesting();
105
- void live.send("__internal", { type: "dialog-shown" });
108
+ sendBridgeNotification(live, "__internal", { type: "dialog-shown" });
106
109
  }
107
110
  // Visibility is hit-testing plus a message, and nothing else. The overlay
108
111
  // iframe is NEVER set to `display: none` — that pauses the iframe's
@@ -116,7 +119,7 @@ function createViewer(options) {
116
119
  visible = false;
117
120
  window.removeEventListener("keydown", onKeyDown);
118
121
  applyHitTesting();
119
- void bridge.send("__internal", { type: "dialog-hidden" });
122
+ sendBridgeNotification(bridge, "__internal", { type: "dialog-hidden" });
120
123
  const notifyClose = onClose;
121
124
  onClose = undefined;
122
125
  notifyClose?.();
@@ -131,7 +134,7 @@ function createViewer(options) {
131
134
  if (next === theme)
132
135
  return;
133
136
  theme = next;
134
- void bridge?.send("__internal", {
137
+ sendBridgeNotification(bridge, "__internal", {
135
138
  type: "set-theme",
136
139
  theme: { colorScheme: next },
137
140
  });
@@ -176,6 +179,14 @@ export function openProfile(username, options = {}) {
176
179
  return false;
177
180
  if (!("document" in globalThis))
178
181
  return false;
182
+ const current = options.flow ?? getFlow();
183
+ if (current?.user?.username === username) {
184
+ closeProfile();
185
+ const opened = current.openAccount();
186
+ if (opened)
187
+ queueMicrotask(() => options.onClose?.());
188
+ return opened;
189
+ }
179
190
  const live = viewer ?? createViewer(options);
180
191
  viewer = live;
181
192
  if (options.theme)
@@ -1,15 +1,3 @@
1
1
  import type { MountProfileOptions, ProfileButtonHandle } from "../types";
2
- /**
3
- * Mounts the universal Flow ID "profile button" — a persistent inline iframe
4
- * showing the signed-in user's avatar + username (the collapsed pill). Clicking
5
- * it opens the profile dialog in a SEPARATE fullscreen overlay iframe, so the
6
- * pill stays in place behind the dialog's dimmed backdrop rather than morphing
7
- * into it. Both iframes ride the dialog SPA at `${host}/dialog/` (routes
8
- * `/dialog/profile-pill` and `/dialog/profile`) so the chrome matches the auth
9
- * dialog and the widget is first-party to Flow ID (reads `/api/me`, uploads
10
- * avatars with the cookie session).
11
- *
12
- * Resolves the Flow singleton automatically (createFlow() must have run), so
13
- * callers don't thread the instance through. No-op under SSR.
14
- */
2
+ /** Mount a native account button that opens Flow ID in a normal browser tab. */
15
3
  export declare function createProfileButton(options: MountProfileOptions): ProfileButtonHandle;
@@ -1,201 +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, } 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
- bridge.send("__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",
49
+ });
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",
101
68
  });
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)
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
- void dialogBridge.send("__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
- void dialogBridge?.send("__internal", { type: "dialog-shown" });
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(() => { });
151
114
  }
152
- function hideDialog() {
153
- if (!dialog)
154
- return;
155
- dialog.style.pointerEvents = "none";
156
- setOverlayFocus(dialog, false);
157
- void dialogBridge?.send("__internal", { type: "dialog-hidden" });
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();
158
125
  }
159
- // ----- keep both iframes' identity in sync with the Flow session -----
160
- // flow.subscribe fires on any store change (jwt/credential/address too), so
161
- // skip re-sending an identical identity (e.g. on every silent JWT refresh).
162
- let lastIdentityKey;
163
- function pushIdentity() {
164
- const identity = hostIdentity(flow);
165
- const key = identity ? `${identity.username}|${identity.isGuest}` : "none";
166
- if (key === lastIdentityKey)
167
- return;
168
- lastIdentityKey = key;
169
- void pillBridge.send("__internal", { type: "profile-identity", identity });
170
- if (dialogBridge)
171
- void dialogBridge.send("__internal", {
172
- type: "profile-identity",
173
- identity,
174
- });
126
+ window.addEventListener("focus", refresh);
127
+ function setTheme(theme) {
128
+ button.style.colorScheme = theme;
175
129
  }
176
- pushIdentity();
177
- 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);
178
134
  return {
179
- setTheme(next) {
180
- if (next === theme)
181
- return;
182
- theme = next;
183
- void pillBridge.send("__internal", {
184
- type: "set-theme",
185
- theme: { colorScheme: next },
186
- });
187
- void dialogBridge?.send("__internal", {
188
- type: "set-theme",
189
- theme: { colorScheme: next },
190
- });
191
- },
135
+ setTheme,
192
136
  destroy() {
193
- hideDialog();
194
137
  unsubscribe();
195
- pillBridge.destroy();
196
- dialogBridge?.destroy();
197
- pill.remove();
198
- dialog?.remove();
138
+ profileRequest?.abort();
139
+ window.removeEventListener("focus", refresh);
140
+ fallback.remove();
141
+ button.remove();
142
+ notice.remove();
199
143
  createdContainer?.remove();
200
144
  },
201
145
  };
@@ -0,0 +1,9 @@
1
+ import type { ReportsApi, ReportTransport } from "../types/reports";
2
+ export type { ReportOperation, ReportRequest, ReportResponse, } from "../types/reports";
3
+ export declare class ReportRequestError extends Error {
4
+ readonly status: number;
5
+ readonly response: string;
6
+ constructor(status: number, response: string);
7
+ }
8
+ /** Typed service reports. Keep the reporting bearer on trusted servers only. */
9
+ export declare function createReportsApi(host: string, token: string, transport?: ReportTransport): ReportsApi;
@@ -0,0 +1,30 @@
1
+ import { httpOperations } from "../contracts/http";
2
+ export class ReportRequestError extends Error {
3
+ status;
4
+ response;
5
+ constructor(status, response) {
6
+ super(`Flow report failed (${status})`);
7
+ this.status = status;
8
+ this.response = response;
9
+ this.name = "ReportRequestError";
10
+ }
11
+ }
12
+ /** Typed service reports. Keep the reporting bearer on trusted servers only. */
13
+ export function createReportsApi(host, token, transport = fetch) {
14
+ return async function report(operation, body) {
15
+ const contract = httpOperations[operation];
16
+ const request = contract.request.parse(body);
17
+ const response = await transport(new URL(contract.path, host), {
18
+ method: "POST",
19
+ headers: {
20
+ authorization: `Bearer ${token}`,
21
+ "content-type": "application/json",
22
+ },
23
+ body: JSON.stringify(request),
24
+ });
25
+ if (!response.ok)
26
+ throw new ReportRequestError(response.status, await response.text());
27
+ // SAFETY: the selected operation owns both this response schema and the mapped response type.
28
+ return contract.response.parse(await response.json());
29
+ };
30
+ }
@@ -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"),