@flow-industries/id 0.19.4 → 0.20.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 { ActionsApi, ActiveSessionConflict, StartActionResult } from "../types";
2
+ /** Thrown on any non-2xx actions response other than a start's 409;
3
+ * `reason` carries the route's machine-readable code (`rate_limited`,
4
+ * `not_found`, `subject_required`, ...). */
5
+ export declare class ActionsRequestError extends Error {
6
+ readonly status: number;
7
+ readonly reason?: string | undefined;
8
+ constructor(status: number, message: string, reason?: string | undefined);
9
+ }
10
+ /** Whether a start collided with a running session instead of opening one. */
11
+ export declare function isActiveSessionConflict(result: StartActionResult): result is ActiveSessionConflict;
12
+ /**
13
+ * Thin typed client for the caller's own action sessions, bound to a host and
14
+ * the Flow token supplier — the `createRoomsApi` pattern. The catalog is
15
+ * public; everything under `/me` attaches the caller's bearer and fails fast
16
+ * with a 401-shaped error when no session exists. A start that collides with
17
+ * a running session resolves to the conflict rather than throwing, because a
18
+ * "switch?" prompt is the ordinary next step, not a fault.
19
+ */
20
+ export declare function createActionsApi(host: string, getToken: () => Promise<string | null>): ActionsApi;
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+ import { createJsonApi } from "./json-api";
3
+ /** Thrown on any non-2xx actions response other than a start's 409;
4
+ * `reason` carries the route's machine-readable code (`rate_limited`,
5
+ * `not_found`, `subject_required`, ...). */
6
+ export class ActionsRequestError extends Error {
7
+ status;
8
+ reason;
9
+ constructor(status, message, reason) {
10
+ super(message);
11
+ this.status = status;
12
+ this.reason = reason;
13
+ this.name = "ActionsRequestError";
14
+ }
15
+ }
16
+ const conflictSchema = z.object({
17
+ error: z.literal("active_session"),
18
+ active: z.object({ sessionId: z.string() }).loose(),
19
+ });
20
+ /** Whether a start collided with a running session instead of opening one. */
21
+ export function isActiveSessionConflict(result) {
22
+ return "error" in result && result.error === "active_session";
23
+ }
24
+ /**
25
+ * Thin typed client for the caller's own action sessions, bound to a host and
26
+ * the Flow token supplier — the `createRoomsApi` pattern. The catalog is
27
+ * public; everything under `/me` attaches the caller's bearer and fails fast
28
+ * with a 401-shaped error when no session exists. A start that collides with
29
+ * a running session resolves to the conflict rather than throwing, because a
30
+ * "switch?" prompt is the ordinary next step, not a fault.
31
+ */
32
+ export function createActionsApi(host, getToken) {
33
+ const api = createJsonApi(host, "/api/actions", getToken, (status, message, reason) => new ActionsRequestError(status, message, reason));
34
+ return {
35
+ catalog: () => api.request("/catalog"),
36
+ me: () => api.request("/me", { authRequired: true }),
37
+ async start(input) {
38
+ const body = {
39
+ ...input,
40
+ sessionId: input.sessionId ?? crypto.randomUUID(),
41
+ };
42
+ const result = await api.exchange("/me/start", {
43
+ method: "POST",
44
+ body,
45
+ authRequired: true,
46
+ });
47
+ if (result.status === 409 &&
48
+ conflictSchema.safeParse(result.payload).success) {
49
+ /* SAFETY: the schema just confirmed the 409 body is the active_session conflict. */
50
+ return result.payload;
51
+ }
52
+ if (!result.ok)
53
+ throw api.failure(result);
54
+ /* SAFETY: a 2xx from /me/start is the { session } envelope. */
55
+ return result.payload;
56
+ },
57
+ event: (request) => api.request("/me/event", {
58
+ method: "POST",
59
+ body: request,
60
+ authRequired: true,
61
+ }),
62
+ finish: (request) => api.request("/me/finish", {
63
+ method: "POST",
64
+ body: request,
65
+ authRequired: true,
66
+ }),
67
+ };
68
+ }
@@ -1,5 +1,7 @@
1
1
  export { defaultIdHost, isLocalHostname } from "../id-host";
