@flow-industries/id 0.19.1 → 0.19.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Flow Industries
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,11 +1,18 @@
1
1
  import * as Messenger from "../dialog/remote/Messenger";
2
- import type { Flow } from "../types";
2
+ import type { Flow, ProfileIdentity } from "../types";
3
3
  /**
4
4
  * Permissions the dialog iframe needs to run WebAuthn passkey ceremonies and
5
5
  * copy recovery values. Shared by every Flow iframe host (the dialog host and
6
6
  * the profile widget).
7
7
  */
8
8
  export declare const IFRAME_ALLOW = "publickey-credentials-create; publickey-credentials-get; clipboard-write";
9
+ /**
10
+ * Geometry for a dialog overlay iframe: a fullscreen layer above everything the
11
+ * host draws, so the dialog owns the whole viewport and dims the page behind
12
+ * its own backdrop. Hosts toggle `pointerEvents` on top of this and nothing
13
+ * else — see the visibility rule on `createProfileButton`.
14
+ */
15
+ export declare const OVERLAY_STYLE: Partial<CSSStyleDeclaration>;
9
16
  /**
10
17
  * Creates a Flow dialog iframe element: WebAuthn-permitted, borderless, and
11
18
  * `color-scheme: normal` so the iframe itself stays transparent (the rendered
@@ -22,6 +29,12 @@ export declare function makeIframe(src: string): HTMLIFrameElement;
22
29
  export declare function bridgeToWindow(target: Window, options?: {
23
30
  targetOrigin?: string;
24
31
  }): Messenger.Bridge;
32
+ /**
33
+ * Who the host currently has signed in, in the shape the dialog reads to tell
34
+ * "this is you" from "this is someone else". Null when signed out, which is a
35
+ * real answer the dialog renders rather than a missing one it waits for.
36
+ */
37
+ export declare function hostIdentity(flow: Flow): ProfileIdentity | null;
25
38
  /**
26
39
  * Answers a widget's request for the host's credential. Every Flow iframe that
27
40
  * reads the Flow ID API from a cross-site frame asks for this, because its own
@@ -5,6 +5,19 @@ import * as Messenger from "../dialog/remote/Messenger";
5
5
  * the profile widget).
6
6
  */
7
7
  export const IFRAME_ALLOW = "publickey-credentials-create; publickey-credentials-get; clipboard-write";
