@flow-industries/id 0.21.2 → 0.22.1

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.
Files changed (47) hide show
  1. package/README.md +52 -0
  2. package/contracts/v1/openapi.json +818 -0
  3. package/contracts/v1/reports.json +188 -0
  4. package/contracts/v1/sdk-exports.json +1165 -0
  5. package/dist/sdk/client/create-flow.js +2 -5
  6. package/dist/sdk/client/dialog-host.js +11 -6
  7. package/dist/sdk/client/flow-widget.js +3 -3
  8. package/dist/sdk/client/focus-session.d.ts +18 -0
  9. package/dist/sdk/client/focus-session.js +140 -0
  10. package/dist/sdk/client/focus.d.ts +11 -0
  11. package/dist/sdk/client/focus.js +30 -0
  12. package/dist/sdk/client/iframe-host.d.ts +2 -1
  13. package/dist/sdk/client/iframe-host.js +11 -1
  14. package/dist/sdk/client/index.d.ts +4 -1
  15. package/dist/sdk/client/index.js +2 -0
  16. package/dist/sdk/client/json-api.d.ts +1 -1
  17. package/dist/sdk/client/json-api.js +3 -1
  18. package/dist/sdk/client/open-profile.js +10 -7
  19. package/dist/sdk/client/profile-button.js +16 -9
  20. package/dist/sdk/client/reports.d.ts +9 -0
  21. package/dist/sdk/client/reports.js +30 -0
  22. package/dist/sdk/client/rooms.js +7 -26
  23. package/dist/sdk/client/study-search.d.ts +20 -0
  24. package/dist/sdk/client/study-search.js +116 -0
  25. package/dist/sdk/contracts/http.d.ts +155 -0
  26. package/dist/sdk/contracts/http.js +91 -0
  27. package/dist/sdk/contracts/reports.d.ts +130 -0
  28. package/dist/sdk/contracts/reports.js +67 -0
  29. package/dist/sdk/dialog/remote/Messenger.d.ts +3 -0
  30. package/dist/sdk/dialog/remote/Messenger.js +9 -3
  31. package/dist/sdk/react/use-cooldown.d.ts +1 -0
  32. package/dist/sdk/react/use-cooldown.js +12 -0
  33. package/dist/sdk/types/auth.d.ts +12 -0
  34. package/dist/sdk/types/dialog.d.ts +25 -0
  35. package/dist/sdk/types/focus.d.ts +109 -0
  36. package/dist/sdk/types/focus.js +0 -0
  37. package/dist/sdk/types/index.d.ts +8 -3
  38. package/dist/sdk/types/landing.d.ts +9 -0
  39. package/dist/sdk/types/landing.js +0 -0
  40. package/dist/sdk/types/otp.d.ts +20 -0
  41. package/dist/sdk/types/otp.js +0 -0
  42. package/dist/sdk/types/reports.d.ts +7 -0
  43. package/dist/sdk/types/reports.js +0 -0
  44. package/dist/sdk/types/settings.d.ts +162 -0
  45. package/dist/sdk/types/settings.js +0 -0
  46. package/dist/sdk/types/xp.d.ts +8 -0
  47. package/package.json +21 -9
@@ -13,11 +13,8 @@ import { createStore, initialFlowState } from "./store";
13
13
  * see the result instead of racing it. Falls back to running `fn` directly
14
14
  * where Web Locks is unavailable.
15
15
  */
