@flow-industries/id 0.16.0 → 0.18.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.
@@ -0,0 +1,20 @@
1
+ import type { FlowWidgetHandle, MountFlowWidgetOptions } from "../types";
2
+ /**
3
+ * Mounts one of the inline Flow ID widgets — the XP strip, the action timer —
4
+ * as a transparent iframe the embedder positions and sizes itself.
5
+ *
6
+ * The reason this exists rather than embedders hand-rolling an `<iframe>`: the
7
+ * widget reads the Flow ID API from a cross-site frame, where its own origin's
8
+ * cookie is unavailable or bound to a different audience, so it needs the
9
+ * host's access token. It gets one by asking over the postMessage bridge, and
10
+ * only a frame that received the `init` handshake knows which origin to ask.
11
+ * A hand-rolled iframe never sends `init` and never gets an answer, so it is
12
+ * silently limited to whatever the cookie happens to allow (AUTH-171).
13
+ *
14
+ * The handle exposes the frame instead of styling it, because each embedder
15
+ * wants different geometry (the in-game overlay is fixed and click-through,
16
+ * the menu's is a laid-out block) and none of that belongs in the SDK.
17
+ *
18
+ * No-op under SSR.
19
+ */
20
+ export declare function createFlowWidget(options: MountFlowWidgetOptions): FlowWidgetHandle;
@@ -0,0 +1,88 @@
1
+ import { resolveIdHost } from "../id-host";
2
+ import { getFlow, requireFlow } from "./create-flow";
3
+ import { answerTokenRequest, bridgeToWindow, makeIframe } from "./iframe-host";
4
+ /**
5
+ * The inline widgets an embedder may mount, mapped to the dialog routes that
6
+ * serve them. Callers name a widget rather than passing a route so the dialog's
7
+ * URL space stays an internal detail and an embedder can't point this at an
8
+ * auth route, which would render a sign-in ceremony inside a decorative frame.
9
+ */
10
+ const WIDGET_ROUTE = {
11
+ xp: "/dialog/xp-widget",
12
+ "action-timer": "/dialog/action-timer",
13
+ };
14
+ /**
15
+ * Mounts one of the inline Flow ID widgets — the XP strip, the action timer —
16
+ * as a transparent iframe the embedder positions and sizes itself.
17
+ *
18
+ * The reason this exists rather than embedders hand-rolling an `<iframe>`: the
19
+ * widget reads the Flow ID API from a cross-site frame, where its own origin's
20
+ * cookie is unavailable or bound to a different audience, so it needs the
21
+ * host's access token. It gets one by asking over the postMessage bridge, and
22
+ * only a frame that received the `init` handshake knows which origin to ask.
23
+ * A hand-rolled iframe never sends `init` and never gets an answer, so it is
24
+ * silently limited to whatever the cookie happens to allow (AUTH-171).
25
+ *
26
+ * The handle exposes the frame instead of styling it, because each embedder
27
+ * wants different geometry (the in-game overlay is fixed and click-through,
28
+ * the menu's is a laid-out block) and none of that belongs in the SDK.
29
+ *
30
+ * No-op under SSR.
31
+ */
32
+ export function createFlowWidget(options) {
33
+ if (typeof document === "undefined")
34
+ return {
35
+ frame: null,
36
+ post() { },
37
+ setTheme() { },
38
+ destroy() { },
39
+ };
40
+ const flow = options.flow ?? getFlow() ?? requireFlow();
41
+ const host = resolveIdHost(options.host ?? flow.host);
42
+ const hostOrigin = new URL(host).origin;
43
+ let theme = options.theme ?? "light dark";
44
+ const route = WIDGET_ROUTE[options.widget];
45
+ const frame = makeIframe(`${host}${route}`);
46
+ frame.style.background = "transparent";
47
+ const container = options.container ?? document.body;
48
+ // Appended before bridging: `contentWindow` is null until the frame is in the
49
+ // document, and the bridge is bound to that window.
50
+ container.appendChild(frame);
51
+ const bridge = bridgeToWindow(frame.contentWindow, {
52
+ targetOrigin: hostOrigin,
53
+ });
54
+ bridge.on("__internal", (payload) => {
55
+ if (payload.type === "profile-token-request")
56
+ void answerTokenRequest(bridge, flow, payload.id);
57
+ });
58
+ // The widget already loads its own route directly, so `route` here only
59
+ // confirms where the frame is; what the handshake is really for is telling
60
+ // the dialog which origin it is embedded by, which is what it later asks for
61
+ // a token.
62
+ void bridge.send("__internal", {
63
+ type: "init",
64
+ mode: "iframe",
65
+ referrer: { title: document.title },
66
+ theme: { colorScheme: theme },
67
+ route,
68
+ });
69
+ return {
70
+ frame,
71
+ post(message) {
72
+ frame.contentWindow?.postMessage(message, hostOrigin);
73
+ },
74
+ setTheme(next) {
75
+ if (next === theme)
76
+ return;
77
+ theme = next;
78
+ void bridge.send("__internal", {
79
+ type: "set-theme",
80
+ theme: { colorScheme: next },
81
+ });
82
+ },
83
+ destroy() {
84
+ bridge.destroy();
85
+ frame.remove();
86
+ },
87
+ };
88
+ }
@@ -1,4 +1,5 @@
1
1
  import * as Messenger from "../dialog/remote/Messenger";