8
+ /**
9
+ * Geometry for a dialog overlay iframe: a fullscreen layer above everything the
10
+ * host draws, so the dialog owns the whole viewport and dims the page behind
11
+ * its own backdrop. Hosts toggle `pointerEvents` on top of this and nothing
12
+ * else — see the visibility rule on `createProfileButton`.
13
+ */
14
+ export const OVERLAY_STYLE = {
15
+ position: "fixed",
16
+ inset: "0",
17
+ width: "100%",
18
+ height: "100%",
19
+ zIndex: "2147483647",
20
+ };
8
21
  /**
9
22
  * Creates a Flow dialog iframe element: WebAuthn-permitted, borderless, and
10
23
  * `color-scheme: normal` so the iframe itself stays transparent (the rendered
@@ -32,6 +45,17 @@ export function bridgeToWindow(target, options = {}) {
32
45
  waitForReady: true,
33
46
  });
34
47
  }
48
+ /**
49
+ * Who the host currently has signed in, in the shape the dialog reads to tell
50
+ * "this is you" from "this is someone else". Null when signed out, which is a
51
+ * real answer the dialog renders rather than a missing one it waits for.
52
+ */
53
+ export function hostIdentity(flow) {
54
+ const user = flow.getState().user;
55
+ return user
56
+ ? { username: user.username, isGuest: user.isGuest === true }
57
+ : null;
58
+ }
35
59
  /**
36
60
  * Answers a widget's request for the host's credential. Every Flow iframe that
37
61
  * reads the Flow ID API from a cross-site frame asks for this, because its own
@@ -1,9 +1,10 @@
1
1
  export { defaultIdHost, isLocalHostname } from "../id-host";
2
- export type { AccessKeyOptions, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
2
+ export type { AccessKeyOptions, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
3
3
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
4
4
  export { createDialogHost } from "./dialog-host";
5
5
  export { createFlowWidget } from "./flow-widget";
6
6
  export { METHODS } from "./methods";
7
+ export { closeProfile, openProfile } from "./open-profile";
7
8
  export { createProfileButton } from "./profile-button";
8
9
  export { createRoomsApi, RoomsRequestError } from "./rooms";
9
10
  export { createStaticFlow } from "./static-flow";
@@ -3,6 +3,7 @@ export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
3
3
  export { createDialogHost } from "./dialog-host";
4
4
  export { createFlowWidget } from "./flow-widget";
5
5
  export { METHODS } from "./methods";
6
+ export { closeProfile, openProfile } from "./open-profile";
6
7
  export { createProfileButton } from "./profile-button";
7
8
  export { createRoomsApi, RoomsRequestError } from "./rooms";
8
9
  export { createStaticFlow } from "./static-flow";
@@ -0,0 +1,42 @@
1
+ import type { OpenProfileOptions } from "../types";
2
+ /**
3
+ * Opens another user's public Flow profile in a fullscreen overlay above the
4
+ * host page — the read-only view of `@username`: avatar, bio, level and the
5
+ * month they joined.
6
+ *
7
+ * This exists because most Flow surfaces have nowhere to navigate to. The game
8
+ * draws into a canvas and talk's chat lives in an embedded frame, so following
9
+ * a link would replace the thing the player is doing; an overlay leaves it
10
+ * running underneath. The dialog is served by Flow ID itself, so it reads the
11
+ * profile first-party and inherits the auth dialog's chrome.
12
+ *
13
+ * Callers name a **user**, never a route, for the reason `createFlowWidget`
14
+ * takes a widget name: the dialog's URL space stays an internal detail and an
15
+ * embedder can't point the frame at an auth route. A profile route is
16
+ * parameterized, so the SDK validates the name and builds the route itself —
17
+ * a name that could not belong to a Flow account is refused here rather than
18
+ * opening the dialog onto a request the server was always going to reject.
19
+ *
20
+ * The overlay is created on first use and reused after: opening a second, third
21
+ * and tenth profile re-points the same iframe, which is what keeps every open
22
+ * animating and costs no reload. It is safe to call from a click handler on
23
+ * every name your UI renders.
24
+ *
25
+ * Returns whether the profile was opened — `false` for a username that isn't
26
+ * shaped like a Flow handle, and under SSR, where there is nothing to open.
27
+ *
28
+ * ```ts
29
+ * import { openProfile } from "@flow-industries/id";
30
+ *
31
+ * openProfile("alice");
32
+ * openProfile("bob", { theme: "dark" });
33
+ * ```
34
+ */
35
+ export declare function openProfile(username: string, options?: OpenProfileOptions): boolean;
36
+ /**
37
+ * Closes the profile overlay if one is open. The dialog already closes itself
38
+ * on its own close button, the backdrop and Escape, so this is for the host
39
+ * that needs to take the screen back on its own terms — a game resuming play,
40
+ * a route change in an embedded app. A no-op when nothing is open.
41
+ */
42
+ export declare function closeProfile(): void;
@@ -0,0 +1,177 @@
1
+ import { resolveIdHost } from "../id-host";
2
+ import { isValidUsername } from "../usernames";
3
+ import { getFlow, requireFlow } from "./create-flow";
4
+ import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, } from "./iframe-host";
5
+ // One overlay per page, kept for the page's lifetime. The alternative — a
6
+ // handle per call site — would put a second fullscreen iframe over the host the
7
+ // moment two parts of an app (a scoreboard and a chat line) both showed a
8
+ // profile, and each would carry its own copy of the dialog.
9
+ let viewer = null;
10
+ function createViewer(options) {
11
+ const flow = options.flow ?? getFlow() ?? requireFlow();
12
+ const host = resolveIdHost(options.host ?? flow.host);
13
+ const hostOrigin = new URL(host).origin;
14
+ let theme = options.theme ?? "light dark";
15
+ let frame = null;
16
+ let bridge = null;
17
+ // The overlay covers the whole viewport, so it must not accept a click until
18
+ // the dialog behind it can act on one. `ready` is the child's own handshake:
19
+ // until it arrives the frame is still loading — or never will, if the host is
20
+ // unreachable — and a hit-testing overlay would silently eat every click
21
+ // meant for the page underneath with nothing on screen to explain it.
22
+ let ready = false;
23
+ let visible = false;
24
+ function applyHitTesting() {
25
+ if (frame)
26
+ frame.style.pointerEvents = ready && visible ? "auto" : "none";
27
+ }
28
+ function mount(username) {
29
+ const el = makeIframe(`${host}/dialog/`);
30
+ el.dataset.flowProfileView = "";
31
+ Object.assign(el.style, OVERLAY_STYLE, { pointerEvents: "none" });
32
+ // Appended before bridging: `contentWindow` is null until the frame is in
33
+ // the document, and the bridge is bound to that window.
34
+ document.body.appendChild(el);
35
+ frame = el;
36
+ /* SAFETY: the iframe was just appended, so its contentWindow exists. */
37
+ const opened = bridgeToWindow(el.contentWindow, {
38
+ targetOrigin: hostOrigin,
39
+ });
40
+ bridge = opened;
41
+ // The dialog SPA has no deep-link history fallback, so the frame loads at
42
+ // `/dialog/` and `init` asks it to land on the profile. Sending the route
43
+ // here rather than as a follow-up message keeps the sign-in root from ever
44
+ // mounting, even for the frame it takes to navigate away from it.
45
+ void opened.send("__internal", {
46
+ type: "init",
47
+ mode: "iframe",
48
+ referrer: { title: document.title },
49
+ theme: { colorScheme: theme },
50
+ route: `/dialog/u/${username}`,
51
+ });
52
+ void opened
53
+ .waitForReady()
54
+ .then(() => {
55
+ ready = true;
56
+ applyHitTesting();
57
+ })
58
+ .catch(() => { });
59
+ opened.on("close", () => close());
60
+ opened.on("__internal", (payload) => {
61
+ // A viewer looking at their own profile can reach the account view from
62
+ // here, which offers the same sign-in and sign-out the pill's dialog
63
+ // does. Those run on the host — it owns the session — so dropping them
64
+ // would leave the buttons doing nothing at all.
65
+ if (payload.type === "profile-login") {
66
+ close();
67
+ void flow
68
+ .login(payload.mode === "signup" ? { signUp: true } : { signIn: true })
69
+ .catch(() => { });
70
+ }
71
+ else if (payload.type === "profile-logout") {
72
+ close();
73
+ void flow.logout().catch(() => { });
74
+ }
75
+ else if (payload.type === "profile-token-request") {
76
+ void answerTokenRequest(opened, flow, payload.id);
77
+ }
78
+ });
79
+ return opened;
80
+ }
81
+ function open(username) {
82
+ const mounted = bridge;
83
+ const live = mounted ?? mount(username);
84
+ // A frame created just now is already on this profile; an existing one is
85
+ // re-pointed in place. Tearing it down and rebuilding would reload the
86
+ // dialog and lose the open animation on every profile after the first.
87
+ if (mounted)
88
+ void live.send("__internal", { type: "profile-view", username });
89
+ // Pushed on every open rather than tracked with a subscription: it is only
90
+ // read while a profile is on screen, and sending it here means the "this is
91
+ // you" link is decided from the session as it stands at that moment.
92
+ void live.send("__internal", {
93
+ type: "profile-identity",
94
+ identity: hostIdentity(flow),
95
+ });
96
+ visible = true;
97
+ applyHitTesting();
98
+ void live.send("__internal", { type: "dialog-shown" });
99
+ }
100
+ // Visibility is hit-testing plus a message, and nothing else. The overlay
101
+ // iframe is NEVER set to `display: none` — that pauses the iframe's
102
+ // requestAnimationFrame, so motion's engine is asleep when the next open
103
+ // fires and the animation jumps straight to its end (instant). Keeping it
104
+ // always-rendered, transparent and click-through while hidden keeps every
105
+ // open animation reliable, on the second open and the tenth.
106
+ function close() {
107
+ if (!frame || !bridge)
108
+ return;
109
+ visible = false;
110
+ applyHitTesting();
111
+ void bridge.send("__internal", { type: "dialog-hidden" });
112
+ }
113
+ function setTheme(next) {
114
+ if (next === theme)
115
+ return;
116
+ theme = next;
117
+ void bridge?.send("__internal", {
118
+ type: "set-theme",
119
+ theme: { colorScheme: next },
120
+ });
121
+ }
122
+ return { open, close, setTheme };
123
+ }
124
+ /**
125
+ * Opens another user's public Flow profile in a fullscreen overlay above the
126
+ * host page — the read-only view of `@username`: avatar, bio, level and the
127
+ * month they joined.
128
+ *
129
+ * This exists because most Flow surfaces have nowhere to navigate to. The game
130
+ * draws into a canvas and talk's chat lives in an embedded frame, so following
131
+ * a link would replace the thing the player is doing; an overlay leaves it
132
+ * running underneath. The dialog is served by Flow ID itself, so it reads the
133
+ * profile first-party and inherits the auth dialog's chrome.
134
+ *
135
+ * Callers name a **user**, never a route, for the reason `createFlowWidget`
136
+ * takes a widget name: the dialog's URL space stays an internal detail and an
137
+ * embedder can't point the frame at an auth route. A profile route is
138
+ * parameterized, so the SDK validates the name and builds the route itself —
139
+ * a name that could not belong to a Flow account is refused here rather than
140
+ * opening the dialog onto a request the server was always going to reject.
141
+ *
142
+ * The overlay is created on first use and reused after: opening a second, third
143
+ * and tenth profile re-points the same iframe, which is what keeps every open
144
+ * animating and costs no reload. It is safe to call from a click handler on
145
+ * every name your UI renders.
146
+ *
147
+ * Returns whether the profile was opened — `false` for a username that isn't
148
+ * shaped like a Flow handle, and under SSR, where there is nothing to open.
149
+ *
150
+ * ```ts
151
+ * import { openProfile } from "@flow-industries/id";
152
+ *
153
+ * openProfile("alice");
154
+ * openProfile("bob", { theme: "dark" });
155
+ * ```
156
+ */
157
+ export function openProfile(username, options = {}) {
158
+ if (!isValidUsername(username))
159
+ return false;
160
+ if (!("document" in globalThis))
161
+ return false;
162
+ const live = viewer ?? createViewer(options);
163
+ viewer = live;
164
+ if (options.theme)
165
+ live.setTheme(options.theme);
166
+ live.open(username);
167
+ return true;
168
+ }
169
+ /**
170
+ * Closes the profile overlay if one is open. The dialog already closes itself
171
+ * on its own close button, the backdrop and Escape, so this is for the host
172
+ * that needs to take the screen back on its own terms — a game resuming play,
173
+ * a route change in an embedded app. A no-op when nothing is open.
174
+ */
175
+ export function closeProfile() {
176
+ viewer?.close();
177
+ }
@@ -1,19 +1,12 @@
1
1
  import { resolveIdHost } from "../id-host";