2
- export type { AccessKeyOptions, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
2
+ export type { AccessKeyOptions, ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionSurface, ActionsApi, ActionTransitionKind, ActiveActionResponse, ActiveSessionConflict, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, CreateStudySubjectInput, DialogHost, FinishActionRequest, FinishActionResponse, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, ListStudySubjectsOptions, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, Session, StartActionInput, StartActionResponse, StartActionResult, StudyApi, StudyCatalog, StudyCategory, StudyField, StudySubject, StudySubjectList, StudySubjectRefusal, StudySubjectRemovalResponse, StudySubjectResponse, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, XpMode, } from "../types";
3
+ export { consistencyCurve, estimateActionXp, FLOW_SCORE_MAX, flowMultiplier, } from "../xp/curve";
4
+ export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "./actions";
3
5
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
4
6
  export { createDialogHost } from "./dialog-host";
5
7
  export { createFlowWidget } from "./flow-widget";
@@ -8,3 +10,4 @@ export { closeProfile, openProfile } from "./open-profile";
8
10
  export { createProfileButton } from "./profile-button";
9
11
  export { createRoomsApi, RoomsRequestError } from "./rooms";
10
12
  export { createStaticFlow } from "./static-flow";
13
+ export { createStudyApi, StudyRequestError } from "./study";
@@ -1,4 +1,6 @@
1
1
  export { defaultIdHost, isLocalHostname } from "../id-host";
2
+ export { consistencyCurve, estimateActionXp, FLOW_SCORE_MAX, flowMultiplier, } from "../xp/curve";
3
+ export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "./actions";
2
4
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
3
5
  export { createDialogHost } from "./dialog-host";
4
6
  export { createFlowWidget } from "./flow-widget";
@@ -7,3 +9,4 @@ export { closeProfile, openProfile } from "./open-profile";
7
9
  export { createProfileButton } from "./profile-button";
8
10
  export { createRoomsApi, RoomsRequestError } from "./rooms";
9
11
  export { createStaticFlow } from "./static-flow";
12
+ export { createStudyApi, StudyRequestError } from "./study";
@@ -0,0 +1,25 @@
1
+ export type JsonRequestOptions = {
2
+ method?: "GET" | "POST" | "PATCH" | "DELETE";
3
+ body?: unknown;
4
+ /** Fail fast with a 401-shaped error when the caller has no token, instead
5
+ * of spending a request the server is going to refuse. */
6
+ authRequired?: boolean;
7
+ };
8
+ export type JsonExchange = {
9
+ ok: boolean;
10
+ status: number;
11
+ /** The decoded JSON body, or `{}` when there was none to decode. */
12
+ payload: unknown;
13
+ };
14
+ export type JsonApiError = (status: number, message: string, reason?: string) => Error;
15
+ /**
16
+ * The transport under the typed clients: one host, one route prefix, the Flow
17
+ * token supplier, and the error class a client throws. `exchange` returns the
18
+ * raw status and payload for the few responses a client treats as values (a
19
+ * start's 409); `request` is the common path that throws on any non-2xx.
20
+ */
21
+ export declare function createJsonApi(host: string, prefix: string, getToken: () => Promise<string | null>, makeError: JsonApiError): {
22
+ exchange: (path: string, options?: JsonRequestOptions) => Promise<JsonExchange>;
23
+ request: <T>(path: string, options?: JsonRequestOptions) => Promise<T>;
24
+ failure: (exchange: JsonExchange) => Error;
25
+ };
@@ -0,0 +1,51 @@
1
+ import { z } from "zod";
2
+ /** Every first-party JSON route answers a failure as `{ error, reason? }`;
3
+ * for the actions and study routes `error` is itself the machine code. */
4
+ const failureSchema = z.object({
5
+ error: z.string().optional(),
6
+ reason: z.string().optional(),
7
+ });
8
+ /**
9
+ * The transport under the typed clients: one host, one route prefix, the Flow
10
+ * token supplier, and the error class a client throws. `exchange` returns the
11
+ * raw status and payload for the few responses a client treats as values (a
12
+ * start's 409); `request` is the common path that throws on any non-2xx.
13
+ */
14
+ export function createJsonApi(host, prefix, getToken, makeError) {
15
+ /** The error a non-2xx exchange throws: the route's own wording when it
16
+ * sent one, and its machine code as `reason`. */
17
+ function failure(exchange) {
18
+ const parsed = failureSchema.safeParse(exchange.payload);
19
+ const { error, reason } = parsed.success ? parsed.data : {};
20
+ return makeError(exchange.status, error ?? `Request failed (${exchange.status})`, reason ?? error);
21
+ }
22
+ async function exchange(path, options = {}) {
23
+ const headers = {};
24
+ const token = await getToken();
25
+ if (token) {
26
+ headers.Authorization = `Bearer ${token}`;
27
+ }
28
+ else if (options.authRequired) {
29
+ throw makeError(401, "Sign in first");
30
+ }
31
+ if (options.body !== undefined) {
32
+ headers["Content-Type"] = "application/json";
33
+ }
34
+ const res = await fetch(`${host}${prefix}${path}`, {
35
+ method: options.method ?? "GET",
36
+ headers,
37
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
38
+ });
39
+ const payload = await res.json().catch(() => ({}));
40
+ return { ok: res.ok, status: res.status, payload };
41
+ }
42
+ async function request(path, options = {}) {
43
+ const result = await exchange(path, options);
44
+ if (!result.ok)
45
+ throw failure(result);
46
+ /* SAFETY: each caller names the response type of the route it just called; a
47
+ non-ok status has already thrown above with the server's own wording. */
48
+ return result.payload;
49
+ }
50
+ return { exchange, request, failure };
51
+ }
@@ -0,0 +1,16 @@
1
+ import type { StudyApi } from "../types";
2
+ /** Thrown on any non-2xx study response; `reason` carries the route's
3
+ * machine-readable code (`name_taken`, `subject_limit`, `field_unknown`,
4
+ * `not_found`, ...). */
5
+ export declare class StudyRequestError extends Error {
6
+ readonly status: number;
7
+ readonly reason?: string | undefined;
8
+ constructor(status: number, message: string, reason?: string | undefined);
9
+ }
10
+ /**
11
+ * Thin typed client for the study catalog and the caller's study subjects,
12
+ * bound to a host and the Flow token supplier. The catalog is public and
13
+ * long-cached by the browser; the subject routes attach the caller's bearer
14
+ * and fail fast with a 401-shaped error when no session exists.
15
+ */
16
+ export declare function createStudyApi(host: string, getToken: () => Promise<string | null>): StudyApi;
@@ -0,0 +1,42 @@
1
+ import { createJsonApi } from "./json-api";
2
+ /** Thrown on any non-2xx study response; `reason` carries the route's
3
+ * machine-readable code (`name_taken`, `subject_limit`, `field_unknown`,
4
+ * `not_found`, ...). */
5
+ export class StudyRequestError extends Error {
6
+ status;
7
+ reason;
8
+ constructor(status, message, reason) {
9
+ super(message);
10
+ this.status = status;
11
+ this.reason = reason;
12
+ this.name = "StudyRequestError";
13
+ }
14
+ }
15
+ /**
16
+ * Thin typed client for the study catalog and the caller's study subjects,
17
+ * bound to a host and the Flow token supplier. The catalog is public and
18
+ * long-cached by the browser; the subject routes attach the caller's bearer
19
+ * and fail fast with a 401-shaped error when no session exists.
20
+ */
21
+ export function createStudyApi(host, getToken) {
22
+ const api = createJsonApi(host, "/api/study", getToken, (status, message, reason) => new StudyRequestError(status, message, reason));
23
+ const subjectPath = (id) => `/subjects/${encodeURIComponent(id)}`;
24
+ return {
25
+ catalog: () => api.request("/catalog"),
26
+ subjects: (options) => api.request(options?.archived ? "/subjects?archived=1" : "/subjects", { authRequired: true }),
27
+ createSubject: (input) => api.request("/subjects", {
28
+ method: "POST",
29
+ body: input,
30
+ authRequired: true,
31
+ }),
32
+ updateSubject: (id, patch) => api.request(subjectPath(id), {
33
+ method: "PATCH",
34
+ body: patch,
35
+ authRequired: true,
36
+ }),
37
+ deleteSubject: (id) => api.request(subjectPath(id), {
38
+ method: "DELETE",
39
+ authRequired: true,
40
+ }),
41
+ };
42
+ }
@@ -6,3 +6,6 @@
6
6
  /** Reads a named string field off a driver error. */
7
7
  export declare function errField(cause: unknown, key: string): string;
8
8
  export declare function errCause(cause: unknown): object | undefined;
9
+ /** Whether a driver error is Postgres's unique_violation (23505), read off the
10
+ * error itself or the cause a query wrapper nests it under. */
11
+ export declare function isUniqueViolation(cause: unknown): boolean;
@@ -15,3 +15,9 @@ export function errCause(cause) {
15
15
  const value = Object.entries(Object(cause)).find(([k]) => k === "cause")?.[1];
16
16
  return isObject(value) ? value : undefined;
17
17
  }
18
+ /** Whether a driver error is Postgres's unique_violation (23505), read off the
19
+ * error itself or the cause a query wrapper nests it under. */
20
+ export function isUniqueViolation(cause) {
21
+ return (errField(cause, "code") === "23505" ||
22
+ errField(errCause(cause), "code") === "23505");
23
+ }
@@ -0,0 +1,14 @@
1
+ import type { ActiveActionState, UseActiveActionOptions } from "../types";
2
+ /**
3
+ * The caller's running action session, kept current without polling: it is
4
+ * read from `GET /api/actions/me` on mount, whenever the tab becomes visible
5
+ * again, after this hook's own `start`/`event`/`finish`, and on `refresh()`.
6
+ * Between reads the clock runs locally from the server's `accruedSeconds`, so
7
+ * a reload mid-session recovers the right time and a paused session stands
8
+ * still. Signed-out callers read as idle rather than as an error.
9
+ *
10
+ * ```tsx
11
+ * const { active, elapsedSeconds, start, finish } = useActiveAction();
12
+ * ```
13
+ */
14
+ export declare function useActiveAction(options?: UseActiveActionOptions): ActiveActionState;
@@ -0,0 +1,124 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "../client/actions";
3
+ import { useFlow } from "./hooks";
4
+ function asError(cause) {
5
+ return cause instanceof Error ? cause : new Error(String(cause));
6
+ }
7
+ function elapsedFromAnchor(anchor, now) {
8
+ if (anchor.paused)
9
+ return anchor.accrued;
10
+ return anchor.accrued + (now - anchor.at) / 1000;
11
+ }
12
+ /**
13
+ * The caller's running action session, kept current without polling: it is
14
+ * read from `GET /api/actions/me` on mount, whenever the tab becomes visible
15
+ * again, after this hook's own `start`/`event`/`finish`, and on `refresh()`.
16
+ * Between reads the clock runs locally from the server's `accruedSeconds`, so
17
+ * a reload mid-session recovers the right time and a paused session stands
18
+ * still. Signed-out callers read as idle rather than as an error.
19
+ *
20
+ * ```tsx
21
+ * const { active, elapsedSeconds, start, finish } = useActiveAction();
22
+ * ```
23
+ */
24
+ export function useActiveAction(options = {}) {
25
+ const flow = useFlow();
26
+ const host = options.host ?? flow.host;
27
+ const api = useMemo(() => createActionsApi(host, flow.getToken), [host, flow]);
28
+ const [active, setActive] = useState(null);
29
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
30
+ const [loaded, setLoaded] = useState(false);
31
+ const [error, setError] = useState(null);
32
+ const anchor = useRef(null);
33
+ const activeId = useRef(null);
34
+ // Bumped by every adoption, so a read that was in flight when a start or
35
+ // finish answered cannot land afterwards and resurrect the older state.
36
+ const generation = useRef(0);
37
+ const adopt = useCallback((next) => {
38
+ generation.current += 1;
39
+ anchor.current = next
40
+ ? { accrued: next.accruedSeconds, at: Date.now(), paused: next.paused }
41
+ : null;
42
+ activeId.current = next?.sessionId ?? null;
43
+ setActive(next);
44
+ setElapsedSeconds(next?.accruedSeconds ?? 0);
45
+ }, []);
46
+ const refresh = useCallback(async () => {
47
+ const ticket = generation.current;
48
+ try {
49
+ const next = await api.me();
50
+ if (ticket !== generation.current)
51
+ return;
52
+ adopt(next);
53
+ setError(null);
54
+ }
55
+ catch (cause) {
56
+ if (ticket !== generation.current)
57
+ return;
58
+ if (cause instanceof ActionsRequestError && cause.status === 401) {
59
+ adopt(null);
60
+ setError(null);
61
+ }
62
+ else {
63
+ setError(asError(cause));
64
+ }
65
+ }
66
+ finally {
67
+ setLoaded(true);
68
+ }
69
+ }, [api, adopt]);
70
+ useEffect(() => {
71
+ void refresh();
72
+ const onVisibility = () => {
73
+ if (document.visibilityState === "visible")
74
+ void refresh();
75
+ };
76
+ document.addEventListener("visibilitychange", onVisibility);
77
+ return () => document.removeEventListener("visibilitychange", onVisibility);
78
+ }, [refresh]);
79
+ // A render clock, not a poll: one local tick per second while the session's
80
+ // clock runs, and nothing at all while it is paused or idle.
81
+ const running = active !== null && !active.paused;
82
+ useEffect(() => {
83
+ if (!running)
84
+ return;
85
+ const tick = () => {
86
+ const a = anchor.current;
87
+ if (a)
88
+ setElapsedSeconds(elapsedFromAnchor(a, Date.now()));
89
+ };
90
+ const timer = setInterval(tick, 1000);
91
+ return () => clearInterval(timer);
92
+ }, [running]);
93
+ const start = useCallback(async (input) => {
94
+ const result = await api.start(input);
95
+ if (!isActiveSessionConflict(result))
96
+ adopt(result.session);
97
+ return result;
98
+ }, [api, adopt]);
99
+ const event = useCallback(async (request) => {
100
+ const response = await api.event(request);
101
+ // Whether the clock stopped is the server's derivation from the event
102
+ // log, so a pause or resume re-reads rather than guessing.
103
+ if (request.kind === "pause" || request.kind === "resume") {
104
+ await refresh();
105
+ }
106
+ return response;
107
+ }, [api, refresh]);
108
+ const finish = useCallback(async (request) => {
109
+ const response = await api.finish(request);
110
+ if (activeId.current === request.sessionId)
111
+ adopt(null);
112
+ return response;
113
+ }, [api, adopt]);
114
+ return {
115
+ active,
116
+ elapsedSeconds,
117
+ loading: !loaded,
118
+ error,
119
+ refresh,
120
+ start,
121
+ event,
122
+ finish,
123
+ };
124
+ }
@@ -1,4 +1,5 @@
1
- export type { FlowIdProviderProps, FlowWidgetProps, ProfileButtonProps, UseOpenProfileOptions, } from "../types";
1
+ export type { ActiveActionState, FlowIdProviderProps, FlowWidgetProps, ProfileButtonProps, UseActiveActionOptions, UseOpenProfileOptions, } from "../types";
2
+ export { useActiveAction } from "./active-action";
2
3
  export { FlowWidget } from "./flow-widget";
3
4
  export { useFlow, useFlowId, useFlowState, useRoom, useRooms } from "./hooks";
4
5
  export { useOpenProfile } from "./open-profile";
@@ -1,3 +1,4 @@
1
+ export { useActiveAction } from "./active-action";
1
2
  export { FlowWidget } from "./flow-widget";
2
3
  export { useFlow, useFlowId, useFlowState, useRoom, useRooms } from "./hooks";
3
4
  export { useOpenProfile } from "./open-profile";
@@ -0,0 +1,93 @@
1
+ /** The SDK-side contract for actions and study: what `createActionsApi`,
2
+ * `createStudyApi` and `useActiveAction` take and return. The wire shapes
3
+ * themselves live in `./xp` and `./study`; this file only names the client
4
+ * inputs and the composed results a consumer app programs against. */
5
+ import type { CreateStudySubjectInput, StudyCatalog, StudySubject, StudySubjectList, StudySubjectRemovalResponse, UpdateStudySubjectInput } from "./study";
6
+ import type { ActionCatalogEntry, ActionCatalogResponse, ActionTransitionKind, ActiveActionResponse, ActiveSessionConflict, FinishActionResponse, StartActionResponse, UserActionSurface } from "./xp";
7
+ /** The curve parameters `estimateActionXp` reads. A catalog entry satisfies
8
+ * it, and so does the server's own registry descriptor, so the widget and a
9
+ * consumer app estimate from the same function without a conversion. */
10
+ export type ActionCurve = Pick<ActionCatalogEntry, "mode" | "base" | "tauMinutes" | "dailyCapXp" | "minDurationSeconds" | "flowMultiplier">;
11
+ /** What a live estimate is computed from: the running session's elapsed time
12
+ * plus the day context `GET /api/actions/me` carries. */
13
+ export interface XpEstimateContext {
14
+ elapsedSeconds: number;
15
+ /** Minutes already credited to this source today, so a day-cumulative curve
16
+ * starts from the right point. */
17
+ priorMinutes: number;
18
+ flowScore: number;
19
+ }
20
+ /** Body of POST /api/actions/me/start as the SDK takes it. `sessionId` is the
21
+ * client-minted idempotency key for the whole session; the client mints a UUID
22
+ * when it is omitted. */
23
+ export interface StartActionInput {
24
+ source: string;
25
+ sessionId?: string;
26
+ subjectId?: string;
27
+ /** Recorded as `metadata.surface`; defaults to `web` on the server. */
28
+ surface?: UserActionSurface;
29
+ /** Finish and grant the running session before starting this one. */
30
+ replace?: boolean;
31
+ }
32
+ /** A start either opened (or replayed) a session or collided with a running
33
+ * one. The 409 is a value, not an error: the UI's next step is a "switch?"
34
+ * prompt, not an error state. */
35
+ export type StartActionResult = StartActionResponse | ActiveSessionConflict;
36
+ /** Body of POST /api/actions/me/event. */
37
+ export interface ActionEventRequest {
38
+ sessionId: string;
39
+ kind: ActionTransitionKind;
40
+ }
41
+ /** Response of POST /api/actions/me/event. `recorded` is false when the
42
+ * transition was a no-op (pausing a paused session, a second focus_lost). */
43
+ export interface ActionEventResponse {
44
+ ok: true;
45
+ recorded: boolean;
46
+ }
47
+ /** Body of POST /api/actions/me/finish. */
48
+ export interface FinishActionRequest {
49
+ sessionId: string;
50
+ }
51
+ /** Typed client for the caller's own action sessions and the public catalog. */
52
+ export interface ActionsApi {
53
+ catalog(): Promise<ActionCatalogResponse>;
54
+ /** The caller's running session, or null when idle. */
55
+ me(): Promise<ActiveActionResponse | null>;
56
+ start(input: StartActionInput): Promise<StartActionResult>;
57
+ event(request: ActionEventRequest): Promise<ActionEventResponse>;
58
+ finish(request: FinishActionRequest): Promise<FinishActionResponse>;
59
+ }
60
+ export interface ListStudySubjectsOptions {
61
+ /** Include archived subjects after the live ones. */
62
+ archived?: boolean;
63
+ }
64
+ /** Envelope of every single-subject study route. */
65
+ export interface StudySubjectResponse {
66
+ subject: StudySubject;
67
+ }
68
+ /** Typed client for the study catalog and the caller's study subjects. */
69
+ export interface StudyApi {
70
+ catalog(): Promise<StudyCatalog>;
71
+ subjects(options?: ListStudySubjectsOptions): Promise<StudySubjectList>;
72
+ createSubject(input: CreateStudySubjectInput): Promise<StudySubjectResponse>;
73
+ updateSubject(id: string, patch: UpdateStudySubjectInput): Promise<StudySubjectResponse>;
74
+ deleteSubject(id: string): Promise<StudySubjectRemovalResponse>;
75
+ }
76
+ export type UseActiveActionOptions = {
77
+ /** Override the Flow ID origin; defaults to the resolved Flow's `host`. */
78
+ host?: string;
79
+ };
80
+ /** What `useActiveAction` returns. `elapsedSeconds` is extrapolated locally
81
+ * from the server's `accruedSeconds` and stands still while `paused`. */
82
+ export interface ActiveActionState {
83
+ active: ActiveActionResponse | null;
84
+ elapsedSeconds: number;
85
+ /** True until the first read of `/api/actions/me` has answered. */
86
+ loading: boolean;
87
+ error: Error | null;
88
+ /** Re-reads the running session from the server. */
89
+ refresh: () => Promise<void>;
90
+ start: (input: StartActionInput) => Promise<StartActionResult>;
91
+ event: (request: ActionEventRequest) => Promise<ActionEventResponse>;
92
+ finish: (request: FinishActionRequest) => Promise<FinishActionResponse>;
93
+ }
@@ -0,0 +1,4 @@
1
+ /** The SDK-side contract for actions and study: what `createActionsApi`,
2
+ * `createStudyApi` and `useActiveAction` take and return. The wire shapes
3
+ * themselves live in `./xp` and `./study`; this file only names the client
4
+ * inputs and the composed results a consumer app programs against. */
@@ -1,3 +1,4 @@
1
+ export type { ActionCurve, ActionEventRequest, ActionEventResponse, ActionsApi, ActiveActionState, FinishActionRequest, ListStudySubjectsOptions, StartActionInput, StartActionResult, StudyApi, StudySubjectResponse, UseActiveActionOptions, XpEstimateContext, } from "./actions";
1
2
  export type { AccountSession, AccountSessionsResponse, AdditionalSession, AuthConfig, AuthResponse, AuthResponseWithWebAuthn, BioNormalization, BioRejection, BioWriteRefusal, BioWriteResult, FlowCredential, FlowUser, PasskeyPluginOptions, RevokeAllSessionsResponse, RevokeSessionResponse, SecurityActivityEntry, SecurityActivityResponse, SessionUser, VerifiedFlowJWT, VerifyOptions, WebAuthnSignature, } from "./auth";
2
3
  export type { BodyRegion, CosmeticItem, CosmeticMaterial, CosmeticPaint, CosmeticSlot, EquipConflict, EquippedCosmetic, EquippedCosmetics, EquipRegion, PaintSlot, SettingsConflict, } from "./cosmetics";
3
4
  export { BODY_REGIONS, COSMETIC_MATERIALS } from "./cosmetics";
@@ -12,5 +13,6 @@ export type { CreateRoomEventInput, RoomEventArchive, RoomEventAttendance, RoomE
12
13
  export type { CreateRoomInput, GlobalRole, ModerationSubject, MyRooms, PlayerPositionReport, RoleChangeVerdict, RoomCapabilities, RoomCapability, RoomChannel, RoomDetail, RoomList, RoomMemberEntry, RoomMembers, RoomOccupancy, RoomOwner, RoomPlayerPosition, RoomPresenceEntry, RoomPresenceSnapshot, RoomRestrictionKind, RoomRole, RoomSummary, RoomSurfaceSettings, RoomsApi, RoomVisibility, StaffEntry, UpdateRoomInput, VerifyRoomContext, Viewer, VoiceDecisionReason, VoiceDecisionSubject, VoiceRoomDecision, } from "./rooms";
13
14
  export type { AccessKeyOptions, AccessKeyPreparation, Address, ColorScheme, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, FlowState, FlowWidgetHandle, FlowWidgetName, FlowWidgetProps, Listener, LoginOptions, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, UseOpenProfileOptions, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
14
15
  export type { GracefulShutdownCleanup, GracefulShutdownOptions, GracefulShutdownServer, GracefulShutdownSignal, ResolvedFlowSession, ResolveSessionOptions, SessionRouteOptions, SessionRouteResponse, SessionRouteSession, } from "./server";
16
+ export type { CreateStudySubjectInput, StudyCatalog, StudyCategory, StudyField, StudySubject, StudySubjectCreation, StudySubjectList, StudySubjectRefusal, StudySubjectRemoval, StudySubjectRemovalResponse, StudySubjectUpdate, UpdateStudySubjectInput, } from "./study";
15
17
  export type { CoinAsset, IdentifiedTx, TxApprove, TxConvert, TxSend, TxSwap, } from "./tx";
16
- export type { ActionDayContext, ActionEventKind, ActionSessionState, ActionTransitionKind, ActiveActionResponse, LevelProgress, PublicProfile, PublicProfileRefusal, PublicProfileResult, PublicProfileSignal, PublicUserProfile, XpGrantResult, XpRecentGrant, XpSummary, } from "./xp";
18
+ export type { ActionCatalogEntry, ActionCatalogResponse, ActionDayContext, ActionEventKind, ActionSessionState, ActionSubjectRef, ActionSurface, ActionTransitionKind, ActiveActionResponse, ActiveSessionConflict, FinishActionResponse, LevelProgress, PublicProfile, PublicProfileRefusal, PublicProfileResult, PublicProfileSignal, PublicUserProfile, StartActionResponse, StartUserSessionResult, UserActionSurface, XpGrantResult, XpMode, XpRecentGrant, XpSummary, } from "./xp";
@@ -0,0 +1,88 @@
1
+ /** Study catalog and study-subject shapes that cross the server/SDK boundary. */
2
+ /**
3
+ * One predefined thing a person can study. `slug` is `category/field`,
4
+ * lowercase ASCII and stable forever — it is what a subject row stores and
5
+ * what totals roll up on. `label` and `aliases` are display and search only,
6
+ * so either may change without touching data.
7
+ */
8
+ export interface StudyField {
9
+ slug: string;
10
+ label: string;
11
+ aliases?: readonly string[];
12
+ }
13
+ export interface StudyCategory {
14
+ slug: string;
15
+ label: string;
16
+ fields: readonly StudyField[];
17
+ }
18
+ /** Response of GET /api/study/catalog. */
19
+ export interface StudyCatalog {
20
+ version: number;
21
+ categories: readonly StudyCategory[];
22
+ }
23
+ /**
24
+ * One of the caller's study subjects, as every `/api/study/subjects` route
25
+ * returns it. `field` is a catalog field slug, a bare category slug (a custom
26
+ * subject that rolls up under one category), or null for a subject the catalog
27
+ * has no home for. `name` is the user's own words for it.
28
+ */
29
+ export interface StudySubject {
30
+ id: string;
31
+ field: string | null;
32
+ name: string;
33
+ archivedAt: string | null;
34
+ createdAt: string;
35
+ lastUsedAt: string;
36
+ }
37
+ /** Response of GET /api/study/subjects. */
38
+ export interface StudySubjectList {
39
+ subjects: StudySubject[];
40
+ }
41
+ /** Body of POST /api/study/subjects. `name` defaults to the field's label. */
42
+ export interface CreateStudySubjectInput {
43
+ field?: string | null;
44
+ name?: string;
45
+ }
46
+ /** Body of PATCH /api/study/subjects/:id — every key optional, at least one required. */
47
+ export interface UpdateStudySubjectInput {
48
+ name?: string;
49
+ field?: string | null;
50
+ archived?: boolean;
51
+ }
52
+ /** Why a subject write was refused; each is also the `error` code the route answers with. */
53
+ export type StudySubjectRefusal = "field_unknown" | "name_invalid" | "name_taken" | "subject_limit" | "not_found";
54
+ /** Outcome of a create: `created: false` means a live subject with that name
55
+ * already existed and is returned instead (the route answers 200, not 201). */
56
+ export type StudySubjectCreation = {
57
+ ok: true;
58
+ created: boolean;
59
+ subject: StudySubject;
60
+ } | {
61
+ ok: false;
62
+ reason: Exclude<StudySubjectRefusal, "not_found" | "name_taken">;
63
+ };
64
+ export type StudySubjectUpdate = {
65
+ ok: true;
66
+ subject: StudySubject;
67
+ } | {
68
+ ok: false;
69
+ reason: StudySubjectRefusal;
70
+ };
71
+ /** Outcome of a delete: a subject no session references is removed outright;
72
+ * one with history is archived instead and returned. */
73
+ export type StudySubjectRemoval = {
74
+ ok: true;
75
+ outcome: "deleted";
76
+ subject: null;
77
+ } | {
78
+ ok: true;
79
+ outcome: "archived";
80
+ subject: StudySubject;
81
+ } | {
82
+ ok: false;
83
+ reason: "not_found";
84
+ };
85
+ /** Response of DELETE /api/study/subjects/:id. */
86
+ export type StudySubjectRemovalResponse = Extract<StudySubjectRemoval, {
87
+ ok: true;
88
+ }>;
@@ -0,0 +1 @@
1
+ /** Study catalog and study-subject shapes that cross the server/SDK boundary. */
@@ -1,4 +1,8 @@
1
1
  /** XP / leveling shapes that cross the server/dialog boundary. */
2
+ /** How a source turns activity into XP: a flat amount per grant, XP per
3
+ * minute, or a day-saturating curve. The curves themselves live in
4
+ * `src/xp/registry.ts`. */
5
+ export type XpMode = "fixed" | "linear" | "consistency";
2
6
  export interface LevelProgress {
3
7
  /** Level within the current tier, 1..100 — it resets to 1 on each tier-up. */
4
8
  level: number;
@@ -52,15 +56,95 @@ export type ActionSessionState = "active" | "finished" | "abandoned";
52
56
  export type ActionEventKind = "start" | "pause" | "resume" | "focus_lost" | "focus_gained" | "finish" | "abandon";
53
57
  /** Reporter-supplied transitions accepted while an action is active. */
54
58
  export type ActionTransitionKind = Extract<ActionEventKind, "pause" | "resume" | "focus_lost" | "focus_gained">;
55
- /** Response of GET /api/actions/active — the caller's current in-progress
56
- * action (or null when idle), plus the day context so the timer can render the
57
- * live XP estimate from the timestamp-derived accrued duration. (Always an
58
- * `active` session by definition, so the lifecycle state isn't carried.) */
59
+ /** Where a session was started: the game world under the report token, or a
60
+ * web surface under the caller's own session. Recorded in
61
+ * `action_session.metadata.surface`; the source id stays surface-agnostic, so
62
+ * Meditate is `game.action.meditation` whether it began in the game or in
63
+ * Talk. */
64
+ export type ActionSurface = "game" | "talk" | "web";
65
+ /** The surfaces a caller may claim on POST /api/actions/me/start; `game` is
66
+ * reserved for the report-token path. */
67
+ export type UserActionSurface = Exclude<ActionSurface, "game">;
68
+ /** The study subject a session is attributed to, resolved from the joined
69
+ * `study_subject` row whether or not it has since been archived, so history
70
+ * keeps its name. Null for a source without subjects and for a Study the
71
+ * game started while the user had none. */
72
+ export interface ActionSubjectRef {
73
+ id: string;
74
+ name: string;
75
+ field: string | null;
76
+ }
77
+ /** The caller's own view of one session — the shape of GET /api/actions/me
78
+ * and /active (null when idle), of the `session` a start returns, and of the
79
+ * `active` a 409 carries — plus the day context so a timer can render the
80
+ * live XP estimate from the timestamp-derived accrued duration. The lifecycle
81
+ * state is not carried: an idle caller reads null, and a start or finish
82
+ * answers with the session as it stands at that instant. */
59
83
  export interface ActiveActionResponse extends ActionDayContext {
84
+ sessionId: string;
60
85
  source: string;
61
86
  label: string | null;
87
+ surface: ActionSurface;
88
+ /** ISO timestamp of the session's start. */
89
+ startedAt: string;
90
+ /** Whether the last pause/resume transition left the clock stopped. */
91
+ paused: boolean;
62
92
  accruedSeconds: number;
93
+ subject: ActionSubjectRef | null;
94
+ }
95
+ /** One user-startable action as GET /api/actions/catalog lists it: the
96
+ * registry descriptor minus its reporter, with the curve parameters a client
97
+ * needs to estimate XP the way the server grants it. `label` and
98
+ * `description` are always present (a source without them lists under its
99
+ * id); the curve fields are present only when the source declares them. */
100
+ export interface ActionCatalogEntry {
101
+ id: string;
102
+ label: string;
103
+ description: string;
104
+ mode: XpMode;
105
+ base: number;
106
+ tauMinutes?: number;
107
+ dailyCapXp?: number;
108
+ minDurationSeconds?: number;
109
+ flowMultiplier: boolean;
110
+ userStartable: boolean;
111
+ requiresSubject: boolean;
112
+ }
113
+ /** Response of GET /api/actions/catalog, in registry declaration order. */
114
+ export interface ActionCatalogResponse {
115
+ actions: ActionCatalogEntry[];
116
+ }
117
+ /** Response of POST /api/actions/me/start (201 created, 200 on an idempotent
118
+ * replay of the same sessionId). */
119
+ export interface StartActionResponse {
120
+ session: ActiveActionResponse;
121
+ }
122
+ /** The 409 body of POST /api/actions/me/start: the session already running,
123
+ * which `replace: true` would finish. */
124
+ export interface ActiveSessionConflict {
125
+ error: "active_session";
126
+ active: ActiveActionResponse;
63
127
  }
128
+ /** Response of POST /api/actions/me/finish. Idempotent: a replay answers with
129
+ * the XP and duration the session was credited when it ended. */
130
+ export interface FinishActionResponse {
131
+ ok: true;
132
+ xpGranted: number;
133
+ durationSeconds: number;
134
+ }
135
+ /** Outcome of a user-initiated start. The refusals map to one status each in
136
+ * the route; `subject_unknown` is a subjectId that is not one of the caller's
137
+ * live subjects, `session_id_taken` a client id that another user's session
138
+ * already holds, and `rate_limited` the per-user hourly start cap. */
139
+ export type StartUserSessionResult = {
140
+ status: "created" | "replayed";
141
+ session: ActiveActionResponse;
142
+ } | {
143
+ status: "active_session";
144
+ active: ActiveActionResponse;
145
+ } | {
146
+ status: "unknown_source" | "not_startable" | "subject_required" | "subject_unknown" | "session_id_taken" | "rate_limited";
147
+ };
64
148
  /** Non-sensitive profile facts any player may see about another — nested into
65
149
  * the /api/session/verify payload beside `appearance` so game servers can
66
150
  * broadcast them (avatar + level badge on nametags) without extra fetches.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The pure XP curve math: the Flow Score multiplier, the day-saturating
3
+ * consistency curve, and the live estimate a timer renders from them. No
4
+ * registry, no DB, no I/O — this module is shipped in the SDK and imported by
5
+ * the grant service alike, so a count-up in a consumer app and the ledger row
6
+ * the server writes come from one function and cannot drift.
7
+ */
8
+ import type { ActionCurve, XpEstimateContext } from "../types";
9
+ export declare const FLOW_SCORE_MAX = 10;
10
+ export declare function clampFlowScore(score: number): number;
11
+ /** The Flow Score IS the XP multiplier: score 10 grants 10x. Floored at 1x so a
12
+ * lapsed player (score 0 or 1) still earns full base XP. */
13
+ export declare function flowMultiplier(flowScore: number): number;
14
+ /** The (unrounded) cumulative pre-multiplier XP a consistency source has earned
15
+ * after `totalMinutes` of activity in the day — a saturating curve toward
16
+ * `base`. A single session's grant is the delta between this at the new and old
17
+ * totals, so the first minutes of the day are worth the most. The live timer
18
+ * uses this raw form for a smooth count-up; grants round it. */
19
+ export declare function consistencyCurve(source: Pick<ActionCurve, "base" | "tauMinutes">, totalMinutes: number): number;
20
+ /**
21
+ * The XP a session of `elapsedSeconds` would grant if it finished now,
22
+ * mirroring the grant service: consistency is the delta on the day-cumulative
23
+ * curve from today's prior minutes, linear is `base` per minute, fixed is flat;
24
+ * below the source's minimum duration it is 0. The Flow Score multiplies the
25
+ * result, and a daily cap bounds it by what is left of the day's ceiling after
26
+ * the prior minutes. Unrounded curve, rounded once at the end, so a count-up
27
+ * advances smoothly and lands on the number the ledger will show.
28
+ */
29
+ export declare function estimateActionXp(entry: ActionCurve, context: XpEstimateContext): number;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The pure XP curve math: the Flow Score multiplier, the day-saturating
3
+ * consistency curve, and the live estimate a timer renders from them. No
4
+ * registry, no DB, no I/O — this module is shipped in the SDK and imported by
5
+ * the grant service alike, so a count-up in a consumer app and the ledger row
6
+ * the server writes come from one function and cannot drift.
7
+ */
8
+ export const FLOW_SCORE_MAX = 10;
9
+ export function clampFlowScore(score) {
10
+ return Math.min(FLOW_SCORE_MAX, Math.max(0, Math.trunc(score)));
11
+ }
12
+ /** The Flow Score IS the XP multiplier: score 10 grants 10x. Floored at 1x so a
13
+ * lapsed player (score 0 or 1) still earns full base XP. */
14
+ export function flowMultiplier(flowScore) {
15
+ return Math.max(1, clampFlowScore(flowScore));
16
+ }
17
+ /** The (unrounded) cumulative pre-multiplier XP a consistency source has earned
18
+ * after `totalMinutes` of activity in the day — a saturating curve toward
19
+ * `base`. A single session's grant is the delta between this at the new and old
20
+ * totals, so the first minutes of the day are worth the most. The live timer
21
+ * uses this raw form for a smooth count-up; grants round it. */
22
+ export function consistencyCurve(source, totalMinutes) {
23
+ const tau = source.tauMinutes ?? 40;
24
+ return source.base * (1 - Math.exp(-Math.max(0, totalMinutes) / tau));
25
+ }
26
+ /**
27
+ * The XP a session of `elapsedSeconds` would grant if it finished now,
28
+ * mirroring the grant service: consistency is the delta on the day-cumulative
29
+ * curve from today's prior minutes, linear is `base` per minute, fixed is flat;
30
+ * below the source's minimum duration it is 0. The Flow Score multiplies the
31
+ * result, and a daily cap bounds it by what is left of the day's ceiling after
32
+ * the prior minutes. Unrounded curve, rounded once at the end, so a count-up
33
+ * advances smoothly and lands on the number the ledger will show.
34
+ */
35
+ export function estimateActionXp(entry, context) {
36
+ const { elapsedSeconds, priorMinutes, flowScore } = context;
37
+ if (entry.minDurationSeconds !== undefined &&
38
+ elapsedSeconds < entry.minDurationSeconds) {
39
+ return 0;
40
+ }
41
+ const elapsedMinutes = Math.max(0, elapsedSeconds) / 60;
42
+ const prior = Math.max(0, priorMinutes);
43
+ const multiplier = entry.flowMultiplier ? flowMultiplier(flowScore) : 1;
44
+ let base;
45
+ let priorBase = 0;
46
+ if (entry.mode === "fixed") {
47
+ base = entry.base;
48
+ }
49
+ else if (entry.mode === "linear") {
50
+ base = entry.base * elapsedMinutes;
51
+ priorBase = entry.base * prior;
52
+ }
53
+ else {
54
+ base =
55
+ consistencyCurve(entry, prior + elapsedMinutes) -
56
+ consistencyCurve(entry, prior);
57
+ priorBase = consistencyCurve(entry, prior);
58
+ }
59
+ const xp = Math.max(0, Math.round(base * multiplier));
60
+ if (entry.dailyCapXp === undefined)
61
+ return xp;
62
+ const remaining = Math.max(0, entry.dailyCapXp - Math.round(priorBase * multiplier));
63
+ return Math.min(xp, remaining);
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.19.4",
3
+ "version": "0.20.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",