@flow-industries/id 0.4.0 → 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.
@@ -311,6 +311,11 @@ export function createFlow(options = {}) {
311
311
  }
312
312
  await runLogout(store);
313
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();
314
319
  }
315
320
  const flow = {
316
321
  get user() {
@@ -362,6 +367,7 @@ export function createFlow(options = {}) {
362
367
  get dialog() {
363
368
  return getDialog();
364
369
  },
370
+ host,
365
371
  };
366
372
  currentFlow = flow;
367
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,25 +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 =
51
- "publickey-credentials-create; publickey-credentials-get; clipboard-write";
52
49
  Object.assign(iframe.style, HIDDEN_STYLE);
53
- iframe.style.colorScheme = "normal";
54
50
  container.appendChild(iframe);
55
- messenger = Messenger.bridge({
56
- from: Messenger.fromWindow(window),
57
- to: Messenger.fromWindow(iframe.contentWindow),
58
- waitForReady: true,
59
- });
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);
60
56
  messenger.on("rpc-response", (payload) => {
61
57
  const id = payload?.id;
62
58
  const handler = pending.get(id);
@@ -90,11 +86,7 @@ export function createDialogHost(options) {
90
86
  return;
91
87
  if (messenger)
92
88
  messenger.destroy();
93
- messenger = Messenger.bridge({
94
- from: Messenger.fromWindow(window),
95
- to: Messenger.fromWindow(popup),
96
- waitForReady: true,
97
- });
89
+ messenger = bridgeToWindow(popup);
98
90
  }
99
91
  let initSent = false;
100
92
  /**
@@ -117,11 +109,20 @@ export function createDialogHost(options) {
117
109
  if (!iframe)
118
110
  return;
119
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" });
120
116
  }
121
117
  function hide() {
122
118
  if (!iframe)
123
119
  return;
124
- 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" });
125
126
  }
126
127
  function open(opts) {
127
128
  if (opts?.mode === "popup") {
@@ -129,8 +130,11 @@ export function createDialogHost(options) {
129
130
  return;
130
131
  }
131
132
  ensureFrame();
132
- 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.
133
136
  sendInit(opts);
137
+ show();
134
138
  }
135
139
  function close() {
136
140
  hide();
@@ -177,6 +181,7 @@ export function createDialogHost(options) {
177
181
  async function requestSilent(method, params) {
178
182
  ensureFrame();
179
183
  sendInit();
184
+ await messenger.waitForReady();
180
185
  return dispatchRequest(method, params);
181
186
  }
182
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, Session, } from "../types";
1
+ export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowState, FlowUser, LoginOptions, MethodName, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
2
2
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
3
3
  export { createDialogHost } from "./dialog-host";
4
4
  export { METHODS } from "./methods";
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";
@@ -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
+ }
@@ -38,7 +38,7 @@ export function from(messenger) {
38
38
  * isolation that prevents arbitrary pages from injecting messages.
39
39
  */
40
40
  export function fromWindow(w, options = {}) {
41
- const { targetOrigin } = options;
41
+ const { targetOrigin, source } = options;
42
42
  const listeners = new Map();
43
43
  return from({
44
44
  destroy() {
@@ -54,6 +54,8 @@ export function fromWindow(w, options = {}) {
54
54
  return;
55
55
  if (targetOrigin && event.origin !== targetOrigin)
56
56
  return;
57
+ if (source && event.source !== source)
58
+ return;
57
59
  listener(event.data.payload, event);
58
60
  }
59
61
  w.addEventListener("message", handler);
@@ -1,3 +1,4 @@
1
- export type { FlowIdProviderProps } from "../types";
1
+ export type { FlowIdProviderProps, ProfileButtonProps } from "../types";
2
2
  export { useFlow, useFlowId, useFlowState } from "./hooks";
3
+ export { ProfileButton } from "./profile-button";
3
4
  export { FlowIdProvider } from "./provider";
@@ -1,2 +1,3 @@
1
1
  export { useFlow, useFlowId, useFlowState } from "./hooks";
2
+ export { ProfileButton } from "./profile-button";
2
3
  export { FlowIdProvider } from "./provider";
@@ -0,0 +1,9 @@
1
+ import type { ProfileButtonProps } from "../types";
2
+ /**
3
+ * Drops the universal Flow ID profile button into a React tree. Renders a host
4
+ * element (style it via `className` for placement/padding — e.g. top-right) that
5
+ * the pill iframe mounts into; the iframe itself is sized to its content and
6
+ * expands to the profile dialog on click. Resolves the Flow instance from a
7
+ * `<FlowIdProvider>` or the createFlow() singleton.
8
+ */
9
+ export declare function ProfileButton({ className, host, theme }: ProfileButtonProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ import { createProfileButton } from "../client/profile-button";
4
+ import { useFlow } from "./hooks";
5
+ /**
6
+ * Drops the universal Flow ID profile button into a React tree. Renders a host
7
+ * element (style it via `className` for placement/padding — e.g. top-right) that
8
+ * the pill iframe mounts into; the iframe itself is sized to its content and
9
+ * expands to the profile dialog on click. Resolves the Flow instance from a
10
+ * `<FlowIdProvider>` or the createFlow() singleton.
11
+ */
12
+ export function ProfileButton({ className, host, theme }) {
13
+ const flow = useFlow();
14
+ const ref = useRef(null);
15
+ useEffect(() => {
16
+ const container = ref.current;
17
+ if (!container)
18
+ return;
19
+ const handle = createProfileButton({
20
+ container,
21
+ flow,
22
+ ...(host ? { host } : {}),
23
+ ...(theme ? { theme } : {}),
24
+ });
25
+ return () => handle.destroy();
26
+ }, [flow, host, theme]);
27
+ return _jsx("div", { ref: ref, className: className });
28
+ }
@@ -37,6 +37,27 @@ export type DialogCustomLabels = {
37
37
  switchAccount?: string;
38
38
  signUpLink?: string;
39
39
  };
40
+ /**
41
+ * The profile fields the widget renders, read from `/api/me` (the cookie
42
+ * session's `session.user`). `image`/`createdAt` are not on the SDK's
43
+ * `FlowUser`, so they only come from this authenticated fetch.
44
+ */
45
+ export type ProfileData = {
46
+ username: string;
47
+ image?: string | null;
48
+ createdAt?: string;
49
+ isGuest?: boolean;
50
+ };
51
+ /**
52
+ * Identity the embedding host pushes into the widget over postMessage. Lets the
53
+ * pill render the username immediately (and reactively on login/logout) without
54
+ * waiting on `/api/me` — the fast path that also works where third-party cookies
55
+ * are blocked (Safari/Firefox).
56
+ */
57
+ export type ProfileIdentity = {
58
+ username: string;
59
+ isGuest: boolean;
60
+ };
40
61
  export type DialogState = {
41
62
  mode: string;
42
63
  display: "floating" | "drawer" | "full";
@@ -50,6 +71,7 @@ export type DialogState = {
50
71
  username?: string;
51
72
  email?: string;
52
73
  }>;
74
+ shown: boolean;
53
75
  customFeatures?: DialogCustomFeatures;
54
76
  customLabels?: DialogCustomLabels;
55
77
  };
@@ -8,8 +8,8 @@ export type AuthOutcome = "success" | "failure" | "info";
8
8
  export type AuthMode = "sign-up" | "sign-in";
9
9
  /** Client-only funnel steps reported via the `/api/events` beacon. */
10
10
  export type FunnelStep = "mode_selected" | "email_entered" | "ceremony_started" | "done_shown";
11
- export type AuthEventName = "auth.username.checked" | "auth.signup.succeeded" | "auth.signup.failed" | "auth.otp.sent" | "auth.otp.verified" | "auth.otp.failed" | "auth.email.verify.sent" | "auth.email.verify.succeeded" | "auth.email.verify.failed" | "auth.email.changed" | "auth.challenge.issued" | "auth.signin.succeeded" | "auth.signin.failed" | "auth.restore.succeeded" | "auth.restore.failed" | "auth.restore.no_session" | "auth.jwt.verified" | "auth.jwt.rejected" | "auth.signout" | "auth.audience.rejected" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.done_shown";
12
- export type AuthErrorCode = "username_taken" | "credential_taken" | "email_taken" | "email_not_verified" | "no_email" | "otp_invalid" | "otp_expired" | "otp_attempts_exceeded" | "otp_resend_cooldown" | "otp_resend_limit" | "otp_global_limit" | "otp_send_failed" | "challenge_expired" | "unknown_credential" | "invalid_assertion_type" | "invalid_assertion_origin" | "user_verification_required" | "invalid_signature" | "user_not_found" | "missing_audience" | "audience_not_allowed" | "audience_mismatch" | "no_session" | "no_passkey" | "guest_rate_limited" | "guest_global_limit" | "guest_username_exhausted" | "already_upgraded" | "malformed_token" | "unknown_key" | "verification_failed" | "internal_error";
11
+ export type AuthEventName = "auth.username.checked" | "auth.signup.succeeded" | "auth.signup.failed" | "auth.otp.sent" | "auth.otp.verified" | "auth.otp.failed" | "auth.email.verify.sent" | "auth.email.verify.succeeded" | "auth.email.verify.failed" | "auth.email.changed" | "auth.avatar.upload.succeeded" | "auth.avatar.upload.failed" | "auth.challenge.issued" | "auth.signin.succeeded" | "auth.signin.failed" | "auth.restore.succeeded" | "auth.restore.failed" | "auth.restore.no_session" | "auth.jwt.verified" | "auth.jwt.rejected" | "auth.signout" | "auth.audience.rejected" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.done_shown";
12
+ export type AuthErrorCode = "username_taken" | "credential_taken" | "email_taken" | "email_not_verified" | "no_email" | "image_upload_forbidden" | "image_upload_rate_limited" | "invalid_image_type" | "image_too_large" | "image_upload_failed" | "otp_invalid" | "otp_expired" | "otp_attempts_exceeded" | "otp_resend_cooldown" | "otp_resend_limit" | "otp_global_limit" | "otp_send_failed" | "challenge_expired" | "unknown_credential" | "invalid_assertion_type" | "invalid_assertion_origin" | "user_verification_required" | "invalid_signature" | "user_not_found" | "missing_audience" | "audience_not_allowed" | "audience_mismatch" | "no_session" | "no_passkey" | "guest_rate_limited" | "guest_global_limit" | "guest_username_exhausted" | "already_upgraded" | "malformed_token" | "unknown_key" | "verification_failed" | "internal_error";
13
13
  /** One flat record per event = one row in the `auth_events` stream. */
14
14
  export interface AuthEventRecord {
15
15
  service: "auth";
@@ -1,8 +1,8 @@
1
1
  export type { AuthConfig, AuthResponse, AuthResponseWithWebAuthn, FlowCredential, FlowUser, PasskeyPluginOptions, VerifiedFlowJWT, VerifyOptions, WebAuthnSignature, } from "./auth";
2
- export type { BoundaryError, DialogCustomFeatures, DialogCustomLabels, DialogError, DialogReferrer, DialogState, } from "./dialog";
2
+ export type { BoundaryError, DialogCustomFeatures, DialogCustomLabels, DialogError, DialogReferrer, DialogState, ProfileData, ProfileIdentity, } from "./dialog";
3
3
  export type { AuthErrorCode, AuthEventName, AuthEventRecord, AuthMode, AuthOutcome, BeaconBody, FunnelStep, } from "./events";
4
4
  export type { Bridge, BridgeParameters, FlowAccount, FlowRemote, FlowRemoteConfig, FromWindowOptions, MessageResponse, Messenger, OneOf, Payload, QueuedRequest, ReadyOptions, RemoteFlowState, RemoteState, Schema, Storage, Topic, WithReady, } from "./messenger";
5
5
  export type { Call, ConnectCapabilities, ConnectRequest, ConnectResponse, GuestRequest, GuestResponse, MethodName, MethodParams, MethodResult, RestoreRequest, RestoreResponse, RpcRequest, SendCallsParams, SendCallsRequest, SendCallsResponse, SendTransactionParams, SendTransactionRequest, SendTransactionResponse, SignMessageRequest, SignMessageResponse, SignOutRequest, SignOutResponse, SignTypedDataRequest, SignTypedDataResponse, TransactionArgs, TypedData, TypedDataDomain, TypedDataField, } from "./protocol";
6
6
  export { isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
7
- export type { AccessKeyOptions, AccessKeyPreparation, Address, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowIdProviderProps, FlowState, Listener, LoginOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
7
+ export type { AccessKeyOptions, AccessKeyPreparation, Address, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowIdProviderProps, FlowState, Listener, LoginOptions, MountProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
8
8
  export type { CoinAsset, IdentifiedTx, TxApprove, TxConvert, TxSend, TxSwap, } from "./tx";
@@ -65,6 +65,7 @@ export type Schema = [
65
65
  };
66
66
  features?: Record<string, boolean>;
67
67
  labels?: Record<string, string>;
68
+ route?: string;
68
69
  } | {
69
70
  type: "switch";
70
71
  mode: "iframe" | "popup" | "popup-standalone" | "page";
@@ -77,6 +78,27 @@ export type Schema = [
77
78
  theme: {
78
79
  colorScheme?: string;
79
80
  };
81
+ } | {
82
+ type: "profile-resize";
83
+ width: number;
84
+ height: number;
85
+ } | {
86
+ type: "profile-expand";
87
+ } | {
88
+ type: "profile-identity";
89
+ identity: {
90
+ username: string;
91
+ isGuest: boolean;
92
+ } | null;
93
+ } | {
94
+ type: "profile-login";
95
+ mode: "signin" | "signup";
96
+ } | {
97
+ type: "profile-logout";
98
+ } | {
99
+ type: "dialog-shown";
100
+ } | {
101
+ type: "dialog-hidden";
80
102
  };
81
103
  response: undefined;
82
104
  }
@@ -106,6 +128,14 @@ export type Bridge = WithReady & {
106
128
  };
107
129
  export type FromWindowOptions = {
108
130
  targetOrigin?: string;
131
+ /**
132
+ * When set, inbound messages are only accepted from this exact window
133
+ * (`event.source`). Required when more than one same-origin iframe posts to
134
+ * the same host window (e.g. the auth dialog and the profile widget) —
135
+ * origin filtering alone can't tell same-origin frames apart, so without this
136
+ * one frame's `ready` handshake would resolve the other bridge's gate.
137
+ */
138
+ source?: MessageEventSource | null;
109
139
  };
110
140
  export type BridgeParameters = {
111
141
  from: Messenger;
@@ -129,6 +129,36 @@ export type DialogOpenOptions = {
129
129
  icon?: string;
130
130
  };
131
131
  };
132
+ /** Anchor for an auto-created (host-less) profile widget container. */
133
+ export type ProfilePosition = "top-right" | "top-left" | "bottom-right" | "bottom-left";
134
+ export type MountProfileOptions = {
135
+ /**
136
+ * Element to mount the pill iframe into. When omitted, a fixed-position
137
+ * container is created and anchored via `position` — the path of least
138
+ * resistance for non-DOM hosts (the Godot canvas overlay).
139
+ */
140
+ container?: HTMLElement;
141
+ /** Anchor used only when `container` is omitted. Defaults to "top-right". */
142
+ position?: ProfilePosition;
143
+ /** CSS padding for the auto-created container. Defaults to a responsive inset. */
144
+ padding?: string;
145
+ /**
146
+ * The Flow ID origin serving the widget (e.g. https://id.flow.industries).
147
+ * Defaults to the resolved Flow instance's `host`, then the SDK default.
148
+ */
149
+ host?: string;
150
+ /**
151
+ * Color scheme for the widget chrome. Set this to match the host (the dark
152
+ * game UI passes "dark"). Defaults to "light dark" (follows the OS).
153
+ */
154
+ theme?: "light" | "dark" | "light dark";
155
+ /** Explicit Flow instance. Defaults to the createFlow() singleton. */
156
+ flow?: Flow;
157
+ };
158
+ export type ProfileButtonHandle = {
159
+ /** Tears down the iframe, listeners, and any auto-created container. */
160
+ destroy: () => void;
161
+ };
132
162
  export type DialogHost = {
133
163
  open: (options?: DialogOpenOptions) => void;
134
164
  close: () => void;
@@ -178,6 +208,13 @@ export type Flow = {
178
208
  subscribe(listener: Listener<FlowState>): () => void;
179
209
  getState(): FlowState;
180
210
  dialog: DialogHost;
211
+ /**
212
+ * The resolved Flow ID origin this instance talks to (no trailing slash),
213
+ * e.g. `https://id.flow.industries`. Exposed so widgets like the profile
214
+ * button build their iframe URL from the single host configured on createFlow
215
+ * instead of re-resolving it.
216
+ */
217
+ readonly host: string;
181
218
  };
182
219
  export type FlowConnectorParameters = Omit<CreateFlowOptions, "chains" | "transports"> & {
183
220
  /**
@@ -202,6 +239,14 @@ export type FlowIdProviderProps = {
202
239
  flow?: Flow;
203
240
  children: ReactNode;
204
241
  };
242
+ export type ProfileButtonProps = {
243
+ /** Class applied to the host element the pill iframe mounts into. */
244
+ className?: string;
245
+ /** Override the Flow ID origin; defaults to the resolved Flow's `host`. */
246
+ host?: string;
247
+ /** Color scheme for the widget chrome; defaults to "light dark" (the OS). */
248
+ theme?: "light" | "dark" | "light dark";
249
+ };
205
250
  export type RunLoginParams = {
206
251
  dialog: DialogHost;
207
252
  store: Store<FlowState>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "main": "./dist/sdk/client/index.js",
5
5
  "module": "./dist/sdk/client/index.js",
6
6
  "types": "./dist/sdk/client/index.d.ts",
@@ -93,6 +93,7 @@
93
93
  "vite": "^7.3.1"
94
94
  },
95
95
  "dependencies": {
96
+ "@aws-sdk/client-s3": "^3.1073.0",
96
97
  "@flow-industries/ui": "^0.15.3",
97
98
  "@hono/otel": "^1.1.2",
98
99
  "@openobserve/browser-logs": "^0.3.1",
@@ -121,6 +122,7 @@
121
122
  "react": "^19.2.4",
122
123
  "react-dom": "^19.2.4",
123
124
  "react-intersection-observer": "^10.0.2",
125
+ "sharp": "^0.35.2",
124
126
  "tempo.ts": "^0.14.2",
125
127
  "viem": "2.47.10",
126
128
  "wagmi": "^3.4.2",