2
2
  import { getFlow, requireFlow } from "./create-flow";
3
- import { answerTokenRequest, bridgeToWindow, makeIframe } from "./iframe-host";
3
+ import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, } from "./iframe-host";
4
4
  const POSITION_STYLE = {
5
5
  "top-right": { top: "0", right: "0" },
6
6
  "top-left": { top: "0", left: "0" },
7
7
  "bottom-right": { bottom: "0", right: "0" },
8
8
  "bottom-left": { bottom: "0", left: "0" },
9
9
  };
10
- const OVERLAY_STYLE = {
11
- position: "fixed",
12
- inset: "0",
13
- width: "100%",
14
- height: "100%",
15
- zIndex: "2147483647",
16
- };
17
10
  /**
18
11
  * Mounts the universal Flow ID "profile button" — a persistent inline iframe
19
12
  * showing the signed-in user's avatar + username (the collapsed pill). Clicking
@@ -77,12 +70,6 @@ export function createProfileButton(options) {
77
70
  });
78
71
  return bridge;
79
72
  }
80
- function currentIdentity() {
81
- const user = flow.getState().user;
82
- return user
83
- ? { username: user.username, isGuest: user.isGuest === true }
84
- : null;
85
- }
86
73
  // ----- the persistent pill (stays mounted; sizes to its own content) -----
87
74
  const pill = makeFrame();
88
75
  let pillWidth = 0;
@@ -143,7 +130,7 @@ export function createProfileButton(options) {
143
130
  });
144
131
  void dialogBridge.send("__internal", {
145
132
  type: "profile-identity",
146
- identity: currentIdentity(),
133
+ identity: hostIdentity(flow),
147
134
  });
148
135
  }
149
136
  // The dialog owns its open/close animation entirely; the host only signals
@@ -170,7 +157,7 @@ export function createProfileButton(options) {
170
157
  // skip re-sending an identical identity (e.g. on every silent JWT refresh).
171
158
  let lastIdentityKey;
172
159
  function pushIdentity() {
173
- const identity = currentIdentity();
160
+ const identity = hostIdentity(flow);
174
161
  const key = identity ? `${identity.username}|${identity.isGuest}` : "none";
175
162
  if (key === lastIdentityKey)
176
163
  return;
@@ -1,5 +1,6 @@
1
- export type { FlowIdProviderProps, FlowWidgetProps, ProfileButtonProps, } from "../types";
1
+ export type { FlowIdProviderProps, FlowWidgetProps, ProfileButtonProps, UseOpenProfileOptions, } from "../types";
2
2
  export { FlowWidget } from "./flow-widget";
3
3
  export { useFlow, useFlowId, useFlowState, useRoom, useRooms } from "./hooks";
4
+ export { useOpenProfile } from "./open-profile";
4
5
  export { ProfileButton } from "./profile-button";
5
6
  export { FlowIdProvider } from "./provider";
@@ -1,4 +1,5 @@
1
1
  export { FlowWidget } from "./flow-widget";
2
2
  export { useFlow, useFlowId, useFlowState, useRoom, useRooms } from "./hooks";
3
+ export { useOpenProfile } from "./open-profile";
3
4
  export { ProfileButton } from "./profile-button";
4
5
  export { FlowIdProvider } from "./provider";
@@ -0,0 +1,18 @@
1
+ import type { UseOpenProfileOptions } from "../types";
2
+ /**
3
+ * Returns a callback that opens another user's public Flow profile in the
4
+ * shared overlay — the React face of `openProfile`, resolving the Flow instance
5
+ * from a `<FlowIdProvider>` ancestor or the createFlow() singleton so callers
6
+ * don't thread it through. The callback returns false for a username that isn't
7
+ * shaped like a Flow handle.
8
+ *
9
+ * There is deliberately nothing to clean up on unmount: the overlay belongs to
10
+ * the page, not to the component that first opened it, so every name in a chat
11
+ * log or a scoreboard shares one iframe and one dialog.
12
+ *
13
+ * ```tsx
14
+ * const openProfile = useOpenProfile({ theme: "dark" });
15
+ * return <button onClick={() => openProfile(player.username)}>@{player.username}</button>;
16
+ * ```
17
+ */
18
+ export declare function useOpenProfile(options?: UseOpenProfileOptions): (username: string) => boolean;
@@ -0,0 +1,24 @@
1
+ import { useCallback } from "react";
2
+ import { openProfile } from "../client/open-profile";
3
+ import { useFlow } from "./hooks";
4
+ /**
5
+ * Returns a callback that opens another user's public Flow profile in the
6
+ * shared overlay — the React face of `openProfile`, resolving the Flow instance
7
+ * from a `<FlowIdProvider>` ancestor or the createFlow() singleton so callers
8
+ * don't thread it through. The callback returns false for a username that isn't
9
+ * shaped like a Flow handle.
10
+ *
11
+ * There is deliberately nothing to clean up on unmount: the overlay belongs to
12
+ * the page, not to the component that first opened it, so every name in a chat
13
+ * log or a scoreboard shares one iframe and one dialog.
14
+ *
15
+ * ```tsx
16
+ * const openProfile = useOpenProfile({ theme: "dark" });
17
+ * return <button onClick={() => openProfile(player.username)}>@{player.username}</button>;
18
+ * ```
19
+ */
20
+ export function useOpenProfile(options = {}) {
21
+ const flow = useFlow();
22
+ const { host, theme } = options;
23
+ return useCallback((username) => openProfile(username, { flow, host, theme }), [flow, host, theme]);
24
+ }
@@ -25,7 +25,34 @@ export interface SessionUser {
25
25
  isGuest?: boolean;
26
26
  role?: string | null;
27
27
  image?: string | null;
28
+ bio?: string | null;
28
29
  }