2
+ import type { Flow } from "../types";
2
3
  /**
3
4
  * Permissions the dialog iframe needs to run WebAuthn passkey ceremonies and
4
5
  * copy recovery values. Shared by every Flow iframe host (the dialog host and
@@ -21,3 +22,14 @@ export declare function makeIframe(src: string): HTMLIFrameElement;
21
22
  export declare function bridgeToWindow(target: Window, options?: {
22
23
  targetOrigin?: string;
23
24
  }): Messenger.Bridge;
25
+ /**
26
+ * Answers a widget's request for the host's credential. Every Flow iframe that
27
+ * reads the Flow ID API from a cross-site frame asks for this, because its own
28
+ * origin's cookie is unavailable or bound to another audience there.
29
+ *
30
+ * `getToken` refreshes a token that is missing or near expiry, so a widget left
31
+ * open past the 1h access-token lifetime still gets a live one rather than a
32
+ * 401. A failure answers `null` — "no session", which widgets render as signed
33
+ * out — instead of leaving the request to time out.
34
+ */
35
+ export declare function answerTokenRequest(bridge: Messenger.Bridge, flow: Flow, id: string): Promise<void>;
@@ -32,3 +32,17 @@ export function bridgeToWindow(target, options = {}) {
32
32
  waitForReady: true,
33
33
  });
34
34
  }
35
+ /**
36
+ * Answers a widget's request for the host's credential. Every Flow iframe that
37
+ * reads the Flow ID API from a cross-site frame asks for this, because its own
38
+ * origin's cookie is unavailable or bound to another audience there.
39
+ *
40
+ * `getToken` refreshes a token that is missing or near expiry, so a widget left
41
+ * open past the 1h access-token lifetime still gets a live one rather than a
42
+ * 401. A failure answers `null` — "no session", which widgets render as signed
43
+ * out — instead of leaving the request to time out.
44
+ */
45
+ export async function answerTokenRequest(bridge, flow, id) {
46
+ const token = await flow.getToken().catch(() => null);
47
+ void bridge.send("__internal", { type: "profile-token", id, token });
48
+ }
@@ -1,7 +1,8 @@
1
1
  export { defaultIdHost, isLocalHostname } from "../id-host";
