@flow-industries/id 0.16.0 → 0.17.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,78 @@
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
+ destroy() { },
38
+ };
39
+ const flow = options.flow ?? getFlow() ?? requireFlow();
40
+ const host = resolveIdHost(options.host ?? flow.host);
41
+ const hostOrigin = new URL(host).origin;
42
+ const theme = options.theme ?? "light dark";
43
+ const route = WIDGET_ROUTE[options.widget];
44
+ const frame = makeIframe(`${host}${route}`);
45
+ frame.style.background = "transparent";
46
+ const container = options.container ?? document.body;
47
+ // Appended before bridging: `contentWindow` is null until the frame is in the
48
+ // document, and the bridge is bound to that window.
49
+ container.appendChild(frame);
50
+ const bridge = bridgeToWindow(frame.contentWindow, {
51
+ targetOrigin: hostOrigin,
52
+ });
53
+ bridge.on("__internal", (payload) => {
54
+ if (payload.type === "profile-token-request")
55
+ void answerTokenRequest(bridge, flow, payload.id);
56
+ });
57
+ // The widget already loads its own route directly, so `route` here only
58
+ // confirms where the frame is; what the handshake is really for is telling
59
+ // the dialog which origin it is embedded by, which is what it later asks for
60
+ // a token.
61
+ void bridge.send("__internal", {
62
+ type: "init",
63
+ mode: "iframe",
64
+ referrer: { title: document.title },
65
+ theme: { colorScheme: theme },
66
+ route,
67
+ });
68
+ return {
69
+ frame,
70
+ post(message) {
71
+ frame.contentWindow?.postMessage(message, hostOrigin);
72
+ },
73
+ destroy() {
74
+ bridge.destroy();
75
+ frame.remove();
76
+ },
77
+ };
78
+ }
@@ -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" },
@@ -81,13 +81,6 @@ export function createProfileButton(options) {
81
81
  ? { username: user.username, isGuest: user.isGuest === true }
82
82
  : null;
83
83
  }
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
84
  // ----- the persistent pill (stays mounted; sizes to its own content) -----
92
85
  const pill = makeFrame();
93
86
  let pillWidth = 0;
@@ -113,7 +106,7 @@ export function createProfileButton(options) {
113
106
  showDialog();
114
107
  }
115
108
  else if (payload.type === "profile-token-request") {
116
- void answerTokenRequest(pillBridge, payload.id);
109
+ void answerTokenRequest(pillBridge, flow, payload.id);
117
110
  }
118
111
  });
119
112
  // ----- the dialog overlay (lazy; a separate fullscreen iframe layered ABOVE
@@ -143,7 +136,7 @@ export function createProfileButton(options) {
143
136
  }
144
137
  else if (payload.type === "profile-token-request") {
145
138
  if (dialogBridge)
146
- void answerTokenRequest(dialogBridge, payload.id);
139
+ void answerTokenRequest(dialogBridge, flow, payload.id);
147
140
  }
148
141
  });
149
142
  void dialogBridge.send("__internal", {
@@ -0,0 +1,12 @@
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
+ export declare function FlowWidget({ widget, className, host, theme, }: FlowWidgetProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,37 @@
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
+ export function FlowWidget({ widget, className, host, theme, }) {
16
+ const flow = useFlow();
17
+ const ref = useRef(null);
18
+ useEffect(() => {
19
+ const container = ref.current;
20
+ if (!container)
21
+ return;
22
+ const handle = createFlowWidget({
23
+ widget,
24
+ container,
25
+ flow,
26
+ ...(host ? { host } : {}),
27
+ ...(theme ? { theme } : {}),
28
+ });
29
+ if (handle.frame) {
30
+ handle.frame.style.width = "100%";
31
+ handle.frame.style.height = "100%";
32
+ handle.frame.style.display = "block";
33
+ }
34
+ return () => handle.destroy();
35
+ }, [widget, flow, host, theme]);
36
+ return _jsx("div", { ref: ref, className: className });
37
+ }
@@ -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";
@@ -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, 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;
@@ -217,6 +217,54 @@ export type ProfileButtonHandle = {
217
217
  /** Tears down the iframe, listeners, and any auto-created container. */
218
218
  destroy: () => void;
219
219
  };
220
+ /** The inline Flow ID widgets an embedder can mount alongside its own UI. */
221
+ export type FlowWidgetName = "xp" | "action-timer";
222
+ export type MountFlowWidgetOptions = {
223
+ /** Which inline widget to mount. */
224
+ widget: FlowWidgetName;
225
+ /**
226
+ * Element to append the widget iframe to. Defaults to `document.body`, which
227
+ * is what a fixed-position overlay wants; pass a container to lay the widget
228
+ * out inline instead.
229
+ */
230
+ container?: HTMLElement;
231
+ /**
232
+ * The Flow ID origin serving the widget (e.g. https://id.flow.industries).
233
+ * Defaults to the resolved Flow instance's `host`, then the SDK default.
234
+ */
235
+ host?: string;
236
+ /**
237
+ * Color scheme for the widget chrome. Set this to match the host (the dark
238
+ * game UI passes "dark"). Defaults to "light dark" (follows the OS).
239
+ */
240
+ theme?: "light" | "dark" | "light dark";
241
+ /** Explicit Flow instance. Defaults to the createFlow() singleton. */
242
+ flow?: Flow;
243
+ };
244
+ export type FlowWidgetHandle = {
245
+ /**
246
+ * The mounted iframe, so the embedder owns geometry and visibility. Null
247
+ * under SSR, where nothing was mounted.
248
+ */
249
+ readonly frame: HTMLIFrameElement | null;
250
+ /**
251
+ * Sends a widget-specific message (e.g. `{ type: "xp-refresh" }`), pinned to
252
+ * the Flow ID origin.
253
+ */
254
+ post: (message: unknown) => void;
255
+ /** Tears down the iframe and its bridge. */
256
+ destroy: () => void;
257
+ };
258
+ export type FlowWidgetProps = {
259
+ /** Which inline widget to mount. */
260
+ widget: FlowWidgetName;
261
+ /** Class applied to the host element the widget iframe fills. */
262
+ className?: string;
263
+ /** Override the Flow ID origin; defaults to the resolved Flow's `host`. */
264
+ host?: string;
265
+ /** Color scheme for the widget chrome; defaults to "light dark" (the OS). */
266
+ theme?: "light" | "dark" | "light dark";
267
+ };
220
268
  export type DialogHost = {
221
269
  open: (options?: DialogOpenOptions) => void;
222
270
  close: () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",