30
+ /** Why a submitted bio was refused: it carried control or bidi characters, or
31
+ * it was longer than `MAX_BIO_LENGTH` once normalized. */
32
+ export type BioRejection = "unsafe_characters" | "too_long";
33
+ /**
34
+ * The result of normalizing a submitted bio. On success `bio` is the value to
35
+ * store — `null` when the input normalized to nothing, which clears the bio.
36
+ */
37
+ export type BioNormalization = {
38
+ ok: true;
39
+ bio: string | null;
40
+ } | {
41
+ ok: false;
42
+ reason: BioRejection;
43
+ };
44
+ /** Why a bio write was refused: the caller is a guest, or the text itself was
45
+ * refused. A bio belongs to a full account, the same rule the avatar upload
46
+ * applies. */
47
+ export type BioWriteRefusal = BioRejection | "guest";
48
+ /** The result of a bio write. `bio: null` means the bio was cleared. */
49
+ export type BioWriteResult = {
50
+ ok: true;
51
+ bio: string | null;
52
+ } | {
53
+ ok: false;
54
+ reason: BioWriteRefusal;
55
+ };
29
56
  /**
30
57
  * A session minted for one of the ADDITIONAL audiences a login requested
31
58
  * (AUTH-39): its own refresh-token lineage and access JWT, both bound to
@@ -36,7 +36,7 @@ export type EquipRegion = "whole_head" | "hat" | "hair" | "face" | "glasses" | "
36
36
  * makes the same mistake a typecheck error — which is the whole point of
37
37
  * mirroring the names into one constant. AUTH-210 is what happens without it:
38
38
  * the catalog was written from a nine-region proposal in
39
- * `docs/internal/cosmetics/bodygroups.md` section 6, the fifteen-region cut
39
+ * the GAME page "design: cosmetic bodygroups" section 6, the fifteen-region cut
40
40
  * that actually shipped shared not one name with it, and every cosmetic
41
41
  * silently suppressed nothing.
42
42
  */
