@flow-industries/id 0.21.0 → 0.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,38 @@ Full documentation at **[docs.flow.industries/en/auth](https://docs.flow.industr
47
47
  - [JWT verify](https://docs.flow.industries/en/auth/jwt-verify) - verify Flow sessions on your backend
48
48
  - [API reference](https://docs.flow.industries/en/auth/api) and [self-hosting](https://docs.flow.industries/en/auth/self-hosting)
49
49
 
50
+ ## Public profile overlays
51
+
52
+ ```ts
53
+ import { closeProfile, openProfile } from "@flow-industries/id";
54
+ import { isValidUsername } from "@flow-industries/id/usernames";
55
+
56
+ if (isValidUsername(username)) {
57
+ openProfile(username, { onClose: () => resumeInput() });
58
+ }
59
+
60
+ closeProfile();
61
+ ```
62
+
63
+ `@flow-industries/id/usernames` also exports `USERNAME_REGEX` and
64
+ `MAX_USERNAME_LENGTH`. It has no browser or server dependencies.
65
+
66
+ `OpenProfileOptions.onClose?: () => void` fires once after the overlay stops
67
+ accepting input, before its exit animation finishes. It covers the close button,
68
+ backdrop, Escape, `closeProfile()`, and sign-in/sign-out handoffs. The latest
69
+ successful `openProfile` call owns the callback, even when switching profiles
70
+ while open; an omitted callback clears the previous one. Invalid usernames and
71
+ SSR return `false` without replacing it. Closing an already closed overlay is a
72
+ no-op. A callback may open another profile.
73
+
74
+ React callers pass the same option to
75
+ `useOpenProfile({ onClose: () => resumeInput() })` from
76
+ `@flow-industries/id/react`; the callback is captured when its returned opener
77
+ is called. The overlay belongs to the page and survives component unmounts.
78
+
79
+ Open from the document that owns the full viewport. Canvas hosts must release
80
+ pointer lock before opening, then use `onClose` to restore their input state.
81
+
50
82
  ## License
51
83
 
52
84
  MIT
@@ -362,6 +362,7 @@ export function createFlow(options = {}) {
362
362
  */
363
363
  async function login(loginOpts = {}) {
364
364
  const dialogHost = getDialog();
365
+ dialogHost.captureFocus();
365
366
  let accessKeyModule;
366
367
  let accessKeyPrep;
367
368
  let extraCapabilities;
@@ -1,4 +1,5 @@
1
1
  import { bridgeToWindow, makeIframe } from "./iframe-host";
2
+ import { setOverlayFocus } from "./overlay-focus";
2
3
  const HIDDEN_STYLE = {
3
4
  position: "fixed",
4
5
  inset: "0",
@@ -35,6 +36,12 @@ export function createDialogHost(options) {
35
36
  const { host, container = document.body } = options;
36
37
  let iframe = null;
37
38
  let messenger = null;
39
+ let opener = null;
40
+ function captureFocus() {
41
+ const active = document.activeElement;
42
+ if (active instanceof HTMLElement && active !== iframe)
43
+ opener = active;
44
+ }
38
45
  const pending = new Map();
39
46
  /**
40
47
  * Mounts the iframe and wires up the postMessage bridge. Idempotent — safe
@@ -43,8 +50,9 @@ export function createDialogHost(options) {
43
50
  function ensureFrame() {
44
51
  if (iframe)
45
52
  return;
46
- iframe = makeIframe(`${host}`);
53
+ iframe = makeIframe(`${host}`, "Flow ID");
47
54
  iframe.dataset.flowId = "";
55
+ iframe.inert = true;
48
56
  Object.assign(iframe.style, HIDDEN_STYLE);
49
57
  container.appendChild(iframe);
50
58
  // Bind the bridge to this iframe's own window — a page may host more than
@@ -109,6 +117,8 @@ export function createDialogHost(options) {
109
117
  if (!iframe)
110
118
  return;
111
119
  Object.assign(iframe.style, VISIBLE_STYLE);
120
+ setOverlayFocus(iframe, true, opener);
121
+ opener = null;
112
122
  // The dialog owns its open/close animation and mounts the overlay (playing
113
123
  // the enter animation) on this signal — same mechanism as the profile
114
124
  // widget, so every dialog appears and disappears identically.
@@ -124,6 +134,7 @@ export function createDialogHost(options) {
124
134
  // requestAnimationFrame, making the close (and the next open) skip straight
125
135
  // to the end. While hidden the overlay is transparent and click-through.
126
136
  iframe.style.pointerEvents = "none";
137
+ setOverlayFocus(iframe, false);
127
138
  void (
128
139
  // SAFETY: the payload map types this topic; the dialog reads exactly these fields.
129
140
  messenger?.send("__internal", { type: "dialog-hidden" }));
@@ -190,6 +201,7 @@ export function createDialogHost(options) {
190
201
  return dispatchRequest(method, params);
191
202
  }
192
203
  return {
204
+ captureFocus,
193
205
  open,
194
206
  close,
195
207
  destroy,
@@ -11,6 +11,11 @@ const WIDGET_ROUTE = {
11
11
  xp: "/dialog/xp-widget",
12
12
  "action-timer": "/dialog/action-timer",
13
13
  };
14
+ // `satisfies` the full widget union so a new widget cannot ship untitled.
15
+ const WIDGET_TITLE = {
16
+ xp: "Flow ID XP",
17
+ "action-timer": "Flow ID action timer",
18
+ };
14
19
  /**
15
20
  * Mounts one of the inline Flow ID widgets — the XP strip, the action timer —
16
21
  * as a transparent iframe the embedder positions and sizes itself.
@@ -42,7 +47,7 @@ export function createFlowWidget(options) {
42
47
  const hostOrigin = new URL(host).origin;
43
48
  let theme = options.theme ?? "light dark";
44
49
  const route = WIDGET_ROUTE[options.widget];
45
- const frame = makeIframe(`${host}${route}`);
50
+ const frame = makeIframe(`${host}${route}`, WIDGET_TITLE[options.widget]);
46
51
  frame.style.background = "transparent";
47
52
  const container = options.container ?? document.body;
48
53
  // Appended before bridging: `contentWindow` is null until the frame is in the
@@ -16,10 +16,12 @@ export declare const OVERLAY_STYLE: Partial<CSSStyleDeclaration>;
16
16
  /**
17
17
  * Creates a Flow dialog iframe element: WebAuthn-permitted, borderless, and
18
18
  * `color-scheme: normal` so the iframe itself stays transparent (the rendered
19
- * theme is applied to nested card wrappers, not the iframe). The caller owns
20
- * positioning/visibility and any `data-*` marker.
19
+ * theme is applied to nested card wrappers, not the iframe). `title` is the
20
+ * accessible name screen readers announce for the frame (WCAG 4.1.2), so every
21
+ * Flow surface must say what it is. The caller owns positioning/visibility and
22
+ * any `data-*` marker.
21
23
  */
22
- export declare function makeIframe(src: string): HTMLIFrameElement;
24
+ export declare function makeIframe(src: string, title: string): HTMLIFrameElement;
23
25
  /**
24
26
  * Builds a postMessage bridge to a child window (iframe `contentWindow` or
25
27
  * popup). Inbound is filtered by `source` so multiple same-origin Flow frames
@@ -21,12 +21,15 @@ export const OVERLAY_STYLE = {
21
21
  /**
22
22
  * Creates a Flow dialog iframe element: WebAuthn-permitted, borderless, and
23
23
  * `color-scheme: normal` so the iframe itself stays transparent (the rendered
24
- * theme is applied to nested card wrappers, not the iframe). The caller owns
25
- * positioning/visibility and any `data-*` marker.
24
+ * theme is applied to nested card wrappers, not the iframe). `title` is the
25
+ * accessible name screen readers announce for the frame (WCAG 4.1.2), so every
26
+ * Flow surface must say what it is. The caller owns positioning/visibility and
27
+ * any `data-*` marker.
26
28
  */
27
- export function makeIframe(src) {
29
+ export function makeIframe(src, title) {
28
30
  const frame = document.createElement("iframe");
29
31
  frame.src = src;
32
+ frame.title = title;
30
33
  frame.allow = IFRAME_ALLOW;
31
34
  frame.style.border = "none";
32
35
  frame.style.colorScheme = "normal";
@@ -2,6 +2,7 @@ import { resolveIdHost } from "../id-host";
2
2
  import { isValidUsername } from "../usernames";
3
3
  import { getFlow, requireFlow } from "./create-flow";
4
4
  import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, } from "./iframe-host";
5
+ import { setOverlayFocus } from "./overlay-focus";
5
6
  // One overlay per page, kept for the page's lifetime. The alternative — a
6
7
  // handle per call site — would put a second fullscreen iframe over the host the
7
8
  // moment two parts of an app (a scoreboard and a chat line) both showed a
@@ -21,13 +22,17 @@ function createViewer(options) {
21
22
  // meant for the page underneath with nothing on screen to explain it.
22
23
  let ready = false;
23
24
  let visible = false;
25
+ let onClose;
24
26
  function applyHitTesting() {
25
- if (frame)
26
- frame.style.pointerEvents = ready && visible ? "auto" : "none";
27
+ if (!frame)
28
+ return;
29
+ frame.style.pointerEvents = ready && visible ? "auto" : "none";
30
+ setOverlayFocus(frame, ready && visible);
27
31
  }
28
32
  function mount(username) {
29
- const el = makeIframe(`${host}/dialog/`);
33
+ const el = makeIframe(`${host}/dialog/`, "Flow ID profile");
30
34
  el.dataset.flowProfileView = "";
35
+ el.inert = true;
31
36
  Object.assign(el.style, OVERLAY_STYLE, { pointerEvents: "none" });
32
37
  // Appended before bridging: `contentWindow` is null until the frame is in
33
38
  // the document, and the bridge is bound to that window.
@@ -78,7 +83,7 @@ function createViewer(options) {
78
83
  });
79
84
  return opened;
80
85
  }
81
- function open(username) {
86
+ function open(username, nextOnClose) {
82
87
  const mounted = bridge;
83
88
  const live = mounted ?? mount(username);
84
89
  // A frame created just now is already on this profile; an existing one is
@@ -93,6 +98,8 @@ function createViewer(options) {
93
98
  type: "profile-identity",
94
99
  identity: hostIdentity(flow),
95
100
  });
101
+ window.addEventListener("keydown", onKeyDown);
102
+ onClose = nextOnClose;
96
103
  visible = true;
97
104
  applyHitTesting();
98
105
  void live.send("__internal", { type: "dialog-shown" });
@@ -104,11 +111,21 @@ function createViewer(options) {
104
111
  // always-rendered, transparent and click-through while hidden keeps every
105
112
  // open animation reliable, on the second open and the tenth.
106
113
  function close() {
107
- if (!frame || !bridge)
114
+ if (!visible || !frame || !bridge)
108
115
  return;
109
116
  visible = false;
117
+ window.removeEventListener("keydown", onKeyDown);
110
118
  applyHitTesting();
111
119
  void bridge.send("__internal", { type: "dialog-hidden" });
120
+ const notifyClose = onClose;
121
+ onClose = undefined;
122
+ notifyClose?.();
123
+ }
124
+ function onKeyDown(event) {
125
+ if (event.key !== "Escape" || event.defaultPrevented)
126
+ return;
127
+ event.preventDefault();
128
+ close();
112
129
  }
113
130
  function setTheme(next) {
114
131
  if (next === theme)
@@ -163,7 +180,7 @@ export function openProfile(username, options = {}) {
163
180
  viewer = live;
164
181
  if (options.theme)
165
182
  live.setTheme(options.theme);
166
- live.open(username);
183
+ live.open(username, options.onClose);
167
184
  return true;
168
185
  }
169
186
  /**
@@ -0,0 +1 @@
1
+ export declare function setOverlayFocus(frame: HTMLIFrameElement, visible: boolean, opener?: HTMLElement | null): void;
@@ -0,0 +1,24 @@
1
+ const triggers = new WeakMap();
2
+ export function setOverlayFocus(frame, visible, opener) {
3
+ if (visible) {
4
+ if (triggers.has(frame))
5
+ return;
6
+ const active = opener ?? document.activeElement;
7
+ triggers.set(frame, active instanceof HTMLElement && active !== frame ? active : null);
8
+ frame.inert = false;
9
+ frame.focus();
10
+ return;
11
+ }
12
+ const restore = document.activeElement === frame;
13
+ frame.inert = true;
14
+ const trigger = triggers.get(frame);
15
+ triggers.delete(frame);
16
+ if (restore && trigger?.isConnected) {
17
+ requestAnimationFrame(() => {
18
+ if (trigger.isConnected &&
19
+ (document.activeElement === frame ||
20
+ document.activeElement === document.body))
21
+ trigger.focus();
22
+ });
23
+ }
24
+ }
@@ -1,6 +1,7 @@
1
1
  import { resolveIdHost } from "../id-host";
2
2
  import { getFlow, requireFlow } from "./create-flow";
3
3
  import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, } from "./iframe-host";
4
+ import { setOverlayFocus } from "./overlay-focus";
4
5
  const POSITION_STYLE = {
5
6
  "top-right": { top: "0", right: "0" },
6
7
  "top-left": { top: "0", left: "0" },
@@ -46,8 +47,8 @@ export function createProfileButton(options) {
46
47
  createdContainer = el;
47
48
  container = el;
48
49
  }
49
- function makeFrame() {
50
- const frame = makeIframe(`${host}/dialog/`);
50
+ function makeFrame(title) {
51
+ const frame = makeIframe(`${host}/dialog/`, title);
51
52
  frame.dataset.flowProfile = "";
52
53
  frame.style.display = "block";
53
54
  return frame;
@@ -71,7 +72,7 @@ export function createProfileButton(options) {
71
72
  return bridge;
72
73
  }
73
74
  // ----- the persistent pill (stays mounted; sizes to its own content) -----
74
- const pill = makeFrame();
75
+ const pill = makeFrame("Flow ID profile button");
75
76
  let pillWidth = 0;
76
77
  let pillHeight = 0;
77
78
  function applyPillSize() {
@@ -105,7 +106,8 @@ export function createProfileButton(options) {
105
106
  function ensureDialog() {
106
107
  if (dialog)
107
108
  return;
108
- dialog = makeFrame();
109
+ dialog = makeFrame("Flow ID profile");
110
+ dialog.inert = true;
109
111
  Object.assign(dialog.style, OVERLAY_STYLE, {
110
112
  pointerEvents: "none",
111
113
  });
@@ -144,12 +146,14 @@ export function createProfileButton(options) {
144
146
  if (!dialog)
145
147
  return;
146
148
  dialog.style.pointerEvents = "auto";
149
+ setOverlayFocus(dialog, true);
147
150
  void dialogBridge?.send("__internal", { type: "dialog-shown" });
148
151
  }
149
152
  function hideDialog() {
150
153
  if (!dialog)
151
154
  return;
152
155
  dialog.style.pointerEvents = "none";
156
+ setOverlayFocus(dialog, false);
153
157
  void dialogBridge?.send("__internal", { type: "dialog-hidden" });
154
158
  }
155
159
  // ----- keep both iframes' identity in sync with the Flow session -----
@@ -186,6 +190,7 @@ export function createProfileButton(options) {
186
190
  });
187
191
  },
188
192
  destroy() {
193
+ hideDialog();
189
194
  unsubscribe();
190
195
  pillBridge.destroy();
191
196
  dialogBridge?.destroy();
@@ -19,6 +19,6 @@ import { useFlow } from "./hooks";
19
19
  */
20
20
  export function useOpenProfile(options = {}) {
21
21
  const flow = useFlow();
22
- const { host, theme } = options;
23
- return useCallback((username) => openProfile(username, { flow, host, theme }), [flow, host, theme]);
22
+ const { host, theme, onClose } = options;
23
+ return useCallback((username) => openProfile(username, { flow, host, theme, onClose }), [flow, host, theme, onClose]);
24
24
  }
@@ -0,0 +1,269 @@
1
+ import type { RawRecord, RawValue } from "../json";
2
+ import type { AccountSession, SecurityActivityEntry } from "./auth";
3
+ import type { RoomEventRecurrence } from "./room-events";
4
+ /**
5
+ * Self-serve account deletion (AUTH-246), the personal-data export (AUTH-247)
6
+ * and the erasure ledger downstream services replay (AUTH-119).
7
+ */
8
+ /** Body of `POST /api/account/delete`: the caller's current username, typed
9
+ * back as the confirmation. */
10
+ export type AccountDeleteRequest = {
11
+ confirm: string;
12
+ };
13
+ export type AccountDeleteResponse = {
14
+ ok: true;
15
+ };
16
+ /** What one account deletion removed, beyond the user row's own cascade. */
17
+ export type AccountErasureCounts = {
18
+ /** Non-system rooms the account owned, deleted with everything inside them. */
19
+ ownedRoomsDeleted: number;
20
+ /** Cookie sessions the cascade took down. */
21
+ sessionsDeleted: number;
22
+ /** Passkeys the cascade took down. */
23
+ passkeysDeleted: number;
24
+ /** OTP / quota rows keyed on the account's email or id. */
25
+ verificationsDeleted: number;
26
+ /** Per-user rate-limit rows (never the ip-keyed ones). */
27
+ rateLimitsDeleted: number;
28
+ };
29
+ /** Result of `deleteAccount`: `missing` is the idempotent second call. */
30
+ export type AccountErasureResult = {
31
+ outcome: "missing";
32
+ } | {
33
+ outcome: "deleted";
34
+ userId: string;
35
+ isGuest: boolean;
36
+ /** The stored avatar URL, for the post-commit object delete. */
37
+ image: string | null;
38
+ counts: AccountErasureCounts;
39
+ };
40
+ /** One row of the erasure ledger as `GET /api/account/erasures` serves it. */
41
+ export type AccountErasureEntry = {
42
+ seq: number;
43
+ /** The deleted account's Flow `user.id` — the JWT `sub` consumers keyed on. */
44
+ sub: string;
45
+ deletedRoomIds: string[];
46
+ erasedAt: string;
47
+ };
48
+ export type AccountErasureFeed = {
49
+ /** Ascending by `seq`. */
50
+ erasures: AccountErasureEntry[];
51
+ /** Feed this back as `after` for the next page; equals the request's
52
+ * `after` when the page was empty. */
53
+ nextAfter: number;
54
+ };
55
+ export type AccountExportAccount = {
56
+ id: string;
57
+ username: string;
58
+ name: string | null;
59
+ email: string | null;
60
+ emailVerified: boolean;
61
+ isGuest: boolean;
62
+ role: string;
63
+ image: string | null;
64
+ bio: string | null;
65
+ walletAddress: string | null;
66
+ subscribedToNews: boolean;
67
+ createdAt: string;
68
+ };
69
+ /** A passkey as the owner sees it: metadata only, never the public key,
70
+ * credential id or signature counter. */
71
+ export type AccountExportPasskey = {
72
+ id: string;
73
+ name: string | null;
74
+ deviceType: string | null;
75
+ backedUp: boolean | null;
76
+ transports: string | null;
77
+ aaguid: string | null;
78
+ createdAt: string;
79
+ };
80
+ export type AccountExportSettingsSurface = {
81
+ version: number;
82
+ settings: Record<string, RawValue>;
83
+ };
84
+ export type AccountExportXpSummary = {
85
+ totalXp: number;
86
+ flowScore: number;
87
+ lastVisitDate: string | null;
88
+ updatedAt: string;
89
+ };
90
+ export type AccountExportXpGrant = {
91
+ id: string;
92
+ source: string;
93
+ baseAmount: number;
94
+ flowScore: number;
95
+ amount: number;
96
+ metadata: RawRecord | null;
97
+ createdAt: string;
98
+ };
99
+ export type AccountExportActionEvent = {
100
+ id: string;
101
+ sessionId: string;
102
+ kind: string;
103
+ accruedSeconds: number;
104
+ at: string;
105
+ };
106
+ export type AccountExportRoomSettings = {
107
+ roomId: string;
108
+ surface: string;
109
+ settings: RawRecord;
110
+ version: number;
111
+ };
112
+ export type AccountExportActionSession = {
113
+ id: string;
114
+ source: string;
115
+ state: string;
116
+ subjectId: string | null;
117
+ accruedSeconds: number;
118
+ focusedSeconds: number;
119
+ xpGranted: number | null;
120
+ metadata: RawRecord | null;
121
+ startedAt: string;
122
+ updatedAt: string;
123
+ finishedAt: string | null;
124
+ };
125
+ export type AccountExportStudySubject = {
126
+ id: string;
127
+ field: string | null;
128
+ name: string;
129
+ archivedAt: string | null;
130
+ createdAt: string;
131
+ lastUsedAt: string;
132
+ };
133
+ export type AccountExportCharacterStateTime = {
134
+ day: string;
135
+ state: string;
136
+ seconds: number;
137
+ updatedAt: string;
138
+ };
139
+ export type AccountExportOwnedRoom = {
140
+ id: string;
141
+ slug: string;
142
+ displayName: string;
143
+ description: string | null;
144
+ visibility: string;
145
+ isSystem: boolean;
146
+ createdAt: string;
147
+ updatedAt: string;
148
+ };
149
+ export type AccountExportRoomMembership = {
150
+ roomId: string;
151
+ roomSlug: string;
152
+ role: string;
153
+ joinedAt: string;
154
+ };
155
+ /** A restriction placed on the caller. The issuing moderator is deliberately
156
+ * absent — the export carries only the caller's own identity. */
157
+ export type AccountExportRoomRestriction = {
158
+ id: string;
159
+ roomId: string;
160
+ kind: string;
161
+ reason: string | null;
162
+ expiresAt: string | null;
163
+ createdAt: string;
164
+ };
165
+ export type AccountExportRoomPresence = {
166
+ roomId: string;
167
+ channel: string;
168
+ joinedAt: string;
169
+ updatedAt: string;
170
+ };
171
+ export type AccountExportRoomPlayerState = {
172
+ roomId: string;
173
+ x: number;
174
+ y: number;
175
+ z: number;
176
+ yaw: number;
177
+ worldId: string;
178
+ updatedAt: string;
179
+ };
180
+ export type AccountExportRooms = {
181
+ settings: AccountExportRoomSettings[];
182
+ owned: AccountExportOwnedRoom[];
183
+ memberships: AccountExportRoomMembership[];
184
+ restrictions: AccountExportRoomRestriction[];
185
+ presence: AccountExportRoomPresence[];
186
+ playerState: AccountExportRoomPlayerState[];
187
+ };
188
+ export type AccountExportAuthoredEvent = {
189
+ id: string;
190
+ roomId: string;
191
+ title: string;
192
+ description: string | null;
193
+ coverUrl: string | null;
194
+ startsAt: string;
195
+ endsAt: string | null;
196
+ timezone: string;
197
+ recurrence: RoomEventRecurrence | null;
198
+ reminderOffsets: number[] | null;
199
+ status: string;
200
+ createdAt: string;
201
+ updatedAt: string;
202
+ };
203
+ export type AccountExportRsvp = {
204
+ occurrenceId: string;
205
+ createdAt: string;
206
+ };
207
+ export type AccountExportFollow = {
208
+ eventId: string;
209
+ createdAt: string;
210
+ };
211
+ export type AccountExportAttendance = {
212
+ occurrenceId: string;
213
+ firstSeenAt: string;
214
+ lastSeenAt: string;
215
+ };
216
+ export type AccountExportRoomEvents = {
217
+ authored: AccountExportAuthoredEvent[];
218
+ rsvps: AccountExportRsvp[];
219
+ follows: AccountExportFollow[];
220
+ attendance: AccountExportAttendance[];
221
+ };
222
+ export type AccountExportGameEvent = {
223
+ id: string;
224
+ event: string;
225
+ occurredAt: string;
226
+ recordedAt: string;
227
+ serverId: string;
228
+ sessionId: string | null;
229
+ roomId: string | null;
230
+ roomSlug: string | null;
231
+ reason: string | null;
232
+ detail: RawRecord | null;
233
+ };
234
+ /** Where the rest of a person's Flow data lives, so one export names every
235
+ * service that has to be asked. */
236
+ export type AccountExportOtherService = {
237
+ service: string;
238
+ export: string;
239
+ holds: string;
240
+ };
241
+ /** The whole personal-data export (`GET /api/account/export`), version 1. */
242
+ export type AccountExport = {
243
+ format: "flow-id-export";
244
+ version: 1;
245
+ exportedAt: string;
246
+ account: AccountExportAccount;
247
+ passkeys: AccountExportPasskey[];
248
+ sessions: AccountSession[];
249
+ settings: Record<string, AccountExportSettingsSurface>;
250
+ xp: {
251
+ summary: AccountExportXpSummary | null;
252
+ ledger: AccountExportXpGrant[];
253
+ };
254
+ actions: {
255
+ events: AccountExportActionEvent[];
256
+ sessions: AccountExportActionSession[];
257
+ };
258
+ study: {
259
+ subjects: AccountExportStudySubject[];
260
+ };
261
+ characterStateTime: AccountExportCharacterStateTime[];
262
+ rooms: AccountExportRooms;
263
+ roomEvents: AccountExportRoomEvents;
264
+ gameEvents: AccountExportGameEvent[];
265
+ securityActivity: SecurityActivityEntry[];
266
+ otherServices: AccountExportOtherService[];
267
+ /** Dotted names of every collection that hit the row cap (newest rows kept). */
268
+ truncated: string[];
269
+ };
File without changes
@@ -9,7 +9,7 @@ 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.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";
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" | "auth.account.deleted" | "auth.account.exported" | "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
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 {
@@ -47,6 +47,10 @@ export interface AuthEventRecord {
47
47
  occurrenceId: string | null;
48
48
  dispatchKind: string | null;
49
49
  recipients: number | null;
50
+ /** Account-lifecycle fields: whether the account was a guest, and what an
51
+ * erasure or export touched, as per-section row counts. */
52
+ guest: boolean | null;
53
+ counts: Record<string, number> | null;
50
54
  }
51
55
  /** Body accepted by `POST /api/events` from the dialog. */
52
56
  export interface BeaconBody {
@@ -1,3 +1,4 @@
1
+ export type { AccountDeleteRequest, AccountDeleteResponse, AccountErasureCounts, AccountErasureEntry, AccountErasureFeed, AccountErasureResult, AccountExport, AccountExportAccount, AccountExportActionEvent, AccountExportActionSession, AccountExportAttendance, AccountExportAuthoredEvent, AccountExportCharacterStateTime, AccountExportFollow, AccountExportGameEvent, AccountExportOtherService, AccountExportOwnedRoom, AccountExportPasskey, AccountExportRoomEvents, AccountExportRoomMembership, AccountExportRoomPlayerState, AccountExportRoomPresence, AccountExportRoomRestriction, AccountExportRoomSettings, AccountExportRooms, AccountExportRsvp, AccountExportSettingsSurface, AccountExportStudySubject, AccountExportXpGrant, AccountExportXpSummary, } from "./account-data";
1
2
  export type { ActionCurve, ActionEventRequest, ActionEventResponse, ActionsApi, ActiveActionState, FinishActionRequest, ListStudySessionsOptions, ListStudySubjectsOptions, StartActionInput, StartActionResult, StudyApi, StudySubjectResponse, UseActiveActionOptions, XpEstimateContext, } from "./actions";
2
3
  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";
3
4
  export type { BodyRegion, CosmeticItem, CosmeticMaterial, CosmeticPaint, CosmeticSlot, EquipConflict, EquippedCosmetic, EquippedCosmetics, EquipRegion, PaintSlot, SettingsConflict, } from "./cosmetics";
@@ -230,6 +230,14 @@ export type ProfileButtonHandle = {
230
230
  destroy: () => void;
231
231
  };
232
232
  export type OpenProfileOptions = {
233
+ /**
234
+ * Called once when the overlay closes, including its close button, backdrop,
235
+ * Escape, closeProfile(), or a sign-in/sign-out handoff. Runs after the
236
+ * overlay stops accepting input, without waiting for the exit animation.
237
+ * The latest successful open replaces this callback; omitting it clears the
238
+ * previous callback. Rejected opens and repeated closes do not notify.
239
+ */
240
+ onClose?: () => void;
233
241
  /**
234
242
  * The Flow ID origin serving the overlay (e.g. https://id.flow.industries).
235
243
  * Defaults to the resolved Flow instance's `host`, then the SDK default.
@@ -249,12 +257,7 @@ export type OpenProfileOptions = {
249
257
  */
250
258
  flow?: Flow;
251
259
  };
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
- };
260
+ export type UseOpenProfileOptions = Pick<OpenProfileOptions, "host" | "theme" | "onClose">;
258
261
  /** The inline Flow ID widgets an embedder can mount alongside its own UI. */
259
262
  export type FlowWidgetName = "xp" | "action-timer";
260
263
  export type MountFlowWidgetOptions = {
@@ -309,6 +312,7 @@ export type FlowWidgetProps = {
309
312
  theme?: ColorScheme;
310
313
  };
311
314
  export type DialogHost = {
315
+ captureFocus: () => void;
312
316
  open: (options?: DialogOpenOptions) => void;
313
317
  close: () => void;
314
318
  destroy: () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,6 +19,10 @@
19
19
  "types": "./dist/sdk/client/index.d.ts",
20
20
  "import": "./dist/sdk/client/index.js"
21
21
  },
22
+ "./usernames": {
23
+ "types": "./dist/sdk/usernames.d.ts",
24
+ "import": "./dist/sdk/usernames.js"
25
+ },
22
26
  "./react": {
23
27
  "types": "./dist/sdk/react/index.d.ts",
24
28
  "import": "./dist/sdk/react/index.js"
@@ -69,6 +73,7 @@
69
73
  "usernames:refresh": "bun run scripts/generate-reserved-usernames.ts",
70
74
  "lint": "biome check",
71
75
  "test": "bun test src",
76
+ "test:sdk-package": "bash scripts/test-sdk-package.sh",
72
77
  "format": "biome format --write",
73
78
  "check": "biome check --write",
74
79
  "typecheck": "tsr generate && tsc --noEmit"
@@ -121,7 +126,7 @@
121
126
  ],
122
127
  "dependencies": {
123
128
  "@aws-sdk/client-s3": "^3.1073.0",
124
- "@flow-industries/ui": "^0.19.0",
129
+ "@flow-industries/ui": "^0.21.0",
125
130
  "@hono/otel": "^1.1.2",
126
131
  "@openobserve/browser-logs": "^0.3.1",
127
132
  "@openobserve/browser-rum": "^0.3.1",