2
- export type { AccessKeyOptions, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, LoginOptions, MethodName, 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, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
3
3
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
4
4
  export { createDialogHost } from "./dialog-host";
5
+ export { createFlowWidget } from "./flow-widget";
5
6
  export { METHODS } from "./methods";
6
7
  export { createProfileButton } from "./profile-button";
7
8
  export { createRoomsApi, RoomsRequestError } from "./rooms";
@@ -1,6 +1,7 @@
1
1
  export { defaultIdHost, isLocalHostname } from "../id-host";
2
2
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
3
3
  export { createDialogHost } from "./dialog-host";
4
+ export { createFlowWidget } from "./flow-widget";
4
5
  export { METHODS } from "./methods";
5
6
  export { createProfileButton } from "./profile-button";
6
7
  export { createRoomsApi, RoomsRequestError } from "./rooms";
@@ -1,6 +1,6 @@
1
1
  import { resolveIdHost } from "../id-host";
2
2
  import { getFlow, requireFlow } from "./create-flow";
3
- import { bridgeToWindow, makeIframe } from "./iframe-host";
3
+ import { answerTokenRequest, bridgeToWindow, makeIframe } from "./iframe-host";
4
4
  const POSITION_STYLE = {
5
5
  "top-right": { top: "0", right: "0" },
6
6
  "top-left": { top: "0", left: "0" },
@@ -29,11 +29,13 @@ const OVERLAY_STYLE = {
29
29
  */
30
30
  export function createProfileButton(options) {
31
31
  if (typeof document === "undefined")
32
- return { destroy() { } };
32
+ return { setTheme() { }, destroy() { } };
33
33
  const flow = options.flow ?? getFlow() ?? requireFlow();
34
34
  const host = resolveIdHost(options.host ?? flow.host);
35
35
  const hostOrigin = new URL(host).origin;
36
- const theme = options.theme ?? "light dark";
36
+ // Mutable so `setTheme` recolors over the open bridge, and so a dialog opened
37
+ // after a toggle is initialized with the theme the host is actually showing.
38
+ let theme = options.theme ?? "light dark";
37
39
  let createdContainer = null;
38
40
  let container = options.container ?? null;
39
41
  if (!container) {
@@ -81,13 +83,6 @@ export function createProfileButton(options) {
81
83
  ? { username: user.username, isGuest: user.isGuest === true }
82
84
  : null;
83
85
  }
84
- // The widget's only credential. `getToken` refreshes a token that is missing
85
- // or near expiry, so a widget open on a page left idle past the 1h access-
86
- // token lifetime still gets a live one instead of a 401.
87
- async function answerTokenRequest(bridge, id) {
88
- const token = await flow.getToken().catch(() => null);
89
- void bridge.send("__internal", { type: "profile-token", id, token });
90
- }
91
86
  // ----- the persistent pill (stays mounted; sizes to its own content) -----
92
87
  const pill = makeFrame();
93
88
  let pillWidth = 0;
@@ -113,7 +108,7 @@ export function createProfileButton(options) {
113
108
  showDialog();
114
109
  }
115
110
  else if (payload.type === "profile-token-request") {
116
- void answerTokenRequest(pillBridge, payload.id);
111
+ void answerTokenRequest(pillBridge, flow, payload.id);
117
112
  }
118
113
  });
119
114
  // ----- the dialog overlay (lazy; a separate fullscreen iframe layered ABOVE
@@ -143,7 +138,7 @@ export function createProfileButton(options) {
143
138
  }
144
139
  else if (payload.type === "profile-token-request") {
145
140
  if (dialogBridge)
146
- void answerTokenRequest(dialogBridge, payload.id);
141
+ void answerTokenRequest(dialogBridge, flow, payload.id);
147
142
  }
148
143
  });
149
144
  void dialogBridge.send("__internal", {
@@ -190,6 +185,19 @@ export function createProfileButton(options) {
190
185
  pushIdentity();
191
186
  const unsubscribe = flow.subscribe(pushIdentity);
192
187
  return {
188
+ setTheme(next) {
189
+ if (next === theme)
190
+ return;
191
+ theme = next;
192
+ void pillBridge.send("__internal", {
193
+ type: "set-theme",
194
+ theme: { colorScheme: next },
195
+ });
196
+ void dialogBridge?.send("__internal", {
197
+ type: "set-theme",
198
+ theme: { colorScheme: next },
199
+ });
200
+ },
193
201
  destroy() {
194
202
  unsubscribe();
195
203
  pillBridge.destroy();
@@ -0,0 +1,16 @@
1
+ import type { FlowWidgetProps } from "../types";
2
+ /**
3
+ * Drops an inline Flow ID widget — the XP strip, the action timer — into a
4
+ * React tree. Renders a host element (style it via `className` for placement
5
+ * and size) that the widget iframe fills. Resolves the Flow instance from a
6
+ * `<FlowIdProvider>` or the createFlow() singleton.
7
+ *
8
+ * Prefer this over embedding the widget's URL in your own `<iframe>`: only a
9
+ * frame mounted this way completes the handshake that lets it authenticate as
10
+ * the signed-in user from a cross-site page (AUTH-171).
11
+ *
12
+ * A changing `theme` recolors the mounted widget over its open bridge rather
13
+ * than remounting it — see `<ProfileButton>` for why the frame must survive a
14
+ * light/dark toggle.
15
+ */
16
+ export declare function FlowWidget({ widget, className, host, theme, }: FlowWidgetProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ import { createFlowWidget } from "../client/flow-widget";
4
+ import { useFlow } from "./hooks";
5
+ /**
6
+ * Drops an inline Flow ID widget — the XP strip, the action timer — into a
7
+ * React tree. Renders a host element (style it via `className` for placement
8
+ * and size) that the widget iframe fills. Resolves the Flow instance from a
9
+ * `<FlowIdProvider>` or the createFlow() singleton.
10
+ *
11
+ * Prefer this over embedding the widget's URL in your own `<iframe>`: only a
12
+ * frame mounted this way completes the handshake that lets it authenticate as
13
+ * the signed-in user from a cross-site page (AUTH-171).
14
+ *
15
+ * A changing `theme` recolors the mounted widget over its open bridge rather
16
+ * than remounting it — see `<ProfileButton>` for why the frame must survive a
17
+ * light/dark toggle.
18
+ */
19
+ export function FlowWidget({ widget, className, host, theme, }) {
20
+ const flow = useFlow();
21
+ const ref = useRef(null);
22
+ const handleRef = useRef(null);
23
+ const themeRef = useRef(theme);
24
+ useEffect(() => {
25
+ const container = ref.current;
26
+ if (!container)
27
+ return;
28
+ const handle = createFlowWidget({
29
+ widget,
30
+ container,
31
+ flow,
32
+ ...(host ? { host } : {}),
33
+ ...(themeRef.current ? { theme: themeRef.current } : {}),
34
+ });
35
+ handleRef.current = handle;
36
+ if (handle.frame) {
37
+ handle.frame.style.width = "100%";
38
+ handle.frame.style.height = "100%";
39
+ handle.frame.style.display = "block";
40
+ }
41
+ return () => {
42
+ handleRef.current = null;
43
+ handle.destroy();
44
+ };
45
+ }, [widget, flow, host]);
46
+ useEffect(() => {
47
+ themeRef.current = theme;
48
+ if (theme)
49
+ handleRef.current?.setTheme(theme);
50
+ }, [theme]);
51
+ return _jsx("div", { ref: ref, className: className });
52
+ }
@@ -1,4 +1,5 @@
1
- export type { FlowIdProviderProps, ProfileButtonProps } from "../types";
1
+ export type { FlowIdProviderProps, FlowWidgetProps, ProfileButtonProps, } from "../types";
2
+ export { FlowWidget } from "./flow-widget";
2
3
  export { useFlow, useFlowId, useFlowState, useRoom, useRooms } from "./hooks";
3
4
  export { ProfileButton } from "./profile-button";
4
5
  export { FlowIdProvider } from "./provider";
@@ -1,3 +1,4 @@
1
+ export { FlowWidget } from "./flow-widget";
1
2
  export { useFlow, useFlowId, useFlowState, useRoom, useRooms } from "./hooks";
2
3
  export { ProfileButton } from "./profile-button";
3
4
  export { FlowIdProvider } from "./provider";
@@ -5,5 +5,10 @@ import type { ProfileButtonProps } from "../types";
5
5
  * the pill iframe mounts into; the iframe itself is sized to its content and
6
6
  * expands to the profile dialog on click. Resolves the Flow instance from a
7
7
  * `<FlowIdProvider>` or the createFlow() singleton.
8
+ *
9
+ * A changing `theme` recolors the mounted pill over its open bridge rather than
10
+ * remounting it: `theme` is deliberately absent from the mount effect's deps,
11
+ * because tearing the iframe down on a light/dark toggle reloads the widget and
12
+ * drops the pill out of the host's layout mid-toggle.
8
13
  */
9
14
  export declare function ProfileButton({ className, host, theme }: ProfileButtonProps): import("react/jsx-runtime").JSX.Element;
@@ -8,10 +8,17 @@ import { useFlow } from "./hooks";
8
8
  * the pill iframe mounts into; the iframe itself is sized to its content and
9
9
  * expands to the profile dialog on click. Resolves the Flow instance from a
10
10
  * `<FlowIdProvider>` or the createFlow() singleton.
11
+ *
12
+ * A changing `theme` recolors the mounted pill over its open bridge rather than
13
+ * remounting it: `theme` is deliberately absent from the mount effect's deps,
14
+ * because tearing the iframe down on a light/dark toggle reloads the widget and
15
+ * drops the pill out of the host's layout mid-toggle.
11
16
  */
12
17
  export function ProfileButton({ className, host, theme }) {
13
18
  const flow = useFlow();
14
19
  const ref = useRef(null);
20
+ const handleRef = useRef(null);
21
+ const themeRef = useRef(theme);
15
22
  useEffect(() => {
16
23
  const container = ref.current;
17
24
  if (!container)
@@ -20,9 +27,18 @@ export function ProfileButton({ className, host, theme }) {
20
27
  container,
21
28
  flow,
22
29
  ...(host ? { host } : {}),
23
- ...(theme ? { theme } : {}),
30
+ ...(themeRef.current ? { theme: themeRef.current } : {}),
24
31
  });
25
- return () => handle.destroy();
26
- }, [flow, host, theme]);
32
+ handleRef.current = handle;
33
+ return () => {
34
+ handleRef.current = null;
35
+ handle.destroy();
36
+ };
37
+ }, [flow, host]);
38
+ useEffect(() => {
39
+ themeRef.current = theme;
40
+ if (theme)
41
+ handleRef.current?.setTheme(theme);
42
+ }, [theme]);
27
43
  return _jsx("div", { ref: ref, className: className });
28
44
  }
@@ -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.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.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.ceremony_failed" | "auth.funnel.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" | "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" | "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 {
@@ -37,6 +37,13 @@ export interface AuthEventRecord {
37
37
  userAgent: string | null;
38
38
  durationMs: number | null;
39
39
  traceId: string | null;
40
+ /** Room-event scheduler fields: which series and dated instance a transition
41
+ * or dispatch was about, and for a dispatch, which reminder and how many
42
+ * subscribers it reached. */
43
+ roomEventId: string | null;
44
+ occurrenceId: string | null;
45
+ dispatchKind: string | null;
46
+ recipients: number | null;
40
47
  }
41
48
  /** Body accepted by `POST /api/events` from the dialog. */
42
49
  export interface BeaconBody {
@@ -4,8 +4,9 @@ export type { AuthErrorCode, AuthEventName, AuthEventRecord, AuthMode, AuthOutco
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, RestoreResponse, RpcRequest, SendCallsParams, SendCallsRequest, SendCallsResponse, SendTransactionParams, SendTransactionRequest, SendTransactionResponse, SignMessageRequest, SignMessageResponse, SignOutRequest, SignOutResponse, SignTypedDataRequest, SignTypedDataResponse, TransactionArgs, TypedData, TypedDataDomain, TypedDataField, } from "./protocol";
6
6
  export { getConnectCapabilities, isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
7
- export type { CreateRoomInput, GlobalRole, ModerationSubject, MyRooms, RoleChangeVerdict, RoomChannel, RoomDetail, RoomList, RoomMemberEntry, RoomMembers, RoomOccupancy, RoomOwner, RoomPresenceEntry, RoomPresenceSnapshot, RoomRestrictionKind, RoomRole, RoomSummary, RoomSurfaceSettings, RoomsApi, RoomVisibility, StaffEntry, UpdateRoomInput, VerifyRoomContext, } from "./rooms";
8
- export type { AccessKeyOptions, AccessKeyPreparation, Address, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, 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";
7
+ export type { CreateRoomEventInput, RoomEventAttendance, RoomEventFrequency, RoomEventInstance, RoomEventInterest, RoomEventInterestState, RoomEventList, RoomEventOccurrence, RoomEventRecurrence, RoomEventRoomRef, RoomEventSeries, RoomEventStatus, SetInterestInput, UpdateRoomEventInput, } from "./room-events";
8
+ export type { CreateRoomInput, GlobalRole, ModerationSubject, MyRooms, RoleChangeVerdict, RoomChannel, RoomDetail, RoomList, RoomMemberEntry, RoomMembers, RoomOccupancy, RoomOwner, RoomPresenceEntry, RoomPresenceSnapshot, RoomRestrictionKind, RoomRole, RoomSummary, RoomSurfaceSettings, RoomsApi, RoomVisibility, StaffEntry, UpdateRoomInput, VerifyRoomContext, Viewer, } from "./rooms";
9
+ 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";
9
10
  export type { ResolvedFlowSession, ResolveSessionOptions, SessionRouteOptions, SessionRouteResponse, SessionRouteSession, } from "./server";
10
11
  export type { CoinAsset, IdentifiedTx, TxApprove, TxConvert, TxSend, TxSwap, } from "./tx";
11
12
  export type { ActionDayContext, ActionEventKind, ActionSessionState, ActiveActionResponse, LevelProgress, PublicProfile, XpGrantResult, XpRecentGrant, XpSummary, } from "./xp";
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Room events — scheduled happenings inside a room, shared by the server
3
+ * routes and the client SDK.
4
+ *
5
+ * Deliberately named `room-events` throughout: `types/events.ts` and
6
+ * `routes/events.ts` already mean structured product/telemetry events, and the
7
+ * two must never blur.
8
+ *
9
+ * Two shapes, and which one an endpoint speaks is load-bearing. A
10
+ * {@link RoomEventSeries} is what an owner authors and edits; an instance is
11
+ * one dated occurrence of it. Players only ever address occurrences — interest,
12
+ * reminders and attendance all attach to an instance, so a weekly race's third
13
+ * week is a thing in its own right.
14
+ */
15
+ /** Discord's Guild Scheduled Event lifecycle, mirrored exactly. */
16
+ export type RoomEventStatus = "scheduled" | "active" | "completed" | "cancelled";
17
+ export type RoomEventFrequency = "daily" | "weekly" | "monthly";
18
+ /**
19
+ * The RRULE subset a series may repeat on. Expanded in the event's IANA
20
+ * timezone by the (db-free) expander, never in UTC. `until` is an ISO instant;
21
+ * `until` and `count` are mutually exclusive and both may be absent for an
22
+ * open-ended series.
23
+ */
24
+ export interface RoomEventRecurrence {
25
+ freq: RoomEventFrequency;
26
+ interval?: number;
27
+ /** 0 = Sunday … 6 = Saturday, matching `Date.prototype.getDay`. */
28
+ byweekday?: number[];
29
+ bymonthday?: number[];
30
+ until?: string;
31
+ count?: number;
32
+ }
33
+ /** The room an event belongs to, denormalized so a listing needs no second read. */
34
+ export interface RoomEventRoomRef {
35
+ id: string;
36
+ slug: string;
37
+ displayName: string;
38
+ }
39
+ /**
40
+ * Interest in one dated instance. `interestedCount` is public; the two viewer
41
+ * flags are meaningful only when the request carried a bearer and read false
42
+ * otherwise — the same shape the room payload uses for the caller's role.
43
+ *
44
+ * A player counts as interested in an instance either by marking that instance
45
+ * or by following its whole series, so `viewerInterested` can be true while
46
+ * `viewerFollowing` is what actually produced it.
47
+ */
48
+ export interface RoomEventInterest {
49
+ interestedCount: number;
50
+ viewerInterested: boolean;
51
+ viewerFollowing: boolean;
52
+ }
53
+ /**
54
+ * One dated instance, as it appears under the series that owns it: only what
55
+ * differs per instance, since the series already carries the title, room and
56
+ * timezone. `startsAt`/`endsAt` are ISO instants in UTC — render them in the
57
+ * series' `timezone` to show the local time the owner scheduled.
58
+ */
59
+ export interface RoomEventInstance extends RoomEventInterest {
60
+ id: string;
61
+ startsAt: string;
62
+ endsAt: string | null;
63
+ status: RoomEventStatus;
64
+ /** Moved or cancelled on its own, so a series-wide edit leaves it alone. */
65
+ overridden: boolean;
66
+ /** Written once the instance completes — the history view reads these rather
67
+ * than scanning presence. Null while it has not finished. */
68
+ attendance: RoomEventAttendance | null;
69
+ }
70
+ /** What actually happened, as observed from room presence while it ran. */
71
+ export interface RoomEventAttendance {
72
+ attendeeCount: number;
73
+ peakConcurrent: number;
74
+ startedAt: string | null;
75
+ endedAt: string | null;
76
+ }
77
+ /** An instance standing on its own in a listing, carrying enough of its series
78
+ * and room to render a card. */
79
+ export interface RoomEventOccurrence extends RoomEventInstance {
80
+ eventId: string;
81
+ room: RoomEventRoomRef;
82
+ title: string;
83
+ description: string | null;
84
+ coverUrl: string | null;
85
+ timezone: string;
86
+ /** True when the parent series repeats, so a card can badge it as recurring. */
87
+ recurring: boolean;
88
+ createdBy: string | null;
89
+ }
90
+ /** The authored series, with the instances it has materialized so far. */
91
+ export interface RoomEventSeries {
92
+ id: string;
93
+ room: RoomEventRoomRef;
94
+ title: string;
95
+ description: string | null;
96
+ coverUrl: string | null;
97
+ startsAt: string;
98
+ endsAt: string | null;
99
+ timezone: string;
100
+ recurrence: RoomEventRecurrence | null;
101
+ /** Minutes before the start at which subscribers are reminded; null is the
102
+ * default schedule, an empty list is silence. */
103
+ reminderOffsets: number[] | null;
104
+ status: RoomEventStatus;
105
+ createdBy: string | null;
106
+ createdAt: string;
107
+ updatedAt: string;
108
+ occurrences: RoomEventInstance[];
109
+ }
110
+ export interface RoomEventList {
111
+ events: RoomEventOccurrence[];
112
+ }
113
+ export interface CreateRoomEventInput {
114
+ title: string;
115
+ description?: string | null;
116
+ coverUrl?: string | null;
117
+ startsAt: string;
118
+ endsAt?: string | null;
119
+ timezone?: string;
120
+ recurrence?: RoomEventRecurrence | null;
121
+ reminderOffsets?: number[] | null;
122
+ }
123
+ /** Every create field, all optional — a patch is merged over the stored row and
124
+ * validated by the same rules, so the two shapes can never drift. */
125
+ export type UpdateRoomEventInput = Partial<CreateRoomEventInput>;
126
+ export interface SetInterestInput {
127
+ /** Also follow (or unfollow) the whole series, not just this instance. */
128
+ series?: boolean;
129
+ }
130
+ /** What the interest endpoints answer with, so a card can update in place. */
131
+ export interface RoomEventInterestState extends RoomEventInterest {
132
+ occurrenceId: string;
133
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Room events — scheduled happenings inside a room, shared by the server
3
+ * routes and the client SDK.
4
+ *
5
+ * Deliberately named `room-events` throughout: `types/events.ts` and
6
+ * `routes/events.ts` already mean structured product/telemetry events, and the
7
+ * two must never blur.
8
+ *
9
+ * Two shapes, and which one an endpoint speaks is load-bearing. A
10
+ * {@link RoomEventSeries} is what an owner authors and edits; an instance is
11
+ * one dated occurrence of it. Players only ever address occurrences — interest,
12
+ * reminders and attendance all attach to an instance, so a weekly race's third
13
+ * week is a thing in its own right.
14
+ */
@@ -18,6 +18,14 @@ export interface ModerationSubject {
18
18
  globalRole: GlobalRole;
19
19
  roomRole: RoomRole | null;
20
20
  }
21
+ /** A verified caller resolved against one room: the raw composite the rank
22
+ * lattice needs, plus the wire projection every route-side gate compares. */
23
+ export interface Viewer {
24
+ sub: string;
25
+ guest: boolean;
26
+ subject: ModerationSubject;
27
+ role: RoomRole | null;
28
+ }
21
29
  /** One row of `GET /api/admin/staff` — a user holding a non-`user` tier. */
22
30
  export interface StaffEntry {
23
31
  userId: string;
@@ -187,6 +187,11 @@ export type DialogOpenOptions = {
187
187
  icon?: string;
188
188
  };
189
189
  };
190
+ /**
191
+ * Color scheme for Flow ID iframe chrome. "light dark" follows the OS, which
192
+ * is the default; a host with its own theme toggle passes the resolved value.
193
+ */
194
+ export type ColorScheme = "light" | "dark" | "light dark";
190
195
  /** Anchor for an auto-created (host-less) profile widget container. */
191
196
  export type ProfilePosition = "top-right" | "top-left" | "bottom-right" | "bottom-left";
192
197
  export type MountProfileOptions = {
@@ -209,14 +214,73 @@ export type MountProfileOptions = {
209
214
  * Color scheme for the widget chrome. Set this to match the host (the dark
210
215
  * game UI passes "dark"). Defaults to "light dark" (follows the OS).
211
216
  */
212
- theme?: "light" | "dark" | "light dark";
217
+ theme?: ColorScheme;
213
218
  /** Explicit Flow instance. Defaults to the createFlow() singleton. */
214
219
  flow?: Flow;
215
220
  };
216
221
  export type ProfileButtonHandle = {
222
+ /**
223
+ * Recolors the mounted pill (and its profile dialog) in place. A host with a
224
+ * theme toggle calls this instead of remounting: rebuilding the iframe would
225
+ * reload the widget and flash the pill out of the layout on every toggle.
226
+ */
227
+ setTheme: (theme: ColorScheme) => void;
217
228
  /** Tears down the iframe, listeners, and any auto-created container. */
218
229
  destroy: () => void;
219
230
  };
231
+ /** The inline Flow ID widgets an embedder can mount alongside its own UI. */
232
+ export type FlowWidgetName = "xp" | "action-timer";
233
+ export type MountFlowWidgetOptions = {
234
+ /** Which inline widget to mount. */
235
+ widget: FlowWidgetName;
236
+ /**
237
+ * Element to append the widget iframe to. Defaults to `document.body`, which
238
+ * is what a fixed-position overlay wants; pass a container to lay the widget
239
+ * out inline instead.
240
+ */
241
+ container?: HTMLElement;
242
+ /**
243
+ * The Flow ID origin serving the widget (e.g. https://id.flow.industries).
244
+ * Defaults to the resolved Flow instance's `host`, then the SDK default.
245
+ */
246
+ host?: string;
247
+ /**
248
+ * Color scheme for the widget chrome. Set this to match the host (the dark
249
+ * game UI passes "dark"). Defaults to "light dark" (follows the OS).
250
+ */
251
+ theme?: ColorScheme;
252
+ /** Explicit Flow instance. Defaults to the createFlow() singleton. */
253
+ flow?: Flow;
254
+ };
255
+ export type FlowWidgetHandle = {
256
+ /**
257
+ * The mounted iframe, so the embedder owns geometry and visibility. Null
258
+ * under SSR, where nothing was mounted.
259
+ */
260
+ readonly frame: HTMLIFrameElement | null;
261
+ /**
262
+ * Sends a widget-specific message (e.g. `{ type: "xp-refresh" }`), pinned to
263
+ * the Flow ID origin.
264
+ */
265
+ post: (message: unknown) => void;
266
+ /**
267
+ * Recolors the mounted widget in place, for the same reason the pill exposes
268
+ * it: a remount reloads the frame and flashes it out of the host's layout.
269
+ */
270
+ setTheme: (theme: ColorScheme) => void;
271
+ /** Tears down the iframe and its bridge. */
272
+ destroy: () => void;
273
+ };
274
+ export type FlowWidgetProps = {
275
+ /** Which inline widget to mount. */
276
+ widget: FlowWidgetName;
277
+ /** Class applied to the host element the widget iframe fills. */
278
+ className?: string;
279
+ /** Override the Flow ID origin; defaults to the resolved Flow's `host`. */
280
+ host?: string;
281
+ /** Color scheme for the widget chrome; defaults to "light dark" (the OS). */
282
+ theme?: ColorScheme;
283
+ };
220
284
  export type DialogHost = {
221
285
  open: (options?: DialogOpenOptions) => void;
222
286
  close: () => void;
@@ -316,7 +380,7 @@ export type ProfileButtonProps = {
316
380
  /** Override the Flow ID origin; defaults to the resolved Flow's `host`. */
317
381
  host?: string;
318
382
  /** Color scheme for the widget chrome; defaults to "light dark" (the OS). */
319
- theme?: "light" | "dark" | "light dark";
383
+ theme?: ColorScheme;
320
384
  };
321
385
  export type RunLoginParams = {
322
386
  dialog: DialogHost;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.16.0",
3
+ "version": "0.18.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",
@@ -58,6 +58,7 @@
58
58
  "build:sdk": "tsc --project tsconfig.sdk.json",
59
59
  "prepublishOnly": "bun run build:sdk",
60
60
  "start": "bun run src/index.ts",
61
+ "worker": "bun run src/worker.ts",
61
62
  "email:dev": "email dev --dir src/emails --port 3010",
62
63
  "db:generate": "bunx @better-auth/cli generate",
63
64
  "db:migrate:gen": "bunx drizzle-kit generate",