@@ -27,7 +27,7 @@
27
27
  * makes the same mistake a typecheck error — which is the whole point of
28
28
  * mirroring the names into one constant. AUTH-210 is what happens without it:
29
29
  * the catalog was written from a nine-region proposal in
30
- * `docs/internal/cosmetics/bodygroups.md` section 6, the fifteen-region cut
30
+ * the GAME page "design: cosmetic bodygroups" section 6, the fifteen-region cut
31
31
  * that actually shipped shared not one name with it, and every cosmetic
32
32
  * silently suppressed nothing.
33
33
  */
@@ -52,12 +52,13 @@ export type DialogCustomLabels = {
52
52
  };
53
53
  /**
54
54
  * The profile fields the widget renders, read from `/api/me` (the cookie
55
- * session's `session.user`). `image`/`createdAt` are not on the SDK's
55
+ * session's `session.user`). `image`/`bio`/`createdAt` are not on the SDK's
56
56
  * `FlowUser`, so they only come from this authenticated fetch.
57
57
  */
58
58
  export type ProfileData = {
59
59
  username: string;
60
60
  image?: string | null;
61
+ bio?: string | null;
61
62
  createdAt?: string;
62
63
  isGuest?: boolean;
63
64
  };
@@ -9,8 +9,8 @@ export type AuthOutcome = "success" | "failure" | "info";
9
9
  export type AuthMode = "sign-up" | "sign-in";
10
10
  /** Client-only funnel steps reported via the `/api/events` beacon. */
11
11
  export type FunnelStep = "mode_selected" | "email_entered" | "ceremony_started" | "ceremony_failed" | "done_shown";