16
- function withOriginLock(name, fn) {
17
- const locks =
18
- /* SAFETY: the SDK stashes its singleton on globalThis so two bundles of it share one
19
- instance; the property is namespaced and written only here. */
20
- globalThis.navigator?.locks;
16
+ async function withOriginLock(name, fn) {
17
+ const locks = globalThis.navigator?.locks;
21
18
  if (locks?.request)
22
19
  return locks.request(name, fn);
23
20
  return fn();
@@ -1,4 +1,4 @@
1
- import { bridgeToWindow, makeIframe } from "./iframe-host";
1
+ import { bridgeToWindow, makeIframe, sendBridgeNotification, } from "./iframe-host";
2
2
  import { setOverlayFocus } from "./overlay-focus";
3
3
  const HIDDEN_STYLE = {
4
4
  position: "fixed",
@@ -106,7 +106,7 @@ export function createDialogHost(options) {
106
106
  return;
107
107
  initSent = true;
108
108
  // SAFETY: the payload map types this topic; the dialog reads exactly these fields.
109
- messenger?.send("__internal", {
109
+ sendBridgeNotification(messenger, "__internal", {
110
110
  type: "init",
111
111
  mode: "iframe",
112
112
  referrer: opts?.referrer ?? { title: document.title },
@@ -124,7 +124,7 @@ export function createDialogHost(options) {
124
124
  // widget, so every dialog appears and disappears identically.
125
125
  void (
126
126
  // SAFETY: the payload map types this topic; the dialog reads exactly these fields.
127
- messenger?.send("__internal", { type: "dialog-shown" }));
127
+ sendBridgeNotification(messenger, "__internal", { type: "dialog-shown" }));
128
128
  }
129
129
  function hide() {
130
130
  if (!iframe)
@@ -137,7 +137,7 @@ export function createDialogHost(options) {
137
137
  setOverlayFocus(iframe, false);
138
138
  void (
139
139
  // SAFETY: the payload map types this topic; the dialog reads exactly these fields.
140
- messenger?.send("__internal", { type: "dialog-hidden" }));
140
+ sendBridgeNotification(messenger, "__internal", { type: "dialog-hidden" }));
141
141
  }
142
142
  function open(opts) {
143
143
  if (opts?.mode === "popup") {
@@ -177,9 +177,14 @@ export function createDialogHost(options) {
177
177
  return new Promise((resolve, reject) => {
178
178
  pending.set(id, { resolve, reject });
179
179
  // SAFETY: the rpc-requests payload shape.
180
- messenger.send("rpc-requests", [
180
+ messenger
181
+ .send("rpc-requests", [
181
182
  { request: rpcRequest, status: "pending" },
182
- ]);
183
+ ])
184
+ .catch((error) => {
185
+ pending.delete(id);
186
+ reject(error);
187
+ });
183
188
  });
184
189
  }
185
190
  /** Opens the dialog UI and dispatches an interactive RPC (login, sign, etc.). */
@@ -1,6 +1,6 @@
1
1
  import { resolveIdHost } from "../id-host";
2
2
  import { getFlow, requireFlow } from "./create-flow";
3
- import { answerTokenRequest, bridgeToWindow, makeIframe } from "./iframe-host";
3
+ import { answerTokenRequest, bridgeToWindow, makeIframe, sendBridgeNotification, } from "./iframe-host";
4
4
  /**
5
5
  * The inline widgets an embedder may mount, mapped to the dialog routes that
6
6
  * serve them. Callers name a widget rather than passing a route so the dialog's
@@ -65,7 +65,7 @@ export function createFlowWidget(options) {
65
65
  // confirms where the frame is; what the handshake is really for is telling
66
66
  // the dialog which origin it is embedded by, which is what it later asks for
67
67
  // a token.
68
- void bridge.send("__internal", {
68
+ sendBridgeNotification(bridge, "__internal", {
69
69
  type: "init",
70
70
  mode: "iframe",
71
71
  referrer: { title: document.title },
@@ -81,7 +81,7 @@ export function createFlowWidget(options) {
81
81
  if (next === theme)
82
82
  return;
83
83
  theme = next;
84
- void bridge.send("__internal", {
84
+ sendBridgeNotification(bridge, "__internal", {
85
85
  type: "set-theme",
86
86
  theme: { colorScheme: next },
87
87
  });
@@ -0,0 +1,18 @@
1
+ import type { ActiveAction, ClockAnchor, FocusSessionEvent, FocusSessionState } from "../types";
2
+ /** A re-read only replaces the anchor when it moves the display by more than
3
+ * this, so request timing never makes the clock jitter. */
4
+ export declare const REANCHOR_TOLERANCE_SECONDS = 2;
5
+ export declare function anchorFor(active: ActiveAction, readAt: number): ClockAnchor;
6
+ export declare function elapsedSeconds(anchor: ClockAnchor, now: number): number;
7
+ /** Whether a fresh server sample should replace the current anchor: yes when
8
+ * the pause state flipped or the server disagrees with the extrapolated clock
9
+ * by more than the tolerance (another surface paused, a replaced session). */
10
+ export declare function shouldReanchor(current: ClockAnchor, next: ActiveAction, now: number): boolean;
11
+ /** `mm:ss`, rolling into `h:mm:ss` past an hour. */
12
+ export declare function formatElapsed(seconds: number): string;
13
+ export declare const INITIAL_FOCUS_STATE: FocusSessionState;
14
+ export declare function focusSessionReducer(state: FocusSessionState, event: FocusSessionEvent): FocusSessionState;
15
+ /** The pill and panel headline: "Meditate", or "Practice · Chinese" when a
16
+ * subject is attached. `fallback` covers a source the catalog no longer
17
+ * labels. */
18
+ export declare function actionHeadline(action: Pick<ActiveAction, "label" | "source" | "subject">, fallback: string): string;
@@ -0,0 +1,140 @@
1
+ /** A re-read only replaces the anchor when it moves the display by more than
2
+ * this, so request timing never makes the clock jitter. */
3
+ export const REANCHOR_TOLERANCE_SECONDS = 2;
4
+ export function anchorFor(active, readAt) {
5
+ return {
6
+ accruedSeconds: active.accruedSeconds,
7
+ paused: active.paused,
8
+ readAt,
9
+ };
10
+ }
11
+ export function elapsedSeconds(anchor, now) {
12
+ if (anchor.paused)
13
+ return anchor.accruedSeconds;
14
+ return anchor.accruedSeconds + Math.max(0, now - anchor.readAt) / 1000;
15
+ }
16
+ /** Whether a fresh server sample should replace the current anchor: yes when
17
+ * the pause state flipped or the server disagrees with the extrapolated clock
18
+ * by more than the tolerance (another surface paused, a replaced session). */
19
+ export function shouldReanchor(current, next, now) {
20
+ if (current.paused !== next.paused)
21
+ return true;
22
+ return (Math.abs(elapsedSeconds(current, now) - next.accruedSeconds) >
23
+ REANCHOR_TOLERANCE_SECONDS);
24
+ }
25
+ /** `mm:ss`, rolling into `h:mm:ss` past an hour. */
26
+ export function formatElapsed(seconds) {
27
+ const whole = Math.max(0, Math.floor(seconds));
28
+ const hours = Math.floor(whole / 3600);
29
+ const minutes = Math.floor((whole % 3600) / 60);
30
+ const secs = whole % 60;
31
+ const mmss = `${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
32
+ return hours > 0 ? `${hours}:${mmss}` : mmss;
33
+ }
34
+ export const INITIAL_FOCUS_STATE = {
35
+ phase: "unknown",
36
+ active: null,
37
+ anchor: null,
38
+ finished: null,
39
+ conflict: null,
40
+ busy: false,
41
+ failed: false,
42
+ };
43
+ function withActive(state, active, at) {
44
+ const sameSession = state.active?.sessionId === active.sessionId;
45
+ const anchor = sameSession && state.anchor && !shouldReanchor(state.anchor, active, at)
46
+ ? state.anchor
47
+ : anchorFor(active, at);
48
+ return {
49
+ ...state,
50
+ phase: "active",
51
+ active,
52
+ anchor,
53
+ conflict: null,
54
+ failed: false,
55
+ };
56
+ }
57
+ export function focusSessionReducer(state, event) {
58
+ switch (event.type) {
59
+ case "read": {
60
+ if (event.active === null) {
61
+ return {
62
+ ...state,
63
+ phase: "idle",
64
+ active: null,
65
+ anchor: null,
66
+ failed: false,
67
+ };
68
+ }
69
+ return withActive(state, event.active, event.at);
70
+ }
71
+ case "read-failed":
72
+ return { ...state, failed: true };
73
+ case "request":
74
+ return { ...state, busy: true, failed: false };
75
+ case "request-failed":
76
+ return { ...state, busy: false, failed: true };
77
+ case "started":
78
+ return {
79
+ ...withActive(state, event.session, event.at),
80
+ anchor: anchorFor(event.session, event.at),
81
+ busy: false,
82
+ finished: null,
83
+ };
84
+ case "conflict":
85
+ return { ...state, busy: false, conflict: event.active };
86
+ case "paused": {
87
+ if (!state.active || !state.anchor)
88
+ return { ...state, busy: false };
89
+ const frozen = elapsedSeconds(state.anchor, event.at);
90
+ return {
91
+ ...state,
92
+ busy: false,
93
+ active: { ...state.active, paused: true, accruedSeconds: frozen },
94
+ anchor: { accruedSeconds: frozen, paused: true, readAt: event.at },
95
+ };
96
+ }
97
+ case "resumed": {
98
+ if (!state.active || !state.anchor)
99
+ return { ...state, busy: false };
100
+ return {
101
+ ...state,
102
+ busy: false,
103
+ active: { ...state.active, paused: false },
104
+ anchor: { ...state.anchor, paused: false, readAt: event.at },
105
+ };
106
+ }
107
+ case "finished": {
108
+ const finished = state.active
109
+ ? {
110
+ source: state.active.source,
111
+ label: state.active.label,
112
+ subject: state.active.subject,
113
+ xpGranted: event.result.xpGranted,
114
+ durationSeconds: event.result.durationSeconds,
115
+ }
116
+ : null;
117
+ return {
118
+ ...state,
119
+ phase: "idle",
120
+ active: null,
121
+ anchor: null,
122
+ busy: false,
123
+ finished,
124
+ };
125
+ }
126
+ case "dismiss-finished":
127
+ return { ...state, finished: null };
128
+ case "dismiss-conflict":
129
+ return { ...state, conflict: null };
130
+ case "reset":
131
+ return INITIAL_FOCUS_STATE;
132
+ }
133
+ }
134
+ /** The pill and panel headline: "Meditate", or "Practice · Chinese" when a
135
+ * subject is attached. `fallback` covers a source the catalog no longer
136
+ * labels. */
137
+ export function actionHeadline(action, fallback) {
138
+ const label = action.label ?? fallback;
139
+ return action.subject ? `${label} · ${action.subject.name}` : label;
140
+ }
@@ -0,0 +1,11 @@
1
+ import type { FocusApi, UserActionSurface } from "../types";
2
+ export type { ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionTransitionKind, ActiveAction, CatalogFieldRef, ClockAnchor, CreateStudySubjectInput, FinishActionRequest, FinishActionResponse, FinishedSummary, FocusApi, FocusPhase, FocusSessionEvent, FocusSessionState, ListStudySessionsOptions, ListStudySubjectsOptions, StartActionResult, StartFocusRequest, StudyCatalog, StudyCategory, StudyField, StudyRange, StudySearchHit, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRemovalResponse, StudySubjectResponse, StudySummary, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, } from "../types";
3
+ export { estimateActionXp, flowMultiplier } from "../xp/curve";
4
+ export { ActionsRequestError, isActiveSessionConflict } from "./actions";
5
+ export * from "./focus-session";
6
+ export { StudyRequestError } from "./study";
7
+ export * from "./study-search";
8
+ export declare const MEDITATION_SOURCE = "game.action.meditation";
9
+ export declare const STUDY_SOURCE = "action.study";
10
+ /** Compose action and study requests using the host's credentials and surface. */
11
+ export declare function createFocusApi(host: string, getToken: () => Promise<string | null>, surface: UserActionSurface): FocusApi;
@@ -0,0 +1,30 @@
1
+ import { createActionsApi } from "./actions";
2
+ import { createStudyApi } from "./study";
3
+ export { estimateActionXp, flowMultiplier } from "../xp/curve";
4
+ export { ActionsRequestError, isActiveSessionConflict } from "./actions";
5
+ export * from "./focus-session";
6
+ export { StudyRequestError } from "./study";
7
+ export * from "./study-search";
8
+ export const MEDITATION_SOURCE = "game.action.meditation";
9
+ export const STUDY_SOURCE = "action.study";
10
+ /** Compose action and study requests using the host's credentials and surface. */
11
+ export function createFocusApi(host, getToken, surface) {
12
+ const actions = createActionsApi(host, getToken);
13
+ const study = createStudyApi(host, getToken);
14
+ return {
15
+ catalog: actions.catalog,
16
+ active: actions.me,
17
+ start(request) {
18
+ return actions.start({ ...request, surface });
19
+ },
20
+ event: actions.event,
21
+ finish: actions.finish,
22
+ studyCatalog: study.catalog,
23
+ subjects: study.subjects,
24
+ createSubject: study.createSubject,
25
+ updateSubject: study.updateSubject,
26
+ deleteSubject: study.deleteSubject,
27
+ summary: study.summary,
28
+ sessions: study.sessions,
29
+ };
30
+ }
@@ -1,5 +1,5 @@
1
1
  import * as Messenger from "../dialog/remote/Messenger";
2
- import type { Flow, ProfileIdentity } from "../types";
2
+ import type { Flow, Payload, ProfileIdentity, Topic } from "../types";
3
3
  /**
4
4
  * Permissions the dialog iframe needs to run WebAuthn passkey ceremonies and
5
5
  * copy recovery values. Shared by every Flow iframe host (the dialog host and
@@ -48,3 +48,4 @@ export declare function hostIdentity(flow: Flow): ProfileIdentity | null;
48
48
  * out — instead of leaving the request to time out.
49
49
  */
50
50
  export declare function answerTokenRequest(bridge: Messenger.Bridge, flow: Flow, id: string): Promise<void>;
51
+ export declare function sendBridgeNotification<T extends Topic>(bridge: Messenger.Bridge | null | undefined, topic: T, payload: Payload<T>): void;
@@ -71,5 +71,15 @@ export function hostIdentity(flow) {
71
71
  */
72
72
  export async function answerTokenRequest(bridge, flow, id) {
73
73
  const token = await flow.getToken().catch(() => null);
74
- void bridge.send("__internal", { type: "profile-token", id, token });
74
+ sendBridgeNotification(bridge, "__internal", {
75
+ type: "profile-token",
76
+ id,
77
+ token,
78
+ });
79
+ }
80
+ export function sendBridgeNotification(bridge, topic, payload) {
81
+ void bridge?.send(topic, payload).catch((error) => {
82
+ if (!(error instanceof Messenger.BridgeDestroyedError))
83
+ throw error;
84
+ });
75
85
  }
@@ -1,13 +1,16 @@
1
1
  export { defaultIdHost, isLocalHostname } from "../id-host";
2
- export type { AccessKeyOptions, ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionSurface, ActionsApi, ActionTransitionKind, ActiveActionResponse, ActiveSessionConflict, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, CreateStudySubjectInput, CurrentAction, DialogHost, FinishActionRequest, FinishActionResponse, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, ListStudySessionsOptions, ListStudySubjectsOptions, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, RoomMemberEntry, Session, StartActionInput, StartActionResponse, StartActionResult, StudyApi, StudyCatalog, StudyCategory, StudyCategoryTotals, StudyField, StudyRange, StudySessionEntry, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRefusal, StudySubjectRemovalResponse, StudySubjectResponse, StudySubjectTotals, StudySummary, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, XpMode, } from "../types";
2
+ export type { AccessKeyOptions, ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionSurface, ActionsApi, ActionTransitionKind, ActiveAction, ActiveActionResponse, ActiveSessionConflict, AdditionalSession, Address, CatalogFieldRef, ClockAnchor, ConnectCapabilities, ConnectResponse, CreateFlowOptions, CreateStudySubjectInput, CurrentAction, DialogHost, FinishActionRequest, FinishActionResponse, FinishedSummary, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, FocusApi, FocusPhase, FocusSessionEvent, FocusSessionState, ListStudySessionsOptions, ListStudySubjectsOptions, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, RoomMemberEntry, Session, StartActionInput, StartActionResponse, StartActionResult, StartFocusRequest, StudyApi, StudyCatalog, StudyCategory, StudyCategoryTotals, StudyField, StudyRange, StudySearchHit, StudySessionEntry, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRefusal, StudySubjectRemovalResponse, StudySubjectResponse, StudySubjectTotals, StudySummary, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, XpMode, } from "../types";
3
3
  export { consistencyCurve, estimateActionXp, FLOW_SCORE_MAX, flowMultiplier, } from "../xp/curve";
4
4
  export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "./actions";
5
5
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
6
6
  export { createDialogHost } from "./dialog-host";
7
7
  export { createFlowWidget } from "./flow-widget";
8
+ export { actionHeadline, anchorFor, catalogFieldIndex, createFocusApi, elapsedSeconds, fieldsCoveredBySubjects, focusSessionReducer, formatElapsed, hitLabel, INITIAL_FOCUS_STATE, MEDITATION_SOURCE, matchScore, normalizeSearchText, REANCHOR_TOLERANCE_SECONDS, recentSubjects, STUDY_SOURCE, searchStudy, shouldReanchor, } from "./focus";
8
9
  export { METHODS } from "./methods";
9
10
  export { closeProfile, openProfile } from "./open-profile";
10
11
  export { createProfileButton } from "./profile-button";
12
+ export type { ReportOperation, ReportRequest, ReportResponse } from "./reports";
13
+ export { createReportsApi, ReportRequestError } from "./reports";
11
14
  export { createRoomsApi, RoomsRequestError } from "./rooms";
12
15
  export { createStaticFlow } from "./static-flow";
13
16
  export { createStudyApi, StudyRequestError } from "./study";
@@ -4,9 +4,11 @@ export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from
4
4
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
5
5
  export { createDialogHost } from "./dialog-host";
6
6
  export { createFlowWidget } from "./flow-widget";
7
+ export { actionHeadline, anchorFor, catalogFieldIndex, createFocusApi, elapsedSeconds, fieldsCoveredBySubjects, focusSessionReducer, formatElapsed, hitLabel, INITIAL_FOCUS_STATE, MEDITATION_SOURCE, matchScore, normalizeSearchText, REANCHOR_TOLERANCE_SECONDS, recentSubjects, STUDY_SOURCE, searchStudy, shouldReanchor, } from "./focus";
7
8
  export { METHODS } from "./methods";
8
9
  export { closeProfile, openProfile } from "./open-profile";
9
10
  export { createProfileButton } from "./profile-button";
11
+ export { createReportsApi, ReportRequestError } from "./reports";
10
12
  export { createRoomsApi, RoomsRequestError } from "./rooms";
11
13
  export { createStaticFlow } from "./static-flow";
12
14
  export { createStudyApi, StudyRequestError } from "./study";
@@ -18,7 +18,7 @@ export type JsonApiError = (status: number, message: string, reason?: string) =>
18
18
  * raw status and payload for the few responses a client treats as values (a
19
19
  * start's 409); `request` is the common path that throws on any non-2xx.
20
20
  */
21
- export declare function createJsonApi(host: string, prefix: string, getToken: () => Promise<string | null>, makeError: JsonApiError): {
21
+ export declare function createJsonApi(host: string, prefix: string, getToken: () => Promise<string | null>, makeError: JsonApiError, mapFailure?: (exchange: JsonExchange) => Error): {
22
22
  exchange: (path: string, options?: JsonRequestOptions) => Promise<JsonExchange>;
23
23
  request: <T>(path: string, options?: JsonRequestOptions) => Promise<T>;
24
24
  failure: (exchange: JsonExchange) => Error;
@@ -11,10 +11,12 @@ const failureSchema = z.object({
11
11
  * raw status and payload for the few responses a client treats as values (a
12
12
  * start's 409); `request` is the common path that throws on any non-2xx.
13
13
  */
14
- export function createJsonApi(host, prefix, getToken, makeError) {
14
+ export function createJsonApi(host, prefix, getToken, makeError, mapFailure) {
15
15
  /** The error a non-2xx exchange throws: the route's own wording when it
16
16
  * sent one, and its machine code as `reason`. */
17
17
  function failure(exchange) {
18
+ if (mapFailure)
19
+ return mapFailure(exchange);
18
20
  const parsed = failureSchema.safeParse(exchange.payload);
19
21
  const { error, reason } = parsed.success ? parsed.data : {};
20
22
  return makeError(exchange.status, error ?? `Request failed (${exchange.status})`, reason ?? error);
@@ -1,7 +1,7 @@
1
1
  import { resolveIdHost } from "../id-host";
2
2
  import { isValidUsername } from "../usernames";
3
3
  import { getFlow, requireFlow } from "./create-flow";
4
- import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, } from "./iframe-host";
4
+ import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, sendBridgeNotification, } from "./iframe-host";
5
5
  import { setOverlayFocus } from "./overlay-focus";
6
6
  // One overlay per page, kept for the page's lifetime. The alternative — a
7
7
  // handle per call site — would put a second fullscreen iframe over the host the
@@ -47,7 +47,7 @@ function createViewer(options) {
47
47
  // `/dialog/` and `init` asks it to land on the profile. Sending the route
48
48
  // here rather than as a follow-up message keeps the sign-in root from ever
49
49
  // mounting, even for the frame it takes to navigate away from it.
50
- void opened.send("__internal", {
50
+ sendBridgeNotification(opened, "__internal", {
51
51
  type: "init",
52
52
  mode: "iframe",
53
53
  referrer: { title: document.title },
@@ -90,11 +90,14 @@ function createViewer(options) {
90
90
  // re-pointed in place. Tearing it down and rebuilding would reload the
91
91
  // dialog and lose the open animation on every profile after the first.
92
92
  if (mounted)
93
- void live.send("__internal", { type: "profile-view", username });
93
+ sendBridgeNotification(live, "__internal", {
94
+ type: "profile-view",
95
+ username,
96
+ });
94
97
  // Pushed on every open rather than tracked with a subscription: it is only
95
98
  // read while a profile is on screen, and sending it here means the "this is
96
99
  // you" link is decided from the session as it stands at that moment.
97
- void live.send("__internal", {
100
+ sendBridgeNotification(live, "__internal", {
98
101
  type: "profile-identity",
99
102
  identity: hostIdentity(flow),
100
103
  });
@@ -102,7 +105,7 @@ function createViewer(options) {
102
105
  onClose = nextOnClose;
103
106
  visible = true;
104
107
  applyHitTesting();
105
- void live.send("__internal", { type: "dialog-shown" });
108
+ sendBridgeNotification(live, "__internal", { type: "dialog-shown" });
106
109
  }
107
110
  // Visibility is hit-testing plus a message, and nothing else. The overlay
108
111
  // iframe is NEVER set to `display: none` — that pauses the iframe's
@@ -116,7 +119,7 @@ function createViewer(options) {
116
119
  visible = false;
117
120
  window.removeEventListener("keydown", onKeyDown);
118
121
  applyHitTesting();
119
- void bridge.send("__internal", { type: "dialog-hidden" });
122
+ sendBridgeNotification(bridge, "__internal", { type: "dialog-hidden" });
120
123
  const notifyClose = onClose;
121
124
  onClose = undefined;
122
125
  notifyClose?.();
@@ -131,7 +134,7 @@ function createViewer(options) {
131
134
  if (next === theme)
132
135
  return;
133
136
  theme = next;
134
- void bridge?.send("__internal", {
137
+ sendBridgeNotification(bridge, "__internal", {
135
138
  type: "set-theme",
136
139
  theme: { colorScheme: next },
137
140
  });
@@ -1,6 +1,6 @@
1
1
  import { resolveIdHost } from "../id-host";
2
2
  import { getFlow, requireFlow } from "./create-flow";
3
- import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, } from "./iframe-host";
3
+ import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, sendBridgeNotification, } from "./iframe-host";
4
4
  import { setOverlayFocus } from "./overlay-focus";
5
5
  const POSITION_STYLE = {
6
6
  "top-right": { top: "0", right: "0" },
@@ -62,7 +62,7 @@ export function createProfileButton(options) {
62
62
  const bridge = bridgeToWindow(frame.contentWindow, {
63
63
  targetOrigin: hostOrigin,
64
64
  });
65
- bridge.send("__internal", {
65
+ sendBridgeNotification(bridge, "__internal", {
66
66
  type: "init",
67
67
  mode: "iframe",
68
68
  referrer: { title: document.title },
@@ -130,7 +130,7 @@ export function createProfileButton(options) {
130
130
  void answerTokenRequest(dialogBridge, flow, payload.id);
131
131
  }
132
132
  });
133
- void dialogBridge.send("__internal", {
133
+ sendBridgeNotification(dialogBridge, "__internal", {
134
134
  type: "profile-identity",
135
135
  identity: hostIdentity(flow),
136
136
  });
@@ -147,14 +147,18 @@ export function createProfileButton(options) {
147
147
  return;
148
148
  dialog.style.pointerEvents = "auto";
149
149
  setOverlayFocus(dialog, true);
150
- void dialogBridge?.send("__internal", { type: "dialog-shown" });
150
+ sendBridgeNotification(dialogBridge, "__internal", {
151
+ type: "dialog-shown",
152
+ });
151
153
  }
152
154
  function hideDialog() {
153
155
  if (!dialog)
154
156
  return;
155
157
  dialog.style.pointerEvents = "none";
156
158
  setOverlayFocus(dialog, false);
157
- void dialogBridge?.send("__internal", { type: "dialog-hidden" });
159
+ sendBridgeNotification(dialogBridge, "__internal", {
160
+ type: "dialog-hidden",
161
+ });
158
162
  }
159
163
  // ----- keep both iframes' identity in sync with the Flow session -----
160
164
  // flow.subscribe fires on any store change (jwt/credential/address too), so
@@ -166,9 +170,12 @@ export function createProfileButton(options) {
166
170
  if (key === lastIdentityKey)
167
171
  return;
168
172
  lastIdentityKey = key;
169
- void pillBridge.send("__internal", { type: "profile-identity", identity });
173
+ sendBridgeNotification(pillBridge, "__internal", {
174
+ type: "profile-identity",
175
+ identity,
176
+ });
170
177
  if (dialogBridge)
171
- void dialogBridge.send("__internal", {
178
+ sendBridgeNotification(dialogBridge, "__internal", {
172
179
  type: "profile-identity",
173
180
  identity,
174
181
  });
@@ -180,11 +187,11 @@ export function createProfileButton(options) {
180
187
  if (next === theme)
181
188
  return;
182
189
  theme = next;
183
- void pillBridge.send("__internal", {
190
+ sendBridgeNotification(pillBridge, "__internal", {
184
191
  type: "set-theme",
185
192
  theme: { colorScheme: next },
186
193
  });
187
- void dialogBridge?.send("__internal", {
194
+ sendBridgeNotification(dialogBridge, "__internal", {
188
195
  type: "set-theme",
189
196
  theme: { colorScheme: next },
190
197
  });
@@ -0,0 +1,9 @@
1
+ import type { ReportsApi, ReportTransport } from "../types/reports";
2
+ export type { ReportOperation, ReportRequest, ReportResponse, } from "../types/reports";
3
+ export declare class ReportRequestError extends Error {
4
+ readonly status: number;
5
+ readonly response: string;
6
+ constructor(status: number, response: string);
7
+ }
8
+ /** Typed service reports. Keep the reporting bearer on trusted servers only. */
9
+ export declare function createReportsApi(host: string, token: string, transport?: ReportTransport): ReportsApi;
@@ -0,0 +1,30 @@
1
+ import { httpOperations } from "../contracts/http";
2
+ export class ReportRequestError extends Error {
3
+ status;
4
+ response;
5
+ constructor(status, response) {
6
+ super(`Flow report failed (${status})`);
7
+ this.status = status;
8
+ this.response = response;
9
+ this.name = "ReportRequestError";
10
+ }
11
+ }
12
+ /** Typed service reports. Keep the reporting bearer on trusted servers only. */
13
+ export function createReportsApi(host, token, transport = fetch) {
14
+ return async function report(operation, body) {
15
+ const contract = httpOperations[operation];
16
+ const request = contract.request.parse(body);
17
+ const response = await transport(new URL(contract.path, host), {
18
+ method: "POST",
19
+ headers: {
20
+ authorization: `Bearer ${token}`,
21
+ "content-type": "application/json",
22
+ },
23
+ body: JSON.stringify(request),
24
+ });
25
+ if (!response.ok)
26
+ throw new ReportRequestError(response.status, await response.text());
27
+ // SAFETY: the selected operation owns both this response schema and the mapped response type.
28
+ return contract.response.parse(await response.json());
29
+ };
30
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { createJsonApi } from "./json-api";
2
3
  /** Every rooms endpoint answers a failure as `{ error, reason? }`. */
3
4
  const failureSchema = z.object({
4
5
  error: z.string().optional(),
@@ -22,32 +23,12 @@ export class RoomsRequestError extends Error {
22
23
  * bearer and fail fast with a 401-shaped error when no session exists.
23
24
  */
24
25
  export function createRoomsApi(host, getToken) {
25
- async function request(path, options = {}) {
26
- const headers = {};
27
- const token = await getToken();
28
- if (token)
29
- headers.Authorization = `Bearer ${token}`;
30
- else if (options.authRequired) {
31
- throw new RoomsRequestError(401, "Sign in first");
32
- }
33
- if (options.body !== undefined)
34
- headers["Content-Type"] = "application/json";
35
- const res = await fetch(`${host}/api/rooms${path}`, {
36
- method: options.method ?? "GET",
37
- headers,
38
- body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
39
- });
40
- const payload = await res.json().catch(() => ({}));
41
- if (!res.ok) {
42
- const failure = failureSchema.safeParse(payload);
43
- throw new RoomsRequestError(res.status, failure.success && failure.data.error
44
- ? failure.data.error
45
- : `Rooms request failed (${res.status})`, failure.success ? failure.data.reason : undefined);
46
- }
47
- /* SAFETY: each caller names the response type of the rooms endpoint it just called; a
48
- non-ok status has already thrown above with the server's own wording. */
49
- return payload;
50
- }
26
+ const { request } = createJsonApi(host, "/api/rooms", getToken, (status, message, reason) => new RoomsRequestError(status, message, reason), ({ status, payload }) => {
27
+ const parsed = failureSchema.safeParse(payload);
28
+ return new RoomsRequestError(status, parsed.success && parsed.data.error
29
+ ? parsed.data.error
30
+ : `Rooms request failed (${status})`, parsed.success ? parsed.data.reason : undefined);
31
+ });
51
32
  const slugPath = (slug) => `/${encodeURIComponent(slug)}`;
52
33
  const act = (slug, action, body) => request(`${slugPath(slug)}/${action}`, {
53
34
  method: "POST",
@@ -0,0 +1,20 @@
1
+ import type { CatalogFieldRef, StudyCatalog, StudySearchHit, StudySubject } from "../types";
2
+ export declare function normalizeSearchText(text: string): string;
3
+ /** 4 exact, 3 prefix, 2 a later word's prefix, 1 substring, 0 no match.
4
+ * Both sides are normalized here so callers can pass raw labels. */
5
+ export declare function matchScore(query: string, candidate: string): number;
6
+ export declare function catalogFieldIndex(catalog: StudyCatalog): Map<string, CatalogFieldRef>;
7
+ /** Live subjects newest-used first; auth stamps `lastUsedAt` at creation, so
8
+ * a subject never studied sorts by when it was added. */
9
+ export declare function recentSubjects(subjects: readonly StudySubject[], limit?: number): StudySubject[];
10
+ /** Catalog fields the user already keeps under the field's own name are
11
+ * hidden from results — the subject hit covers them and picking the field
12
+ * again would only re-create the same subject. A renamed subject ("Math for
13
+ * my test" under Algebra) leaves the field visible for a second subject. */
14
+ export declare function fieldsCoveredBySubjects(subjects: readonly StudySubject[], index: Map<string, CatalogFieldRef>): Set<string>;
15
+ export declare function searchStudy(query: string, options: {
16
+ catalog: StudyCatalog;
17
+ subjects: readonly StudySubject[];
18
+ limit?: number;
19
+ }): StudySearchHit[];
20
+ export declare function hitLabel(hit: StudySearchHit): string;