@flow-industries/id 0.3.1 → 0.6.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 (42) hide show
  1. package/dist/sdk/client/access-key.d.ts +5 -3
  2. package/dist/sdk/client/access-key.js +6 -4
  3. package/dist/sdk/client/create-flow.js +145 -3
  4. package/dist/sdk/client/dialog-host.js +24 -18
  5. package/dist/sdk/client/iframe-host.d.ts +23 -0
  6. package/dist/sdk/client/iframe-host.js +34 -0
  7. package/dist/sdk/client/index.d.ts +2 -1
  8. package/dist/sdk/client/index.js +1 -0
  9. package/dist/sdk/client/methods.d.ts +1 -0
  10. package/dist/sdk/client/methods.js +1 -0
  11. package/dist/sdk/client/profile-button.d.ts +15 -0
  12. package/dist/sdk/client/profile-button.js +189 -0
  13. package/dist/sdk/client/signing.d.ts +1 -1
  14. package/dist/sdk/client/signing.js +3 -1
  15. package/dist/sdk/client/store.js +4 -1
  16. package/dist/sdk/dialog/remote/Messenger.d.ts +1 -2
  17. package/dist/sdk/dialog/remote/Messenger.js +3 -1
  18. package/dist/sdk/react/hooks.d.ts +6 -2
  19. package/dist/sdk/react/hooks.js +6 -2
  20. package/dist/sdk/react/index.d.ts +3 -2
  21. package/dist/sdk/react/index.js +2 -1
  22. package/dist/sdk/react/profile-button.d.ts +9 -0
  23. package/dist/sdk/react/profile-button.js +28 -0
  24. package/dist/sdk/react/provider.js +1 -1
  25. package/dist/sdk/types/auth.d.ts +13 -0
  26. package/dist/sdk/types/dialog.d.ts +22 -0
  27. package/dist/sdk/types/events.d.ts +40 -0
  28. package/dist/sdk/types/events.js +6 -0
  29. package/dist/sdk/types/index.d.ts +5 -4
  30. package/dist/sdk/types/messenger.d.ts +30 -0
  31. package/dist/sdk/types/protocol.d.ts +15 -1
  32. package/dist/sdk/types/sdk.d.ts +72 -1
  33. package/dist/sdk/verify.d.ts +7 -0
  34. package/dist/sdk/verify.js +7 -0
  35. package/dist/sdk/wagmi/index.js +8 -3
  36. package/package.json +30 -4
  37. package/dist/sdk/client/protocol.d.ts +0 -97
  38. package/dist/sdk/client/protocol.js +0 -9
  39. package/dist/sdk/client/types.d.ts +0 -35
  40. package/dist/sdk/client/types.js +0 -0
  41. package/dist/sdk/types.d.ts +0 -32
  42. package/dist/sdk/types.js +0 -0
@@ -1,5 +1,5 @@
1
1
  import { Account } from "viem/tempo";