12
- 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.passkey.added" | "auth.passkey.add_failed" | "auth.restore.succeeded" | "auth.restore.failed" | "auth.restore.no_session" | "auth.refresh.succeeded" | "auth.refresh.failed" | "auth.refresh.reuse" | "auth.refresh.grace" | "auth.jwt.verified" | "auth.jwt.rejected" | "auth.signout" | "auth.session.revoked" | "auth.session.revoked_all" | "auth.audience.rejected" | "auth.audience.minted" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.role.granted" | "auth.role.revoked" | "room.restriction.issued" | "room.restriction.lifted" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.ceremony_failed" | "auth.funnel.done_shown" | "room_event.activated" | "room_event.completed" | "room_event.reminder_dispatched" | "room_event.reminder_failed";
13
- 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" | "refresh_token_invalid" | "refresh_token_expired" | "refresh_epoch_stale" | "refresh_user_epoch_stale" | "refresh_reuse_detected" | "guest_rate_limited" | "guest_global_limit" | "guest_username_exhausted" | "guest_session" | "passkey_exists" | "already_upgraded" | "malformed_token" | "unknown_key" | "verification_failed" | "webauthn_not_allowed" | "webauthn_security" | "webauthn_invalid_state" | "webauthn_not_supported" | "webauthn_constraint" | "webauthn_aborted" | "webauthn_unknown" | "internal_error";
12
+ 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.bio.update.succeeded" | "auth.bio.update.failed" | "auth.profile.view.failed" | "auth.profile.view.uncapped" | "auth.challenge.issued" | "auth.signin.succeeded" | "auth.signin.failed" | "auth.passkey.added" | "auth.passkey.add_failed" | "auth.restore.succeeded" | "auth.restore.failed" | "auth.restore.no_session" | "auth.refresh.succeeded" | "auth.refresh.failed" | "auth.refresh.reuse" | "auth.refresh.grace" | "auth.jwt.verified" | "auth.jwt.rejected" | "auth.signout" | "auth.session.revoked" | "auth.session.revoked_all" | "auth.audience.rejected" | "auth.audience.minted" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.role.granted" | "auth.role.revoked" | "room.restriction.issued" | "room.restriction.lifted" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.ceremony_failed" | "auth.funnel.done_shown" | "room_event.activated" | "room_event.completed" | "room_event.reminder_dispatched" | "room_event.reminder_failed";
13
+ 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" | "bio_forbidden" | "bio_invalid" | "bio_too_long" | "profile_rate_limited" | "profile_ip_missing" | "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" | "refresh_token_invalid" | "refresh_token_expired" | "refresh_epoch_stale" | "refresh_user_epoch_stale" | "refresh_reuse_detected" | "guest_rate_limited" | "guest_global_limit" | "guest_username_exhausted" | "guest_session" | "passkey_exists" | "already_upgraded" | "malformed_token" | "unknown_key" | "verification_failed" | "webauthn_not_allowed" | "webauthn_security" | "webauthn_invalid_state" | "webauthn_not_supported" | "webauthn_constraint" | "webauthn_aborted" | "webauthn_unknown" | "internal_error";
14
14
  /** One flat record per event = one row in the `auth_events` stream. */
