@homespunapps/core 1.0.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.
package/dist/icons.js ADDED
@@ -0,0 +1,99 @@
1
+ // Icon helpers shared by the relay and CLI — emoji validation and the raster
2
+ // image MIME allowlist for template/app icons.
3
+ //
4
+ // Icons are deliberately constrained: either ONE emoji grapheme (rendered
5
+ // inline as text) or an uploaded raster image (served from the relay's blob
6
+ // store). No external URLs, no SVG (XSS vector), no multi-character strings.
7
+ // Raster image MIME types accepted as an uploaded icon. SVG is deliberately
8
+ // EXCLUDED — it can carry script and is an XSS vector when rendered in an
9
+ // <img> from a same-origin route. Vector/animated-vector and non-image types
10
+ // are rejected.
11
+ export const RASTER_ICON_MIME_ALLOWLIST = [
12
+ "image/png",
13
+ "image/jpeg",
14
+ "image/webp",
15
+ "image/gif",
16
+ ];
17
+ /** True iff `mime` is a raster image type allowed as an icon. */
18
+ export function isRasterImageMime(mime) {
19
+ if (!mime)
20
+ return false;
21
+ // Normalise: drop any `; charset=...` parameter and lowercase.
22
+ const base = mime.split(";")[0].trim().toLowerCase();
23
+ return RASTER_ICON_MIME_ALLOWLIST.includes(base);
24
+ }
25
+ // Upper bound on the raw byte length of an emoji string. A single rendered
26
+ // emoji can be a long ZWJ sequence (e.g. a family/flag with skin-tone
27
+ // modifiers), but 64 bytes is well beyond any real single grapheme and bounds
28
+ // the work the segmenter does.
29
+ export const MAX_ICON_EMOJI_BYTES = 64;
30
+ // Matches a code point with the Extended_Pictographic property — the Unicode
31
+ // property that flags "this is an emoji-ish pictograph". Used to ensure the
32
+ // grapheme actually contains an emoji and isn't a plain letter/digit/symbol.
33
+ const EXTENDED_PICTOGRAPHIC_RE = /\p{Extended_Pictographic}/u;
34
+ // Control characters (C0 + DEL + C1) are never valid in an icon emoji. The
35
+ // control chars in the class are intentional — that's exactly what we reject.
36
+ // eslint-disable-next-line no-control-regex
37
+ const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f-\u009f]/;
38
+ const byteLength = (s) => typeof TextEncoder !== "undefined"
39
+ ? new TextEncoder().encode(s).length
40
+ : Buffer.byteLength(s, "utf8");
41
+ /**
42
+ * Validate an icon emoji. Returns `{ ok: true }` for a single emoji grapheme,
43
+ * or `{ ok: false, error }` with a human-readable reason otherwise.
44
+ *
45
+ * Rules:
46
+ * - non-empty, ≤ MAX_ICON_EMOJI_BYTES bytes
47
+ * - no control characters
48
+ * - exactly ONE grapheme cluster (via Intl.Segmenter)
49
+ * - that grapheme contains at least one Extended_Pictographic code point
50
+ * (so plain ASCII letters/digits and bare symbols are rejected)
51
+ */
52
+ export function validateIconEmoji(raw) {
53
+ if (typeof raw !== "string" || raw.length === 0) {
54
+ return { ok: false, error: "icon_emoji must be a non-empty string" };
55
+ }
56
+ if (byteLength(raw) > MAX_ICON_EMOJI_BYTES) {
57
+ return {
58
+ ok: false,
59
+ error: `icon_emoji must be at most ${MAX_ICON_EMOJI_BYTES} bytes`,
60
+ };
61
+ }
62
+ if (CONTROL_CHAR_RE.test(raw)) {
63
+ return {
64
+ ok: false,
65
+ error: "icon_emoji must not contain control characters",
66
+ };
67
+ }
68
+ // Count grapheme clusters. Intl.Segmenter is available in Node 20+ and all
69
+ // evergreen browsers (our targets); guard defensively anyway.
70
+ let graphemeCount;
71
+ if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
72
+ const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
73
+ // Iterate the segmenter to count grapheme clusters. `[...iterable].length`
74
+ // materialises the segments, but a single emoji is tiny so it's fine.
75
+ graphemeCount = [...seg.segment(raw)].length;
76
+ }
77
+ else {
78
+ // Fallback: count code points (over-counts ZWJ sequences, but the
79
+ // pictographic check below still gates non-emoji input).
80
+ graphemeCount = Array.from(raw).length;
81
+ }
82
+ if (graphemeCount !== 1) {
83
+ return {
84
+ ok: false,
85
+ error: "icon_emoji must be exactly one emoji (a single grapheme)",
86
+ };
87
+ }
88
+ if (!EXTENDED_PICTOGRAPHIC_RE.test(raw)) {
89
+ return {
90
+ ok: false,
91
+ error: "icon_emoji must be an emoji, not a letter, digit, or symbol",
92
+ };
93
+ }
94
+ return { ok: true };
95
+ }
96
+ /** Convenience boolean form of {@link validateIconEmoji}. */
97
+ export function isValidIconEmoji(raw) {
98
+ return validateIconEmoji(raw).ok;
99
+ }
@@ -0,0 +1,13 @@
1
+ export { HomespunClient, HomespunApiError } from "./client.js";
2
+ export type { ClientOptions, RelayResponse, AttachmentRef, UploadBlobOptions, PresignBlobOptions, AttachmentTokenMintResponse, ListBlobsOptions, AttachmentTokenAuditEntry, AttachmentTokenListResponse, QueryResponse, DeployAppRequest, DeployAppResponse, RedeployAppRequest, RedeployAppResponse, AppSummary, AppDetail, AppsPage, AppMember, AddAppMemberResult, AppRow, AppRowsPage, AppFeedEntry, AppFeedPage, } from "./client.js";
3
+ export { openStream } from "./stream.js";
4
+ export type { OpenStreamOptions, StreamHandlers, StreamHandle, } from "./stream.js";
5
+ export { openAppStream, appWsUrlFromAppUrl } from "./app-stream.js";
6
+ export type { OpenAppStreamOptions, AppStreamHandlers, AppStreamHandle, } from "./app-stream.js";
7
+ export { registerAgent } from "./register.js";
8
+ export type { RegisterAgentOptions, RegisterAgentResult } from "./register.js";
9
+ export { feedbackTypeSchema, submitFeedbackSchema } from "./schemas.js";
10
+ export { validateIconEmoji, isValidIconEmoji, isRasterImageMime, RASTER_ICON_MIME_ALLOWLIST, MAX_ICON_EMOJI_BYTES, } from "./icons.js";
11
+ export type { RasterIconMime } from "./icons.js";
12
+ export { MAX_EVENT_TYPE_LENGTH, MAX_IDEMPOTENCY_KEY_LENGTH, MAX_RESPONSE_SNIPPET_LENGTH, MAX_FRAME_SNIPPET_LENGTH, } from "./limits.js";
13
+ export type { AuthorKind, HomespunEvent, SerializedRecord, DeletedRecordRef, RecordDeltaMessage, Template, KeyInfo, TasteInfo, FeedbackType, FeedbackSubmission, FeedbackRecord, FeedbackPage, Callback, RelayError, } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // @homespunapps/core — typed client for the Homespun relay HTTP + WebSocket API.
2
+ // Pure and framework-free: no argv, no MCP, no server deps.
3
+ export { HomespunClient, HomespunApiError } from "./client.js";
4
+ export { openStream } from "./stream.js";
5
+ export { openAppStream, appWsUrlFromAppUrl } from "./app-stream.js";
6
+ export { registerAgent } from "./register.js";
7
+ export { feedbackTypeSchema, submitFeedbackSchema } from "./schemas.js";
8
+ export { validateIconEmoji, isValidIconEmoji, isRasterImageMime, RASTER_ICON_MIME_ALLOWLIST, MAX_ICON_EMOJI_BYTES, } from "./icons.js";
9
+ export { MAX_EVENT_TYPE_LENGTH, MAX_IDEMPOTENCY_KEY_LENGTH, MAX_RESPONSE_SNIPPET_LENGTH, MAX_FRAME_SNIPPET_LENGTH, } from "./limits.js";
@@ -0,0 +1,8 @@
1
+ /** Maximum length of an event type string, in characters. */
2
+ export declare const MAX_EVENT_TYPE_LENGTH = 64;
3
+ /** Maximum length of an idempotency key string, in characters. */
4
+ export declare const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
5
+ /** Maximum number of characters from a raw response body to include in error details. */
6
+ export declare const MAX_RESPONSE_SNIPPET_LENGTH = 500;
7
+ /** Maximum number of characters from a raw stream frame to include in error messages. */
8
+ export declare const MAX_FRAME_SNIPPET_LENGTH = 200;
package/dist/limits.js ADDED
@@ -0,0 +1,10 @@
1
+ // Shared protocol limits used across transports (HTTP and WebSocket).
2
+ // Defined once here so both relay and client code import the same constants.
3
+ /** Maximum length of an event type string, in characters. */
4
+ export const MAX_EVENT_TYPE_LENGTH = 64;
5
+ /** Maximum length of an idempotency key string, in characters. */
6
+ export const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
7
+ /** Maximum number of characters from a raw response body to include in error details. */
8
+ export const MAX_RESPONSE_SNIPPET_LENGTH = 500;
9
+ /** Maximum number of characters from a raw stream frame to include in error messages. */
10
+ export const MAX_FRAME_SNIPPET_LENGTH = 200;
@@ -0,0 +1,32 @@
1
+ export interface RegisterAgentOptions {
2
+ /** Relay base URL, e.g. https://homespun.example.com. Trailing slash is trimmed. */
3
+ url: string;
4
+ /** Optional agent display name; the relay defaults it if omitted. */
5
+ name?: string;
6
+ /**
7
+ * Shared registration secret. Sent as `Authorization: Bearer <secret>`.
8
+ * Only needed when the relay runs REGISTRATION_MODE=secret; ignored by
9
+ * relays in open mode and rejected (404) by relays in closed mode.
10
+ */
11
+ secret?: string;
12
+ /** Optional fetch override (defaults to global fetch). */
13
+ fetch?: typeof fetch;
14
+ /**
15
+ * Optional client version string. Sent as `x-homespun-cli-version` so the
16
+ * relay's version-skew check can flag a too-old CLI even on the
17
+ * un-authenticated register endpoint. See `HomespunClient`'s `cliVersion`
18
+ * option for the parent contract.
19
+ */
20
+ cliVersion?: string;
21
+ }
22
+ export interface RegisterAgentResult {
23
+ agent_id: string;
24
+ api_key: string;
25
+ key_prefix: string;
26
+ }
27
+ /**
28
+ * Provision a fresh agent + API key from the relay. Mirrors HomespunClient.call's
29
+ * never-throw-raw style: network/parse failures and non-2xx responses are
30
+ * surfaced as HomespunApiError.
31
+ */
32
+ export declare function registerAgent(opts: RegisterAgentOptions): Promise<RegisterAgentResult>;
@@ -0,0 +1,65 @@
1
+ // Standalone agent self-registration: POST /v1/register.
2
+ //
3
+ // Unlike HomespunClient operations this needs no bearer API key — registration is
4
+ // the call that *obtains* one. Whether the relay endpoint is reachable depends
5
+ // on its REGISTRATION_MODE: a `secret`-mode relay requires the shared
6
+ // registration secret to be passed as a Bearer token (see the `secret` option
7
+ // below). Abuse is bounded server-side by a per-IP rate limit (a 429 apps
8
+ // here as a HomespunApiError with status 429).
9
+ import { HomespunApiError } from "./client.js";
10
+ import { MAX_RESPONSE_SNIPPET_LENGTH } from "./limits.js";
11
+ /**
12
+ * Provision a fresh agent + API key from the relay. Mirrors HomespunClient.call's
13
+ * never-throw-raw style: network/parse failures and non-2xx responses are
14
+ * surfaced as HomespunApiError.
15
+ */
16
+ export async function registerAgent(opts) {
17
+ const base = opts.url.replace(/\/$/, "");
18
+ const fetchImpl = opts.fetch ?? fetch;
19
+ const body = {};
20
+ if (opts.name !== undefined)
21
+ body["name"] = opts.name;
22
+ const headers = {
23
+ "content-type": "application/json",
24
+ };
25
+ // Sent only when the relay runs REGISTRATION_MODE=secret; harmless otherwise.
26
+ if (opts.secret !== undefined && opts.secret !== "") {
27
+ headers["authorization"] = `Bearer ${opts.secret}`;
28
+ }
29
+ if (opts.cliVersion !== undefined && opts.cliVersion !== "") {
30
+ headers["x-homespun-cli-version"] = opts.cliVersion;
31
+ }
32
+ let res;
33
+ try {
34
+ res = await fetchImpl(base + "/v1/register", {
35
+ method: "POST",
36
+ headers,
37
+ body: JSON.stringify(body),
38
+ });
39
+ }
40
+ catch (e) {
41
+ const msg = e instanceof Error ? e.message : String(e);
42
+ throw new HomespunApiError(0, "fetch_error", msg);
43
+ }
44
+ let data = null;
45
+ const text = await res.text().catch(() => "");
46
+ if (text !== "") {
47
+ try {
48
+ data = JSON.parse(text);
49
+ }
50
+ catch {
51
+ const snippet = text.length > MAX_RESPONSE_SNIPPET_LENGTH
52
+ ? text.slice(0, MAX_RESPONSE_SNIPPET_LENGTH) + "…"
53
+ : text;
54
+ throw new HomespunApiError(res.status, "non_json_response", `relay returned a non-JSON body (status ${res.status})`, { body: snippet });
55
+ }
56
+ }
57
+ if (!res.ok) {
58
+ const err = data?.error;
59
+ throw new HomespunApiError(res.status, err?.code ?? "relay_error", err?.message ?? `relay returned ${res.status}`, err?.details);
60
+ }
61
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
62
+ throw new HomespunApiError(res.status, "invalid_response", `relay returned a ${res.status} with a non-object body`, { body: data });
63
+ }
64
+ return data;
65
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ export declare const feedbackTypeSchema: z.ZodEnum<{
3
+ bug: "bug";
4
+ feature: "feature";
5
+ note: "note";
6
+ }>;
7
+ export declare const submitFeedbackSchema: z.ZodObject<{
8
+ type: z.ZodEnum<{
9
+ bug: "bug";
10
+ feature: "feature";
11
+ note: "note";
12
+ }>;
13
+ message: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodString>;
14
+ app_id: z.ZodOptional<z.ZodString>;
15
+ }, z.core.$strip>;
@@ -0,0 +1,24 @@
1
+ // Zod schemas for the Homespun relay request shapes. These let callers (the CLI,
2
+ // other clients) validate user-supplied input before it hits the relay,
3
+ // producing clear errors.
4
+ //
5
+ // The legacy v1 request schemas (create / list / upgrade / update /
6
+ // mint-participant, plus their inline + reference template forms) were removed
7
+ // when the v1 data model was dropped: they had no surviving HomespunClient
8
+ // method and no live relay route. Only the feedback schema, still used by the
9
+ // CLI + MCP, remains.
10
+ import { z } from "zod";
11
+ // POST /v1/feedback — an agent submits a bug report, feature request, or note
12
+ // to the relay operator. Message is trimmed before length check so whitespace
13
+ // padding cannot bypass the 1..4000 cap.
14
+ export const feedbackTypeSchema = z.enum(["bug", "feature", "note"]);
15
+ export const submitFeedbackSchema = z.object({
16
+ type: feedbackTypeSchema,
17
+ message: z
18
+ .string()
19
+ .transform((s) => s.trim())
20
+ .pipe(z.string().min(1).max(4000)),
21
+ // App-scoped (v2): optionally attach the feedback to an App the caller can
22
+ // access.
23
+ app_id: z.string().min(1).optional(),
24
+ });
@@ -0,0 +1,70 @@
1
+ import { WebSocket } from "ws";
2
+ import type { HomespunEvent, RecordDeltaMessage } from "./types.js";
3
+ export interface OpenStreamOptions {
4
+ /** WebSocket base URL, e.g. wss://homespun.example.com (no trailing slash). */
5
+ wsBaseUrl: string;
6
+ /** Homespun id. */
7
+ appId: string;
8
+ /** Agent (or participant) bearer token. */
9
+ token: string;
10
+ /** Opaque cursor: replay only events strictly after this id. */
11
+ since?: string | null;
12
+ /**
13
+ * #297 — subscribe to record-collection deltas. `"*"` expands to every
14
+ * declared collection on the app's template; a comma list filters
15
+ * to those names. Absent = no record traffic (legacy event-only stream).
16
+ */
17
+ subscribeRecords?: string;
18
+ /**
19
+ * #297 — per-collection record-replay cursors. Map of collection name →
20
+ * last observed seq, sent as `?since_record_seq.<name>=<seq>` so the
21
+ * relay's replay (#295) skips already-observed rows on reconnect.
22
+ */
23
+ sinceRecordSeq?: Record<string, number>;
24
+ }
25
+ /** Callbacks for a live stream. */
26
+ export interface StreamHandlers {
27
+ /** Fired for every event envelope (replayed and live). */
28
+ onEvent?: (event: HomespunEvent) => void;
29
+ /** Fired once when the initial event replay finishes. */
30
+ onReplayComplete?: () => void;
31
+ /**
32
+ * #297 — fired for every record-delta message (record.upsert /
33
+ * record.delete / record.replay.complete) on the stream. Per-collection
34
+ * record.replay.complete fires once per subscribed collection after
35
+ * the replay set has been drained.
36
+ */
37
+ onRecord?: (msg: RecordDeltaMessage) => void;
38
+ /** Fired on a relay error frame. */
39
+ onRelayError?: (error: {
40
+ code?: string;
41
+ message?: string;
42
+ details?: unknown;
43
+ }) => void;
44
+ /** Fired when the socket closes (cleanly or otherwise). */
45
+ onClose?: (info: {
46
+ code: number;
47
+ reason: string;
48
+ }) => void;
49
+ /** Fired on a transport-level error. */
50
+ onError?: (err: Error) => void;
51
+ }
52
+ /** A live handle to an open stream. */
53
+ export interface StreamHandle {
54
+ /** Send an event frame into the app. */
55
+ send(frame: {
56
+ type: string;
57
+ data?: unknown;
58
+ causation_id?: string;
59
+ idempotency_key?: string;
60
+ }): void;
61
+ /** Close the stream. */
62
+ close(): void;
63
+ /** The underlying ws socket (escape hatch). */
64
+ readonly socket: WebSocket;
65
+ }
66
+ /**
67
+ * Open a WebSocket stream to a Homespun app. Replays on connect, then streams
68
+ * live. Returns a handle for sending frames and closing.
69
+ */
70
+ export declare function openStream(opts: OpenStreamOptions, handlers: StreamHandlers): StreamHandle;
package/dist/stream.js ADDED
@@ -0,0 +1,113 @@
1
+ // WebSocket client for WS /v1/apps/:id/stream.
2
+ //
3
+ // The relay protocol (see the relay's src/ws/handler.ts):
4
+ // - on connect, the relay replays every event since `?since=` (or from the
5
+ // start), then sends a `{ kind: "system.replay.complete" }` marker;
6
+ // - thereafter it pushes live events as they land;
7
+ // - each frame is a JSON object: either a HomespunEvent envelope, the replay
8
+ // marker, an `{ ack, deduped }` for frames we sent, or an `{ error }`.
9
+ //
10
+ // Note: `system.participant.joined` / `system.participant.left` (and other
11
+ // `system.*` events) arrive as ordinary `HomespunEvent` envelopes — they are not a
12
+ // distinct frame kind, and may be interleaved with the initial replay stream
13
+ // just like any other event.
14
+ //
15
+ // `openStream` exposes this as a typed event emitter over the `ws` package.
16
+ import { WebSocket } from "ws";
17
+ import { MAX_FRAME_SNIPPET_LENGTH } from "./limits.js";
18
+ /**
19
+ * Open a WebSocket stream to a Homespun app. Replays on connect, then streams
20
+ * live. Returns a handle for sending frames and closing.
21
+ */
22
+ export function openStream(opts, handlers) {
23
+ const base = opts.wsBaseUrl.replace(/\/$/, "");
24
+ const u = new URL(`${base}/v1/apps/${encodeURIComponent(opts.appId)}/stream`);
25
+ if (opts.since != null && opts.since !== "") {
26
+ u.searchParams.set("since", opts.since);
27
+ }
28
+ if (opts.subscribeRecords && opts.subscribeRecords.length > 0) {
29
+ u.searchParams.set("subscribe_records", opts.subscribeRecords);
30
+ }
31
+ if (opts.sinceRecordSeq) {
32
+ for (const [name, seq] of Object.entries(opts.sinceRecordSeq)) {
33
+ u.searchParams.set(`since_record_seq.${name}`, String(seq));
34
+ }
35
+ }
36
+ // Token via Authorization header (Node ws supports it); the relay also
37
+ // accepts ?token= but the header keeps it out of any URL access log.
38
+ const socket = new WebSocket(u.toString(), {
39
+ headers: { authorization: "Bearer " + opts.token },
40
+ });
41
+ socket.on("message", (raw) => {
42
+ const text = raw.toString();
43
+ let msg;
44
+ try {
45
+ msg = JSON.parse(text);
46
+ }
47
+ catch (e) {
48
+ // A malformed frame must never be silently dropped — a dropped event
49
+ // makes `watch --type X` hang forever. Surface it as a transport error.
50
+ const snippet = text.length > MAX_FRAME_SNIPPET_LENGTH
51
+ ? text.slice(0, MAX_FRAME_SNIPPET_LENGTH) + "…"
52
+ : text;
53
+ handlers.onError?.(new Error(`failed to parse stream frame as JSON (${e instanceof Error ? e.message : String(e)}): ${snippet}`));
54
+ return;
55
+ }
56
+ if (!msg || typeof msg !== "object") {
57
+ handlers.onError?.(new Error(`unexpected non-object stream frame: ${text.slice(0, MAX_FRAME_SNIPPET_LENGTH)}`));
58
+ return;
59
+ }
60
+ const obj = msg;
61
+ if (obj["kind"] === "system.replay.complete") {
62
+ handlers.onReplayComplete?.();
63
+ return;
64
+ }
65
+ if ("error" in obj) {
66
+ handlers.onRelayError?.(obj["error"]);
67
+ return;
68
+ }
69
+ if ("ack" in obj) {
70
+ // Ack for a frame we sent; nothing to app by default.
71
+ return;
72
+ }
73
+ // #297 — record deltas. record.upsert / record.delete / record.replay.complete.
74
+ const kind = obj["kind"];
75
+ if (kind === "record.upsert" ||
76
+ kind === "record.delete" ||
77
+ kind === "record.replay.complete") {
78
+ handlers.onRecord?.(obj);
79
+ return;
80
+ }
81
+ if (typeof obj["id"] === "string" && typeof obj["type"] === "string") {
82
+ handlers.onEvent?.(obj);
83
+ return;
84
+ }
85
+ // Unrecognized frame shape — route to onError rather than dropping it.
86
+ handlers.onError?.(new Error(`unrecognized stream frame: ${JSON.stringify(obj).slice(0, MAX_FRAME_SNIPPET_LENGTH)}`));
87
+ });
88
+ socket.on("close", (code, reason) => {
89
+ handlers.onClose?.({ code, reason: reason.toString() });
90
+ });
91
+ socket.on("error", (err) => {
92
+ handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
93
+ });
94
+ return {
95
+ send(frame) {
96
+ if (socket.readyState !== WebSocket.OPEN) {
97
+ throw new Error(`cannot send frame: stream socket is not open (readyState=${socket.readyState})`);
98
+ }
99
+ socket.send(JSON.stringify(frame));
100
+ },
101
+ close() {
102
+ try {
103
+ socket.close();
104
+ }
105
+ catch (e) {
106
+ console.debug("[app] stream close error:", e instanceof Error ? e.message : String(e));
107
+ }
108
+ },
109
+ get socket() {
110
+ return socket;
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,144 @@
1
+ export type AuthorKind = "human" | "agent" | "system";
2
+ /** A single event envelope as emitted by the relay. */
3
+ export interface HomespunEvent {
4
+ id: string;
5
+ app_id: string;
6
+ author: {
7
+ kind: AuthorKind;
8
+ id: string;
9
+ };
10
+ ts: string;
11
+ type: string;
12
+ data: unknown;
13
+ causation_id: string | null;
14
+ idempotency_key: string | null;
15
+ /**
16
+ * The template version this event was written under — the app's pinned
17
+ * templateVersionId at the moment of the write. Stamped at write time and
18
+ * never rewritten, so a downstream upgrade (#267) can read old events
19
+ * under the new schema (Level 1 polymorphic render). Nullable for events
20
+ * written before #268 landed; the relay's one-shot migration backfilled
21
+ * those from the app's current pin where possible.
22
+ */
23
+ template_version_id: string | null;
24
+ /** Denormalised integer version number for `template_version_id`. */
25
+ template_version: number | null;
26
+ }
27
+ /**
28
+ * One record on the wire (#287). Returned by the records CRUD routes
29
+ * (#292) and by the WS record-delta messages (#294). Structurally
30
+ * identical to the relay-side SerializedRecord; mirrored here so the
31
+ * core package is self-contained.
32
+ */
33
+ export interface SerializedRecord {
34
+ id: string;
35
+ collection: string;
36
+ key: string;
37
+ data: unknown;
38
+ version: number;
39
+ seq: number;
40
+ author: {
41
+ kind: AuthorKind;
42
+ id: string;
43
+ };
44
+ created_at: string;
45
+ updated_at: string;
46
+ deleted_at: string | null;
47
+ }
48
+ /** Wire shape for a soft-deleted record on the WS channel. */
49
+ export interface DeletedRecordRef {
50
+ id: string;
51
+ key: string;
52
+ seq: number;
53
+ deleted_at: string;
54
+ }
55
+ /** Discriminated wire shape for record-state changes. */
56
+ export type RecordDeltaMessage = {
57
+ kind: "record.upsert";
58
+ collection: string;
59
+ record: SerializedRecord;
60
+ } | {
61
+ kind: "record.delete";
62
+ collection: string;
63
+ record: DeletedRecordRef;
64
+ } | {
65
+ kind: "record.replay.complete";
66
+ collection: string;
67
+ seq: number;
68
+ };
69
+ /**
70
+ * An template: discriminated on `type`. `html-inline` carries raw HTML in
71
+ * `source`; `html-ref` carries a URL the relay/shell fetches on the human's
72
+ * behalf. The discriminant keeps the type↔source coupling explicit.
73
+ */
74
+ export type Template = {
75
+ type: "html-inline";
76
+ source: string;
77
+ } | {
78
+ type: "html-ref";
79
+ source: string;
80
+ };
81
+ /** Optional webhook callback config. */
82
+ export interface Callback {
83
+ url: string;
84
+ events: string[];
85
+ secret: string;
86
+ }
87
+ /**
88
+ * Response from GET /v1/keys — the calling agent's own key info. The relay
89
+ * scopes this to the authenticated agent: it returns ONE key (the caller's),
90
+ * not a list.
91
+ */
92
+ export interface KeyInfo {
93
+ agent_id: string;
94
+ name: string | null;
95
+ key_prefix: string;
96
+ created_at: string;
97
+ last_used_at: string | null;
98
+ revoked_at: string | null;
99
+ }
100
+ /**
101
+ * Response from GET /v1/taste, PUT /v1/taste — the calling agent's freeform
102
+ * "taste notes" markdown attachment (presentation preferences the agent has picked
103
+ * up from human feedback over time). `taste` and `updated_at` are null when
104
+ * the agent has never written notes; `bytes` is the utf8 byte length and 0
105
+ * when `taste` is null.
106
+ */
107
+ export interface TasteInfo {
108
+ taste: string | null;
109
+ updated_at: string | null;
110
+ bytes: number;
111
+ }
112
+ /** A feedback `type` discriminant accepted by POST /v1/feedback. */
113
+ export type FeedbackType = "bug" | "feature" | "note";
114
+ /** Response from POST /v1/feedback — id, type, created_at only (no message echo). */
115
+ export interface FeedbackSubmission {
116
+ id: string;
117
+ type: FeedbackType;
118
+ created_at: string;
119
+ }
120
+ /** A row from GET /v1/feedback, the full record including message. */
121
+ export interface FeedbackRecord {
122
+ id: string;
123
+ type: FeedbackType;
124
+ message: string;
125
+ app_id: string | null;
126
+ created_at: string;
127
+ }
128
+ /** Response from GET /v1/feedback — page of the calling agent's own submissions. */
129
+ export interface FeedbackPage {
130
+ items: FeedbackRecord[];
131
+ next_before?: string;
132
+ }
133
+ /** A relay error envelope. */
134
+ export interface RelayError {
135
+ code: string;
136
+ message?: string;
137
+ details?: unknown;
138
+ /** Agent-friendly remediation hint. */
139
+ hint?: string;
140
+ /** Whether retrying the same request may succeed. */
141
+ retryable?: boolean;
142
+ /** Documentation URL for this error class (snake_case on the wire). */
143
+ docs_url?: string;
144
+ }
package/dist/types.js ADDED
@@ -0,0 +1,7 @@
1
+ // Wire types for the Homespun relay HTTP + WebSocket API.
2
+ //
3
+ // These mirror the relay's public response shapes (see the relay's
4
+ // src/types.ts, src/http/serialize.ts and src/http/routes/*). They are
5
+ // re-declared here rather than imported from @homespunapps/relay so that @homespunapps/core
6
+ // stays pure and framework-free — no Prisma, no Hono, no server deps.
7
+ export {};