2
- import type { AccessKeyPreparation, Address as Hex, FinalizeAccessKeyParams, FlowCredential, ResolvedAccessKeyOptions, StoredAccessKey } from "../types";
2
+ import type { AccessKeyPreparation, FinalizeAccessKeyParams, FlowCredential, Address as Hex, ResolvedAccessKeyOptions, StoredAccessKey } from "../types";
3
3
  /**
4
4
  * Step 1 of access-key creation — runs BEFORE the dialog opens.
5
5
  *
@@ -7,8 +7,10 @@ import type { AccessKeyPreparation, Address as Hex, FinalizeAccessKeyParams, Flo
7
7
  * and builds an unsigned KeyAuthorization that grants this key permission to
8
8
  * sign on behalf of the root account until `expiry`. Returns the key pair
9
9
  * plus a signing payload (`accessKeyHash`) that gets passed into the dialog
10
- * so the same WebAuthn ceremony that signs the user in also signs the
11
- * authorization — saving a second tap.
10
+ * so the user's passkey can authorize it. The dialog signs `accessKeyHash`
11
+ * in its own WebAuthn ceremony, separate from login — Tempo's on-chain
12
+ * verifier requires a signature over this exact digest, so it cannot be
13
+ * folded into the login challenge.
12
14
  *
13
15
  * Pairs with `finalizeAccessKey()` after the dialog returns the WebAuthn
14
16
  * signature.
@@ -1,7 +1,7 @@
1
- import { Account, WebCryptoP256 } from "viem/tempo";
2
- import { KeyAuthorization, SignatureEnvelope } from "ox/tempo";
3
1
  import * as Address from "ox/Address";
4
2
  import * as PublicKey from "ox/PublicKey";
3
+ import { KeyAuthorization, SignatureEnvelope } from "ox/tempo";
4
+ import { Account, WebCryptoP256 } from "viem/tempo";
5
5
  import { idb } from "./idb";
6
6
  /**
7
7
  * Step 1 of access-key creation — runs BEFORE the dialog opens.
@@ -10,8 +10,10 @@ import { idb } from "./idb";
10
10
  * and builds an unsigned KeyAuthorization that grants this key permission to
11
11
  * sign on behalf of the root account until `expiry`. Returns the key pair
12
12
  * plus a signing payload (`accessKeyHash`) that gets passed into the dialog
13
- * so the same WebAuthn ceremony that signs the user in also signs the
14
- * authorization — saving a second tap.
13
+ * so the user's passkey can authorize it. The dialog signs `accessKeyHash`
14
+ * in its own WebAuthn ceremony, separate from login — Tempo's on-chain
15
+ * verifier requires a signature over this exact digest, so it cannot be
16
+ * folded into the login challenge.
15
17
  *
16
18
  * Pairs with `finalizeAccessKey()` after the dialog returns the WebAuthn
17
19
  * signature.
@@ -4,6 +4,43 @@ import { METHODS } from "./methods";
4
4
  import { credentialToAddress, restoreCredential, runLogin, runLogout, } from "./session";
5
5
  import { createStore, initialFlowState } from "./store";
6
6
  const DEFAULT_HOST = "https://id.flow.industries";
7
+ // Refresh a little before the JWT's `exp` so a token handed out by getToken()
8
+ // is still valid by the time it reaches the relying party, absorbing request
9
+ // latency and minor client/server clock skew.
10
+ const EXPIRY_SKEW_MS = 60_000;
11
+ /** Reads the `exp` (seconds since epoch) from a JWT, or null if unreadable. */
12
+ function jwtExp(token) {
13
+ const parts = token.split(".");
14
+ if (parts.length !== 3)
15
+ return null;
16
+ try {
17
+ const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
18
+ const payload = JSON.parse(atob(padded));
19
+ return typeof payload.exp === "number" ? payload.exp : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** True if the token is missing an exp, already expired, or within the skew. */
26
+ function isExpiring(token) {
27
+ const exp = jwtExp(token);
28
+ if (exp === null)
29
+ return true;
30
+ return Date.now() >= exp * 1000 - EXPIRY_SKEW_MS;
31
+ }
32
+ /**
33
+ * Runs `fn` while holding a cross-tab lock (Web Locks API) so concurrent tabs
34
+ * of the same origin serialize guest creation: the first tab mints and sets the
35
+ * shared cookie, later tabs then restore it instead of minting a duplicate
36
+ * guest. Falls back to running `fn` directly where Web Locks is unavailable.
37
+ */
38
+ function withGuestLock(name, fn) {
39
+ const locks = globalThis.navigator?.locks;
40
+ if (locks?.request)
41
+ return locks.request(name, fn);
42
+ return fn();
43
+ }
7
44
  /**
8
45
  * Normalizes user-supplied access-key configuration into a fully-resolved
9
46
  * shape. Accepts `true` for defaults, a partial options object, or omitted
@@ -93,7 +130,12 @@ export function createFlow(options = {}) {
93
130
  credential: result.credential,
94
131
  address: result.address,
95
132
  });
96
- await idb.set("flow.activeCredential", result.credential);
133
+ // A guest restore returns a null credential; never persist that — the IDB
134
+ // store is reserved for a real passkey credential and writing null would
135
+ // erase a previously stored one.
136
+ if (result.credential) {
137
+ await idb.set("flow.activeCredential", result.credential);
138
+ }
97
139
  return true;
98
140
  }
99
141
  catch {
@@ -104,10 +146,94 @@ export function createFlow(options = {}) {
104
146
  // from the cookie session. We expose two names because consumers reach for
105
147
  // one or the other based on intent (page-load vs near-expiry).
106
148
  const refreshJwt = restore;
149
+ // Dedupe concurrent refreshes: a burst of getToken() calls that all find the
150
+ // cached token expired should trigger one mint, not one per call.
151
+ let refreshInFlight = null;
152
+ /**
153
+ * Returns a currently-valid JWT, silently minting a fresh one from the
154
+ * 60-day cookie session when the cached token is missing, expired, or within
155
+ * EXPIRY_SKEW_MS of expiring. This is the accessor to call before hitting a
156
+ * relying-party backend: the JWT is a 1h access token, so reading the cached
157
+ * `flow.jwt` from a tab open longer than an hour would send a stale token.
158
+ *
159
+ * The refresh is silent (cookie-based, no passkey prompt). Returns null only
160
+ * when there is no usable session — the caller should then prompt
161
+ * `flow.login()`. Use it inside a 401 handler too: `await flow.getToken()`
162
+ * before retrying the request.
163
+ */
164
+ async function getToken() {
165
+ const current = store.getSnapshot().jwt;
166
+ if (current && !isExpiring(current))
167
+ return current;
168
+ if (!refreshInFlight) {
169
+ refreshInFlight = refreshJwt().finally(() => {
170
+ refreshInFlight = null;
171
+ });
172
+ }
173
+ await refreshInFlight;
174
+ const next = store.getSnapshot().jwt;
175
+ return next && !isExpiring(next) ? next : null;
176
+ }
177
+ // Single-flight within this instance; the cross-tab lock below extends the
178
+ // dedup across tabs of the same origin.
179
+ let guestInFlight = null;
180
+ /**
181
+ * Ensures a session exists, silently creating a persistent guest when none
182
+ * does. Idempotent and never throws (a failed mint resolves false so the
183
+ * caller can fall back to flow.login()).
184
+ *
185
+ * Restore-first under a cross-tab lock is the dedup: a returning or
186
+ * concurrently-minting visitor reuses their existing guest/full session
187
+ * instead of spawning a duplicate. The guest-mint branch writes only to the
188
+ * in-memory store (never IDB) — IDB is reserved for a real passkey credential.
189
+ */
190
+ async function ensureGuest() {
191
+ if (store.getSnapshot().user)
192
+ return true;
193
+ if (!guestInFlight) {
194
+ guestInFlight = withGuestLock(`flow.id.guest:${host}`, async () => {
195
+ // Re-check under the lock: another tab may have minted the guest (and
196
+ // set the shared id.flow.industries cookie) while we waited, so
197
+ // restore-first collapses concurrent first-visits onto one row.
198
+ if (store.getSnapshot().user)
199
+ return true;
200
+ if (await restore())
201
+ return true;
202
+ try {
203
+ const result = await getDialog().requestSilent(METHODS.guest, []);
204
+ if (!result.user)
205
+ return false;
206
+ store.setState({
207
+ user: result.user,
208
+ jwt: result.jwt,
209
+ // Only a guest has no credential. The server's idempotency path can
210
+ // return a full session here; in that case keep any credential/
211
+ // address already in state rather than stripping signing.
212
+ ...(result.user.isGuest ? { credential: null, address: null } : {}),
213
+ });
214
+ return true;
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ }).finally(() => {
220
+ guestInFlight = null;
221
+ });
222
+ }
223
+ return guestInFlight;
224
+ }
107
225
  void (async () => {
108
226
  await restoreCredential(store);
109
227
  if (options.autoRestore !== false) {
110
- await restore();
228
+ const restored = await restore();
229
+ // ensureGuest re-checks under a cross-tab lock (restore-first) before
230
+ // minting, so calling it after a failed restore can't fork a visitor into
231
+ // duplicate guests across tabs — the extra restore is the dedup.
232
+ if (!restored && options.autoGuest)
233
+ await ensureGuest();
234
+ }
235
+ else if (options.autoGuest) {
236
+ await ensureGuest();
111
237
  }
112
238
  })();
113
239
  function buildSigningContext() {
@@ -158,7 +284,12 @@ export function createFlow(options = {}) {
158
284
  preparation: accessKeyPrep,
159
285
  });
160
286
  }
161
- dialogHost.close();
287
+ // The dialog owns closing in every interactive path: sign-in sends "close"
288
+ // immediately after responding, sign-up after its brief "Welcome" screen.
289
+ // Closing here would preempt the welcome screen, and — since the gate can
290
+ // only see the caller's intent, not the flow the user actually completed —
291
+ // it fired even when a flow.login() user navigated to sign-up. So don't
292
+ // close from the connector; let the dialog decide.
162
293
  return session;
163
294
  }
164
295
  /**
@@ -180,6 +311,11 @@ export function createFlow(options = {}) {
180
311
  }
181
312
  await runLogout(store);
182
313
  dialog?.close();
314
+ // An autoGuest app is never truly "signed out" — it always wants at least a
315
+ // guest session. Re-mint one so the UI (e.g. the profile widget pill) keeps
316
+ // working after logout instead of vanishing until the next page load.
317
+ if (options.autoGuest)
318
+ await ensureGuest();
183
319
  }
184
320
  const flow = {
185
321
  get user() {
@@ -197,10 +333,15 @@ export function createFlow(options = {}) {
197
333
  get isAuthenticated() {
198
334
  return store.getSnapshot().user !== null;
199
335
  },
336
+ get isGuest() {
337
+ return store.getSnapshot().user?.isGuest === true;
338
+ },
200
339
  login,
201
340
  logout,
202
341
  restore,
342
+ ensureGuest,
203
343
  refreshJwt,
344
+ getToken,
204
345
  signMessage: async (args) => {
205
346
  const mod = await import("./signing");
206
347
  return mod.signMessage(buildSigningContext(), args);
@@ -226,6 +367,7 @@ export function createFlow(options = {}) {
226
367
  get dialog() {
227
368
  return getDialog();
228
369
  },
370
+ host,
229
371
  };
230
372
  currentFlow = flow;
231
373
  return flow;
@@ -1,4 +1,5 @@
1
1
  import * as Messenger from "../dialog/remote/Messenger";
2
+ import { bridgeToWindow, makeIframe } from "./iframe-host";
2
3
  const HIDDEN_STYLE = {
3
4
  position: "fixed",
4
5
  inset: "0",
@@ -38,24 +39,20 @@ export function createDialogHost(options) {
38
39
  const pending = new Map();
39
40
  /**
40
41
  * Mounts the iframe and wires up the postMessage bridge. Idempotent — safe
41
- * to call before every request. The `allow` attribute grants WebAuthn and
42
- * clipboard permissions to the dialog (required for passkey ceremonies).
42
+ * to call before every request.
43
43
  */
44
44
  function ensureFrame() {
45
45
  if (iframe)
46
46
  return;
47
- iframe = document.createElement("iframe");
48
- iframe.src = `${host}`;
47
+ iframe = makeIframe(`${host}`);
49
48
  iframe.dataset.flowId = "";
50
- iframe.allow = "publickey-credentials-create; publickey-credentials-get; clipboard-write";
51
49
  Object.assign(iframe.style, HIDDEN_STYLE);
52
- iframe.style.colorScheme = "normal";
53
50
  container.appendChild(iframe);
54
- messenger = Messenger.bridge({
55
- from: Messenger.fromWindow(window),
56
- to: Messenger.fromWindow(iframe.contentWindow),
57
- waitForReady: true,
58
- });
51
+ // Bind the bridge to this iframe's own window — a page may host more than
52
+ // one same-origin Flow iframe (e.g. the profile widget), and origin
53
+ // filtering can't tell them apart, so without the source filter their
54
+ // `ready`/`close` would cross-fire onto this dialog's bridge.
55
+ messenger = bridgeToWindow(iframe.contentWindow);
59
56
  messenger.on("rpc-response", (payload) => {
60
57
  const id = payload?.id;
61
58
  const handler = pending.get(id);
@@ -89,11 +86,7 @@ export function createDialogHost(options) {
89
86
  return;
90
87
  if (messenger)
91
88
  messenger.destroy();
92
- messenger = Messenger.bridge({
93
- from: Messenger.fromWindow(window),
94
- to: Messenger.fromWindow(popup),
95
- waitForReady: true,
96
- });
89
+ messenger = bridgeToWindow(popup);
97
90
  }
98
91
  let initSent = false;
99
92
  /**
@@ -116,11 +109,20 @@ export function createDialogHost(options) {
116
109
  if (!iframe)
117
110
  return;
118
111
  Object.assign(iframe.style, VISIBLE_STYLE);
112
+ // The dialog owns its open/close animation and mounts the overlay (playing
113
+ // the enter animation) on this signal — same mechanism as the profile
114
+ // widget, so every dialog appears and disappears identically.
115
+ void messenger?.send("__internal", { type: "dialog-shown" });
119
116
  }
120
117
  function hide() {
121
118
  if (!iframe)
122
119
  return;
123
- Object.assign(iframe.style, { display: "none", pointerEvents: "none" });
120
+ // Keep the iframe rendered and only drop hit-testing, so the dialog can play
121
+ // its exit animation. display:none would pause the iframe's
122
+ // requestAnimationFrame, making the close (and the next open) skip straight
123
+ // to the end. While hidden the overlay is transparent and click-through.
124
+ iframe.style.pointerEvents = "none";
125
+ void messenger?.send("__internal", { type: "dialog-hidden" });
124
126
  }
125
127
  function open(opts) {
126
128
  if (opts?.mode === "popup") {
@@ -128,8 +130,11 @@ export function createDialogHost(options) {
128
130
  return;
129
131
  }
130
132
  ensureFrame();
131
- show();
133
+ // Init before show so the dialog is initialized/navigated by the time it
134
+ // mounts the overlay on the show signal — the card enters with its content
135
+ // ready rather than flashing the pre-init spinner.
132
136
  sendInit(opts);
137
+ show();
133
138
  }
134
139
  function close() {
135
140
  hide();
@@ -176,6 +181,7 @@ export function createDialogHost(options) {
176
181
  async function requestSilent(method, params) {
177
182
  ensureFrame();
178
183
  sendInit();
184
+ await messenger.waitForReady();
179
185
  return dispatchRequest(method, params);
180
186
  }
181
187
  return {
@@ -0,0 +1,23 @@
1
+ import * as Messenger from "../dialog/remote/Messenger";
2
+ /**
3
+ * Permissions the dialog iframe needs to run WebAuthn passkey ceremonies and
4
+ * copy recovery values. Shared by every Flow iframe host (the dialog host and
5
+ * the profile widget).
6
+ */
7
+ export declare const IFRAME_ALLOW = "publickey-credentials-create; publickey-credentials-get; clipboard-write";
8
+ /**
9
+ * Creates a Flow dialog iframe element: WebAuthn-permitted, borderless, and
10
+ * `color-scheme: normal` so the iframe itself stays transparent (the rendered
11
+ * theme is applied to nested card wrappers, not the iframe). The caller owns
12
+ * positioning/visibility and any `data-*` marker.
13
+ */
14
+ export declare function makeIframe(src: string): HTMLIFrameElement;
15
+ /**
16
+ * Builds a postMessage bridge to a child window (iframe `contentWindow` or
17
+ * popup). Inbound is filtered by `source` so multiple same-origin Flow frames
18
+ * on one page can't cross-fire their `ready`/`close` handshakes; `targetOrigin`
19
+ * pins outbound sends to the host origin when given (omit for "*").
20
+ */
21
+ export declare function bridgeToWindow(target: Window, options?: {
22
+ targetOrigin?: string;
23
+ }): Messenger.Bridge;
@@ -0,0 +1,34 @@
1
+ import * as Messenger from "../dialog/remote/Messenger";
2
+ /**
3
+ * Permissions the dialog iframe needs to run WebAuthn passkey ceremonies and
4
+ * copy recovery values. Shared by every Flow iframe host (the dialog host and
5
+ * the profile widget).
6
+ */
7
+ export const IFRAME_ALLOW = "publickey-credentials-create; publickey-credentials-get; clipboard-write";
8
+ /**
9
+ * Creates a Flow dialog iframe element: WebAuthn-permitted, borderless, and
10
+ * `color-scheme: normal` so the iframe itself stays transparent (the rendered
11
+ * theme is applied to nested card wrappers, not the iframe). The caller owns
12
+ * positioning/visibility and any `data-*` marker.
13
+ */
14
+ export function makeIframe(src) {
15
+ const frame = document.createElement("iframe");
16
+ frame.src = src;
17
+ frame.allow = IFRAME_ALLOW;
18
+ frame.style.border = "none";
19
+ frame.style.colorScheme = "normal";
20
+ return frame;
21
+ }
22
+ /**
23
+ * Builds a postMessage bridge to a child window (iframe `contentWindow` or
24
+ * popup). Inbound is filtered by `source` so multiple same-origin Flow frames
25
+ * on one page can't cross-fire their `ready`/`close` handshakes; `targetOrigin`
26
+ * pins outbound sends to the host origin when given (omit for "*").
27
+ */
28
+ export function bridgeToWindow(target, options = {}) {
29
+ return Messenger.bridge({
30
+ from: Messenger.fromWindow(window, { source: target }),
31
+ to: Messenger.fromWindow(target, options.targetOrigin ? { targetOrigin: options.targetOrigin } : undefined),
32
+ waitForReady: true,
33
+ });
34
+ }
@@ -1,4 +1,5 @@
1
+ export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowState, FlowUser, LoginOptions, MethodName, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
1
2
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
2
3
  export { createDialogHost } from "./dialog-host";
3
4
  export { METHODS } from "./methods";
4
- export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowState, FlowUser, LoginOptions, MethodName, Session, } from "../types";
5
+ export { createProfileButton } from "./profile-button";
@@ -1,3 +1,4 @@
1
1
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
2
2
  export { createDialogHost } from "./dialog-host";
3
3
  export { METHODS } from "./methods";
4
+ export { createProfileButton } from "./profile-button";
@@ -5,5 +5,6 @@ export declare const METHODS: {
5
5
  readonly sendTransaction: "eth_sendTransaction";
6
6
  readonly sendCalls: "wallet_sendCalls";
7
7
  readonly restore: "wallet_restore";
8
+ readonly guest: "wallet_guest";
8
9
  readonly signOut: "wallet_signout";
9
10
  };
@@ -5,5 +5,6 @@ export const METHODS = {
5
5
  sendTransaction: "eth_sendTransaction",
6
6
  sendCalls: "wallet_sendCalls",
7
7
  restore: "wallet_restore",
8
+ guest: "wallet_guest",
8
9
  signOut: "wallet_signout",
9
10
  };
@@ -0,0 +1,15 @@
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
+ */
15
+ export declare function createProfileButton(options: MountProfileOptions): ProfileButtonHandle;
@@ -0,0 +1,189 @@
1
+ import * as Messenger from "../dialog/remote/Messenger";
2
+ import { getFlow, requireFlow } from "./create-flow";
3
+ import { bridgeToWindow, makeIframe } from "./iframe-host";
4
+ const DEFAULT_HOST = "https://id.flow.industries";
5
+ const POSITION_STYLE = {
6
+ "top-right": { top: "0", right: "0" },
7
+ "top-left": { top: "0", left: "0" },
8
+ "bottom-right": { bottom: "0", right: "0" },
9
+ "bottom-left": { bottom: "0", left: "0" },
10
+ };
11
+ const OVERLAY_STYLE = {
12
+ position: "fixed",
13
+ inset: "0",
14
+ width: "100%",
15
+ height: "100%",
16
+ zIndex: "2147483647",
17
+ };
18
+ /**
19
+ * Mounts the universal Flow ID "profile button" — a persistent inline iframe
20
+ * showing the signed-in user's avatar + username (the collapsed pill). Clicking
21
+ * it opens the profile dialog in a SEPARATE fullscreen overlay iframe, so the
22
+ * pill stays in place behind the dialog's dimmed backdrop rather than morphing
23
+ * into it. Both iframes ride the dialog SPA at `${host}/dialog/` (routes
24
+ * `/dialog/profile-pill` and `/dialog/profile`) so the chrome matches the auth
25
+ * dialog and the widget is first-party to Flow ID (reads `/api/me`, uploads
26
+ * avatars with the cookie session).
27
+ *
28
+ * Resolves the Flow singleton automatically (createFlow() must have run), so
29
+ * callers don't thread the instance through. No-op under SSR.
30
+ */
31
+ export function createProfileButton(options) {
32
+ if (typeof document === "undefined")
33
+ return { destroy() { } };
34
+ const flow = options.flow ?? getFlow() ?? requireFlow();
35
+ const host = (options.host ?? flow.host ?? DEFAULT_HOST).replace(/\/+$/, "");
36
+ const hostOrigin = new URL(host).origin;
37
+ const theme = options.theme ?? "light dark";
38
+ let createdContainer = null;
39
+ let container = options.container ?? null;
40
+ if (!container) {
41
+ const el = document.createElement("div");
42
+ Object.assign(el.style, {
43
+ position: "fixed",
44
+ zIndex: "2147483000",
45
+ // Never steal input from the host (e.g. the Godot canvas) — only the
46
+ // iframe re-enables pointer events for itself.
47
+ pointerEvents: "none",
48
+ padding: options.padding ?? "1.5rem",
49
+ ...POSITION_STYLE[options.position ?? "top-right"],
50
+ });
51
+ document.body.appendChild(el);
52
+ createdContainer = el;
53
+ container = el;
54
+ }
55
+ function makeFrame() {
56
+ const frame = makeIframe(`${host}/dialog/`);
57
+ frame.dataset.flowProfile = "";
58
+ frame.style.display = "block";
59
+ return frame;
60
+ }
61
+ // A page may host several same-origin Flow iframes (this pill, this dialog,
62
+ // the auth login dialog). Origin filtering can't tell them apart, so each
63
+ // bridge is bound to its own iframe window — otherwise their `ready`/`close`
64
+ // handshakes would cross-fire. Identity is pinned to the host origin so the
65
+ // username is never broadcast with "*".
66
+ function makeBridge(frame, route) {
67
+ const bridge = bridgeToWindow(frame.contentWindow, {
68
+ targetOrigin: hostOrigin,
69
+ });
70
+ bridge.send("__internal", {
71
+ type: "init",
72
+ mode: "iframe",
73
+ referrer: { title: document.title },
74
+ theme: { colorScheme: theme },
75
+ route,
76
+ });
77
+ return bridge;
78
+ }
79
+ function currentIdentity() {
80
+ const user = flow.getState().user;
81
+ return user
82
+ ? { username: user.username, isGuest: user.isGuest === true }
83
+ : null;
84
+ }
85
+ // ----- the persistent pill (stays mounted; sizes to its own content) -----
86
+ const pill = makeFrame();
87
+ let pillWidth = 0;
88
+ let pillHeight = 0;
89
+ function applyPillSize() {
90
+ Object.assign(pill.style, {
91
+ position: "static",
92
+ width: `${pillWidth}px`,
93
+ height: `${pillHeight}px`,
94
+ pointerEvents: pillWidth > 0 ? "auto" : "none",
95
+ });
96
+ }
97
+ applyPillSize();
98
+ container.appendChild(pill);
99
+ const pillBridge = makeBridge(pill, "/dialog/profile-pill");
100
+ pillBridge.on("__internal", (payload) => {
101
+ if (payload.type === "profile-resize") {
102
+ pillWidth = payload.width;
103
+ pillHeight = payload.height;
104
+ applyPillSize();
105
+ }
106
+ else if (payload.type === "profile-expand") {
107
+ showDialog();
108
+ }
109
+ });
110
+ // ----- the dialog overlay (lazy; a separate fullscreen iframe layered ABOVE
111
+ // the pill, so the pill shows through its dimmed backdrop) -----
112
+ let dialog = null;
113
+ let dialogBridge = null;
114
+ function ensureDialog() {
115
+ if (dialog)
116
+ return;
117
+ dialog = makeFrame();
118
+ Object.assign(dialog.style, OVERLAY_STYLE, {
119
+ pointerEvents: "none",
120
+ });
121
+ document.body.appendChild(dialog);
122
+ dialogBridge = makeBridge(dialog, "/dialog/profile");
123
+ dialogBridge.on("close", () => hideDialog());
124
+ dialogBridge.on("__internal", (payload) => {
125
+ if (payload.type === "profile-login") {
126
+ hideDialog();
127
+ void flow
128
+ .login(payload.mode === "signup" ? { signUp: true } : { signIn: true })
129
+ .catch(() => { });
130
+ }
131
+ else if (payload.type === "profile-logout") {
132
+ hideDialog();
133
+ void flow.logout().catch(() => { });
134
+ }
135
+ });
136
+ void dialogBridge.send("__internal", {
137
+ type: "profile-identity",
138
+ identity: currentIdentity(),
139
+ });
140
+ }
141
+ // The dialog owns its open/close animation entirely; the host only signals
142
+ // visibility and toggles hit-testing. The overlay iframe is NEVER set to
143
+ // display:none — that pauses the iframe's requestAnimationFrame, so motion's
144
+ // engine is asleep when the next open fires and the animation jumps straight
145
+ // to its end (instant). Keeping it always-rendered, transparent and
146
+ // click-through while hidden keeps every open/close animation reliable.
147
+ function showDialog() {
148
+ ensureDialog();
149
+ if (!dialog)
150
+ return;
151
+ dialog.style.pointerEvents = "auto";
152
+ void dialogBridge?.send("__internal", { type: "dialog-shown" });
153
+ }
154
+ function hideDialog() {
155
+ if (!dialog)
156
+ return;
157
+ dialog.style.pointerEvents = "none";
158
+ void dialogBridge?.send("__internal", { type: "dialog-hidden" });
159
+ }
160
+ // ----- keep both iframes' identity in sync with the Flow session -----
161
+ // flow.subscribe fires on any store change (jwt/credential/address too), so
162
+ // skip re-sending an identical identity (e.g. on every silent JWT refresh).
163
+ let lastIdentityKey;
164
+ function pushIdentity() {
165
+ const identity = currentIdentity();
166
+ const key = identity ? `${identity.username}|${identity.isGuest}` : "none";
167
+ if (key === lastIdentityKey)
168
+ return;
169
+ lastIdentityKey = key;
170
+ void pillBridge.send("__internal", { type: "profile-identity", identity });
171
+ if (dialogBridge)
172
+ void dialogBridge.send("__internal", {
173
+ type: "profile-identity",
174
+ identity,
175
+ });
176
+ }
177
+ pushIdentity();
178
+ const unsubscribe = flow.subscribe(pushIdentity);
179
+ return {
180
+ destroy() {
181
+ unsubscribe();
182
+ pillBridge.destroy();
183
+ dialogBridge?.destroy();
184
+ pill.remove();
185
+ dialog?.remove();
186
+ createdContainer?.remove();
187
+ },
188
+ };
189
+ }
@@ -1,5 +1,5 @@
1
1
  import { type Hex, type WalletClient } from "viem";
2
- import type { SendCallsArgs, SendTransactionArgs, SignMessageArgs, SignTypedDataArgs, SigningContext } from "../types";
2
+ import type { SendCallsArgs, SendTransactionArgs, SigningContext, SignMessageArgs, SignTypedDataArgs } from "../types";
3
3
  /**
4
4
  * Constructs a viem WalletClient bound to the user's Flow account. Wraps the
5
5
  * transport in `walletNamespaceCompat` so Tempo's wallet RPC namespace is
@@ -108,7 +108,9 @@ export async function buildWalletClient(ctx, chainId) {
108
108
  return createWalletClient({
109
109
  account,
110
110
  chain: withAccessKeyAuthorization(chain, address),
111
- transport: walletNamespaceCompat(transport, { account }),
111
+ transport: walletNamespaceCompat(transport, {
112
+ account,
113
+ }),
112
114
  });
113
115
  }
114
116
  export async function signMessage(ctx, args) {
@@ -28,7 +28,10 @@ export const initialFlowState = {
28
28
  function shallowEqual(a, b) {
29
29
  if (a === b)
30
30
  return true;
31
- if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
31
+ if (typeof a !== "object" ||
32
+ typeof b !== "object" ||
33
+ a === null ||
34
+ b === null) {
32
35
  return false;
33
36
  }
34
37
  const ak = Object.keys(a);