15
15
  export interface AuthEventRecord {
16
16
  service: "auth";
@@ -1,4 +1,4 @@
1
- export type { AccountSession, AccountSessionsResponse, AdditionalSession, AuthConfig, AuthResponse, AuthResponseWithWebAuthn, FlowCredential, FlowUser, PasskeyPluginOptions, RevokeAllSessionsResponse, RevokeSessionResponse, SecurityActivityEntry, SecurityActivityResponse, SessionUser, VerifiedFlowJWT, VerifyOptions, WebAuthnSignature, } from "./auth";
1
+ export type { AccountSession, AccountSessionsResponse, AdditionalSession, AuthConfig, AuthResponse, AuthResponseWithWebAuthn, BioNormalization, BioRejection, BioWriteRefusal, BioWriteResult, FlowCredential, FlowUser, PasskeyPluginOptions, RevokeAllSessionsResponse, RevokeSessionResponse, SecurityActivityEntry, SecurityActivityResponse, SessionUser, VerifiedFlowJWT, VerifyOptions, WebAuthnSignature, } from "./auth";
2
2
  export type { BodyRegion, CosmeticItem, CosmeticMaterial, CosmeticPaint, CosmeticSlot, EquipConflict, EquippedCosmetic, EquippedCosmetics, EquipRegion, PaintSlot, SettingsConflict, } from "./cosmetics";
3
3
  export { BODY_REGIONS, COSMETIC_MATERIALS } from "./cosmetics";
4
4
  export type { BoundaryError, DialogCustomFeatures, DialogCustomLabels, DialogError, DialogReferrer, DialogState, Loadable, ProfileData, ProfileIdentity, } from "./dialog";
@@ -10,7 +10,7 @@ export type { Call, ConnectCapabilities, ConnectRequest, ConnectResponse, GuestR
10
10
  export { getConnectCapabilities, isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
11
11
  export type { CreateRoomEventInput, RoomEventArchive, RoomEventAttendance, RoomEventFrequency, RoomEventInstance, RoomEventInterest, RoomEventInterestState, RoomEventList, RoomEventOccurrence, RoomEventRecurrence, RoomEventRoomRef, RoomEventRun, RoomEventSeries, RoomEventStatus, SetInterestInput, UpdateRoomEventInput, } from "./room-events";
12
12
  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";
13
- export type { AccessKeyOptions, AccessKeyPreparation, Address, ColorScheme, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, FlowState, FlowWidgetHandle, FlowWidgetName, FlowWidgetProps, Listener, LoginOptions, MountFlowWidgetOptions, MountProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
13
+ export type { AccessKeyOptions, AccessKeyPreparation, Address, ColorScheme, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, FlowState, FlowWidgetHandle, FlowWidgetName, FlowWidgetProps, Listener, LoginOptions, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, UseOpenProfileOptions, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
14
14
  export type { GracefulShutdownCleanup, GracefulShutdownOptions, GracefulShutdownServer, GracefulShutdownSignal, ResolvedFlowSession, ResolveSessionOptions, SessionRouteOptions, SessionRouteResponse, SessionRouteSession, } from "./server";
15
15
  export type { CoinAsset, IdentifiedTx, TxApprove, TxConvert, TxSend, TxSwap, } from "./tx";
16
- export type { ActionDayContext, ActionEventKind, ActionSessionState, ActionTransitionKind, ActiveActionResponse, LevelProgress, PublicProfile, XpGrantResult, XpRecentGrant, XpSummary, } from "./xp";
16
+ export type { ActionDayContext, ActionEventKind, ActionSessionState, ActionTransitionKind, ActiveActionResponse, LevelProgress, PublicProfile, PublicProfileRefusal, PublicProfileResult, PublicProfileSignal, PublicUserProfile, XpGrantResult, XpRecentGrant, XpSummary, } from "./xp";
@@ -84,6 +84,9 @@ export type Schema = [
84
84
  height: number;
85
85
  } | {
86
86
  type: "profile-expand";
87
+ } | {
88
+ type: "profile-view";
89
+ username: string;
87
90
  } | {
88
91
  type: "profile-identity";
89
92
  identity: {
@@ -229,6 +229,32 @@ export type ProfileButtonHandle = {
229
229
  /** Tears down the iframe, listeners, and any auto-created container. */
230
230
  destroy: () => void;
231
231
  };
232
+ export type OpenProfileOptions = {
233
+ /**
234
+ * The Flow ID origin serving the overlay (e.g. https://id.flow.industries).
235
+ * Defaults to the resolved Flow instance's `host`, then the SDK default.
236
+ * Read once, when the overlay is first created — every later open reuses it.
237
+ */
238
+ host?: string;
239
+ /**
240
+ * Color scheme for the dialog chrome. Set this to match the host (the dark
241
+ * game UI passes "dark"). Applied on every open, so a host with a theme
242
+ * toggle needs no separate call. Defaults to "light dark" (follows the OS).
243
+ */
244
+ theme?: ColorScheme;
245
+ /**
246
+ * Explicit Flow instance, used to answer the dialog's token requests and to
247
+ * tell it who is looking. Defaults to the createFlow() singleton, and like
248
+ * `host` is read once, when the overlay is first created.
249
+ */
250
+ flow?: Flow;
251
+ };
252
+ export type UseOpenProfileOptions = {
253
+ /** Override the Flow ID origin; defaults to the resolved Flow's `host`. */
254
+ host?: string;
255
+ /** Color scheme for the dialog chrome; defaults to "light dark" (the OS). */
256
+ theme?: ColorScheme;
257
+ };
232
258
  /** The inline Flow ID widgets an embedder can mount alongside its own UI. */
233
259
  export type FlowWidgetName = "xp" | "action-timer";
234
260
  export type MountFlowWidgetOptions = {
@@ -71,3 +71,43 @@ export interface PublicProfile {
71
71
  tier: number;
72
72
  level: number;
73
73
  }
74
+ /** Everything `GET /api/users/:username` shows about a user: the identity
75
+ * fields anyone may see plus the same XP standing the nametag carries.
76
+ * `flowScore` is public by decision, so a future leaderboard row can link this
77
+ * endpoint rather than adding a second one. Deliberately no email, wallet
78
+ * address, role, session state or `updatedAt` — a change here is a change to
79
+ * what Flow publishes about a person. camelCase, unlike the snake_case
80
+ * `PublicProfile` nested in the game-server verify payload: this one is read by
81
+ * web clients, where every other Flow payload is camelCase. */
82
+ export interface PublicUserProfile {
83
+ username: string;
84
+ image: string | null;
85
+ bio: string | null;
86
+ level: number;
87
+ tier: number;
88
+ flowScore: number;
89
+ /** UTC calendar date the account was created, `YYYY-MM-DD`, rendered as
90
+ * "Joined ...". Deliberately not a full timestamp: no consumer shows the
91
+ * clock time, and a public by-name payload should carry no more of one than
92
+ * it displays. */
93
+ createdAt: string;
94
+ }
95
+ /** Why a public profile read was refused. `not_found` covers both an unknown
96
+ * username and a guest row — a guest is a placeholder, not a person, and the
97
+ * two are deliberately indistinguishable to the caller. */
98
+ export type PublicProfileRefusal = "invalid_username" | "not_found" | "rate_limited";
99
+ /** Whether a read did something its route should emit an event for, resolved
100
+ * against the caller's quota window so a flood produces one row rather than
101
+ * one per request. `cap_crossed` marks the request that spent the last slot;
102
+ * `ip_missing` marks the first header-less caller in a window, which is a
103
+ * deployment fault worth seeing and is served uncapped. */
104
+ export type PublicProfileSignal = "none" | "cap_crossed" | "ip_missing";
105
+ export type PublicProfileResult = {
106
+ ok: true;
107
+ profile: PublicUserProfile;
108
+ signal: PublicProfileSignal;
109
+ } | {
110
+ ok: false;
111
+ reason: PublicProfileRefusal;
112
+ signal: PublicProfileSignal;
113
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared adjective/noun wordlists and the playful `adjective_noun####`
3
+ * generator. Single source of truth for both the server (collision-safe guest
4
+ * account creation) and the dialog (the sign-up username placeholder), so the
5
+ * two can never drift. The wordlists are chosen so the longest combination
6
+ * stays within the 20-char username limit.
7
+ */
8
+ export declare const funnyAdjectives: string[];
9
+ export declare const funnyNouns: string[];
10
+ /** Single source of truth for the username constraints, shared by the
11
+ * generator and the server-side validators (register zod, availability check). */
12
+ export declare const MAX_USERNAME_LENGTH = 20;
13
+ export declare const USERNAME_REGEX: RegExp;
14
+ /**
15
+ * Whether a string can address a Flow account at all — the constraints above
16
+ * applied together. This is the check to spend before a lookup, because it is
17
+ * the same one `/api/users/:username` applies before it touches the database:
18
+ * anything it rejects can only ever come back as a refusal, so a caller that
19
+ * runs it first never opens a view onto a guaranteed 400.
20
+ */
21
+ export declare function isValidUsername(name: string): boolean;
22
+ /**
23
+ * A playful `adjective_noun####` handle. Matches `USERNAME_REGEX` and is capped
24
+ * to `MAX_USERNAME_LENGTH`. NOT collision-checked — callers needing uniqueness
25
+ * (guest creation) retry against the username unique index.
26
+ */
27
+ export declare function randomGuestUsername(): string;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Shared adjective/noun wordlists and the playful `adjective_noun####`
3
+ * generator. Single source of truth for both the server (collision-safe guest
4
+ * account creation) and the dialog (the sign-up username placeholder), so the
5
+ * two can never drift. The wordlists are chosen so the longest combination
6
+ * stays within the 20-char username limit.
7
+ */
8
+ export const funnyAdjectives = [
9
+ "cool",
10
+ "epic",
11
+ "lazy",
12
+ "sneaky",
13
+ "brave",
14
+ "chill",
15
+ "wild",
16
+ "tiny",
17
+ "mega",
18
+ "turbo",
19
+ "hyper",
20
+ "cozy",
21
+ "swift",
22
+ "bold",
23
+ "dark",
24
+ "lost",
25
+ "lucky",
26
+ "dizzy",
27
+ "fuzzy",
28
+ "shiny",
29
+ "sleepy",
30
+ "spicy",
31
+ "crispy",
32
+ "flowy",
33
+ "donkey",
34
+ ];
35
+ export const funnyNouns = [
36
+ "goku",
37
+ "naruto",
38
+ "pikachu",
39
+ "saitama",
40
+ "kirby",
41
+ "zelda",
42
+ "mario",
43
+ "sonic",
44
+ "yoshi",
45
+ "toad",
46
+ "kakashi",
47
+ "luffy",
48
+ "gojo",
49
+ "todoroki",
50
+ "midoriya",
51
+ "vegeta",
52
+ "jotaro",
53
+ "tanjiro",
54
+ "link",
55
+ "kratos",
56
+ "sora",
57
+ "roxas",
58
+ "cloud",
59
+ "sephiroth",
60
+ "megaman",
61
+ "waluigi",
62
+ "bowser",
63
+ "ganon",
64
+ "kong",
65
+ "king",
66
+ ];
67
+ /** Single source of truth for the username constraints, shared by the
68
+ * generator and the server-side validators (register zod, availability check). */
69
+ export const MAX_USERNAME_LENGTH = 20;
70
+ export const USERNAME_REGEX = /^[a-z0-9_]+$/;
71
+ /**
72
+ * Whether a string can address a Flow account at all — the constraints above
73
+ * applied together. This is the check to spend before a lookup, because it is
74
+ * the same one `/api/users/:username` applies before it touches the database:
75
+ * anything it rejects can only ever come back as a refusal, so a caller that
76
+ * runs it first never opens a view onto a guaranteed 400.
77
+ */
78
+ export function isValidUsername(name) {
79
+ // Typed as a string but reached from untyped JavaScript: the SDK hands this
80
+ // whatever a chat payload or a nametag produced, where a missing name is an
81
+ // ordinary outcome. Answering "no" is the contract; throwing is not.
82
+ //
83
+ // Established structurally, and deliberately BEFORE the charset test, because
84
+ // coercion would answer yes: `String(null)` is "null" and `String(undefined)`
85
+ // is "undefined", both of which the charset accepts as perfectly good
86
+ // usernames. A missing name would open a stranger's profile.
87
+ if (Object.prototype.toString.call(name) !== "[object String]")
88
+ return false;
89
+ return name.length <= MAX_USERNAME_LENGTH && USERNAME_REGEX.test(name);
90
+ }
91
+ /**
92
+ * A playful `adjective_noun####` handle. Matches `USERNAME_REGEX` and is capped
93
+ * to `MAX_USERNAME_LENGTH`. NOT collision-checked — callers needing uniqueness
94
+ * (guest creation) retry against the username unique index.
95
+ */
96
+ export function randomGuestUsername() {
97
+ const adj = funnyAdjectives[Math.floor(Math.random() * funnyAdjectives.length)];
98
+ const noun = funnyNouns[Math.floor(Math.random() * funnyNouns.length)];
99
+ const num = Math.floor(Math.random() * 9999) + 1;
100
+ return `${adj}_${noun}${num}`.slice(0, MAX_USERNAME_LENGTH);
101
+ }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.19.1",
3
+ "version": "0.19.3",
4
+ "license": "MIT",
4
5
  "repository": {
5
6
  "type": "git",
6
7
  "url": "git+https://github.com/flow-industries/auth.git"