@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.
@@ -0,0 +1,508 @@
1
+ import type { FeedbackPage, FeedbackSubmission, FeedbackType, KeyInfo, TasteInfo } from "./types.js";
2
+ export interface ClientOptions {
3
+ /** Relay base URL, e.g. https://homespun.example.com. Trailing slash is trimmed. */
4
+ url: string;
5
+ /** Agent API key (bearer token). */
6
+ apiKey: string;
7
+ /** Optional fetch override (defaults to global fetch). */
8
+ fetch?: typeof fetch;
9
+ /**
10
+ * Optional client version string sent as `x-homespun-cli-version` on every
11
+ * request. The CLI passes its own `VERSION` constant here so a relay can
12
+ * detect version skew and respond with a `cli_upgrade_required` error
13
+ * (HTTP 426) when the CLI is below the relay's minimum supported version.
14
+ * Library callers (non-CLI) can leave this unset — the header is omitted
15
+ * and the relay treats the request as version-unknown.
16
+ */
17
+ cliVersion?: string;
18
+ }
19
+ /** Low-level relay response: ok flag, HTTP status, parsed JSON body. */
20
+ export interface RelayResponse {
21
+ ok: boolean;
22
+ status: number;
23
+ data: unknown;
24
+ }
25
+ /** Response from POST /v1/query. */
26
+ export interface QueryResponse {
27
+ /** Ordered column names exactly as DuckDB returned them. */
28
+ columns: string[];
29
+ /** Result rows; each row is an array of values aligned to `columns`. */
30
+ rows: unknown[][];
31
+ /** True if the result was capped by the relay's per-query row cap. */
32
+ truncated: boolean;
33
+ /** Tells the caller which apps the query saw and how it was scoped. */
34
+ scope: {
35
+ kind: "human" | "agent";
36
+ app_count: number;
37
+ };
38
+ /** Wall-clock milliseconds the relay spent serving the query. */
39
+ elapsed_ms: number;
40
+ }
41
+ /**
42
+ * An error thrown by the typed operations when the relay returns a non-2xx
43
+ * response (or the request fails outright). Carries the HTTP status and the
44
+ * relay error envelope so callers can branch on `code`.
45
+ */
46
+ export declare class HomespunApiError extends Error {
47
+ readonly status: number;
48
+ readonly code: string;
49
+ readonly details: unknown;
50
+ /** Agent-friendly remediation hint, when the relay supplies one. */
51
+ readonly hint?: string;
52
+ /** Whether retrying the same request may succeed (e.g. 429). */
53
+ readonly retryable?: boolean;
54
+ /** Documentation URL for this error class (mapped from the wire's `docs_url`). */
55
+ readonly docsUrl?: string;
56
+ constructor(status: number, code: string, message: string, details?: unknown, extra?: {
57
+ hint?: string;
58
+ retryable?: boolean;
59
+ docsUrl?: string;
60
+ });
61
+ }
62
+ export declare class HomespunClient {
63
+ private readonly base;
64
+ private readonly apiKey;
65
+ private readonly fetchImpl;
66
+ private readonly cliVersion;
67
+ constructor(opts: ClientOptions);
68
+ /** Relay base URL (trailing slash trimmed). */
69
+ get baseUrl(): string;
70
+ /** WebSocket base URL derived from the relay base URL (http→ws, https→wss). */
71
+ get wsBaseUrl(): string;
72
+ /**
73
+ * Low-level HTTP helper. Mirrors the relay API contract: Bearer auth,
74
+ * JSON bodies, 204 handled. Never throws on non-2xx — returns `ok: false`.
75
+ * Network failures return `{ ok: false, status: 0, ... }`.
76
+ */
77
+ call(method: string, path: string, body?: object): Promise<RelayResponse>;
78
+ /** Assert a 2xx body is a non-null object before treating it as typed JSON. */
79
+ private asObject;
80
+ /** Throw a HomespunApiError from a failed RelayResponse. */
81
+ private fail;
82
+ /**
83
+ * GET /v1/keys — the calling agent's own key info. The relay scopes this to
84
+ * the authenticated agent: it returns one key (the caller's), not a list.
85
+ */
86
+ listKeys(): Promise<KeyInfo>;
87
+ /**
88
+ * DELETE /v1/keys/:id — revoke an API key. The relay only permits revoking
89
+ * the caller's OWN key (any other id is rejected 403): this is a
90
+ * self-destruct. Returns 204 with no body on success.
91
+ */
92
+ revokeKey(id: string): Promise<void>;
93
+ /**
94
+ * POST /v1/agents/claim — bind this agent to a human via a one-shot
95
+ * claim code the human generated in their settings UI. After a
96
+ * successful claim the agent's existing API key continues to work,
97
+ * but the agent (and its apps/templates) now belong to the
98
+ * claiming human. One-way operation — there is no unclaim in v1.
99
+ */
100
+ claimAgent(code: string): Promise<{
101
+ ok: true;
102
+ owner_human_id: string;
103
+ claimed_at: string;
104
+ }>;
105
+ /**
106
+ * GET /v1/taste — the calling agent's freeform "taste notes" markdown attachment:
107
+ * presentation preferences the agent has picked up from human feedback over
108
+ * time. Returns `{ taste: null, updated_at: null, bytes: 0 }` when the
109
+ * agent has never written notes. Read this before generating an template so
110
+ * the agent applies prior feedback.
111
+ */
112
+ getTaste(): Promise<TasteInfo>;
113
+ /**
114
+ * PUT /v1/taste — whole-attachment replace of the calling agent's taste notes.
115
+ * Empty/whitespace-only values are rejected by the relay; callers asking to
116
+ * clear must use {@link clearTaste}. The relay caps the payload at the
117
+ * server's `MAX_TASTE_BYTES` (utf8 bytes).
118
+ */
119
+ setTaste(taste: string): Promise<TasteInfo>;
120
+ /**
121
+ * DELETE /v1/taste — clear the calling agent's taste notes (idempotent on
122
+ * the relay; clearing already-empty notes still succeeds). Returns 204 with
123
+ * no body.
124
+ */
125
+ clearTaste(): Promise<void>;
126
+ /**
127
+ * POST /v1/feedback — submit a one-shot bug report, feature request, or
128
+ * note to the relay operator. Returns the new row's id, type, and
129
+ * created_at; the message is not echoed.
130
+ */
131
+ submitFeedback(req: {
132
+ type: FeedbackType;
133
+ message: string;
134
+ appId?: string;
135
+ }): Promise<FeedbackSubmission>;
136
+ /**
137
+ * GET /v1/feedback — the calling agent's own submissions, newest first.
138
+ * `before` is an opaque cursor from a previous page's `next_before`.
139
+ */
140
+ listFeedback(opts?: {
141
+ limit?: number;
142
+ before?: string;
143
+ }): Promise<FeedbackPage>;
144
+ /**
145
+ * Upload a attachment to the relay. Returns a `AttachmentRef` that can be referenced
146
+ * in event payloads (the relay's `format: homespun-attachment-id` schema vocab
147
+ * validates the id) or in `homespun create --input-data`.
148
+ *
149
+ * Scope defaults to "agent" (reusable). For `scope: "app"` pass `appId`.
150
+ * The calling agent's owning human must own the referenced App;
151
+ * cross-tenant attempts return app_not_found.
152
+ *
153
+ * MIME is inferred from `mime` if supplied; otherwise the relay sniffs
154
+ * leading bytes and may reject with mime_mismatch / mime_disallowed.
155
+ *
156
+ * Backed by the relay's multipart `POST /v1/attachments` (the fallback path).
157
+ * For large uploads (>1 MB on hosted Azure) call `presignBlob()` +
158
+ * `confirmBlob()` instead — those use SAS direct-to-storage and don't
159
+ * stream bytes through the relay.
160
+ */
161
+ uploadBlob(file: Blob | Buffer | Uint8Array, opts?: UploadBlobOptions): Promise<AttachmentRef>;
162
+ /** GET /v1/attachments/:id — download bytes as an ArrayBuffer. */
163
+ downloadBlob(attachmentId: string): Promise<ArrayBuffer>;
164
+ /**
165
+ * GET a attachment's metadata only — useful before downloading large attachments, or
166
+ * for `homespun attachment show <id>` which doesn't want the bytes. Returns the full
167
+ * AttachmentRef (the same shape POST /v1/attachments returns): id, scope, mime, size,
168
+ * sha256, filename, width, height, status, scope FKs, timestamps.
169
+ *
170
+ * Backed by GET /v1/attachments/:id/metadata which serves the JSON AttachmentRef
171
+ * without streaming the bytes — cheap on the relay and avoids the
172
+ * encrypt-at-rest decrypt cost when only the metadata is needed.
173
+ */
174
+ getBlob(attachmentId: string): Promise<AttachmentRef>;
175
+ /** DELETE /v1/attachments/:id — soft-delete (idempotent). */
176
+ deleteBlob(attachmentId: string): Promise<{
177
+ deleted: true;
178
+ }>;
179
+ /**
180
+ * Mint a `/b/<token>` capability URL for `attachmentId`. Default TTL is set by
181
+ * the relay (24h agent-scope, 30d app-scope). `once: true`
182
+ * tokens self-delete on first GET.
183
+ */
184
+ mintBlobToken(attachmentId: string, opts?: {
185
+ ttlSeconds?: number;
186
+ once?: boolean;
187
+ }): Promise<AttachmentTokenMintResponse>;
188
+ /** Revoke a previously-minted token. Idempotent. */
189
+ revokeBlobToken(attachmentId: string, tokenId: string): Promise<{
190
+ token_id: string;
191
+ revoked: true;
192
+ }>;
193
+ /**
194
+ * GET /v1/attachments — list YOUR agent's non-deleted attachments (newest first).
195
+ * Paginated via opaque cursor: when `next_cursor` is non-null, pass it
196
+ * back as `cursor` on the next call.
197
+ */
198
+ listBlobs(opts?: ListBlobsOptions): Promise<{
199
+ items: AttachmentRef[];
200
+ next_cursor: string | null;
201
+ }>;
202
+ /**
203
+ * GET /v1/attachments/:id/tokens — enumerate the capability tokens minted
204
+ * against one attachment, including revoked rows (for audit). The plaintext
205
+ * token is NEVER returned — it isn't stored, only its sha256 is.
206
+ */
207
+ listBlobTokens(attachmentId: string): Promise<AttachmentTokenListResponse>;
208
+ /**
209
+ * Issue a presigned PUT URL for direct-to-storage upload. Returns the
210
+ * upload URL + the attachment_id (already reserved in the relay's DB with
211
+ * status=pending) + expiry. After PUTting the bytes to the URL, call
212
+ * `confirmBlob(attachment_id)` to finalise.
213
+ *
214
+ * Filesystem backend returns 501 not_implemented — use uploadBlob()
215
+ * (multipart fallback) instead. Azure backend returns a SAS URL.
216
+ */
217
+ presignBlob(opts: PresignBlobOptions): Promise<{
218
+ attachment_id: string;
219
+ upload_url: string;
220
+ expires_at: string;
221
+ }>;
222
+ /** Finalise a presigned upload — relay HEADs the bytes, verifies, flips ready. */
223
+ confirmBlob(attachmentId: string): Promise<AttachmentRef>;
224
+ /** POST /v1/apps — create (deploy) a new App + its first AppVersion. */
225
+ deployApp(req: DeployAppRequest): Promise<DeployAppResponse>;
226
+ /** POST /v1/apps/:id/versions — redeploy (compat-gated unless force). */
227
+ redeployApp(appId: string, req: RedeployAppRequest): Promise<RedeployAppResponse>;
228
+ /**
229
+ * GET /v1/apps — list apps scoped to the calling agent's owning human.
230
+ * `slug` is an exact-match filter — the one case a caller resolves a
231
+ * human-given slug to an id (see the class-level note above).
232
+ */
233
+ listApps(opts?: {
234
+ status?: "active" | "dormant" | "archived" | "all";
235
+ limit?: number;
236
+ cursor?: string;
237
+ slug?: string;
238
+ }): Promise<AppsPage>;
239
+ /** GET /v1/apps/:id — full app detail (manifest, current_version, quota). */
240
+ getApp(appId: string): Promise<AppDetail>;
241
+ /** PATCH /v1/apps/:id — update visibility (the only mutable app field). */
242
+ updateApp(appId: string, visibility: "private" | "link" | "public"): Promise<{
243
+ id: string;
244
+ visibility: string;
245
+ }>;
246
+ /** DELETE /v1/apps/:id — soft-delete (idempotent). */
247
+ deleteApp(appId: string): Promise<void>;
248
+ /** POST /v1/apps/:id/wake — wake a dormant app; a no-op on a non-dormant one. */
249
+ wakeApp(appId: string): Promise<{
250
+ id: string;
251
+ status: string;
252
+ }>;
253
+ /**
254
+ * POST /v1/apps/:id/members — add or invite a member by email. If a Human
255
+ * already exists for the email, the member row is created immediately
256
+ * (`{ member }`); otherwise the relay mints a signed invite and emails a
257
+ * magic link (`{ ok: true, invited, expires_at }`). 503s if the relay has
258
+ * EMAIL_PROVIDER=none (no invite-email path available).
259
+ */
260
+ addAppMember(appId: string, opts: {
261
+ email: string;
262
+ role?: "member";
263
+ }): Promise<AddAppMemberResult>;
264
+ /** GET /v1/apps/:id/members — list the app's owner + member rows. */
265
+ listAppMembers(appId: string): Promise<{
266
+ members: AppMember[];
267
+ }>;
268
+ /**
269
+ * DELETE /v1/apps/:id/members/:humanId — remove a member (idempotent);
270
+ * cascades to revoke that human's live app sessions. The app owner cannot
271
+ * be removed — the relay refuses with a 409 conflict.
272
+ */
273
+ removeAppMember(appId: string, humanId: string): Promise<void>;
274
+ /** GET /v1/apps/:id/collections/:name — list rows (current-state page). */
275
+ listAppRows(appId: string, collection: string, opts?: {
276
+ since?: string;
277
+ limit?: number;
278
+ }): Promise<AppRowsPage>;
279
+ /**
280
+ * GET /v1/apps/:id/collections/:name/:key — read one row. A dedicated
281
+ * route (not a client-side scan like v1's `getRecord`) — spec-cli §8
282
+ * ruling 3 confirms it as a first-class relay route.
283
+ */
284
+ getAppRow(appId: string, collection: string, key: string): Promise<{
285
+ row: AppRow;
286
+ }>;
287
+ /**
288
+ * POST /v1/apps/:id/collections/:name — upsert (create, or return the
289
+ * existing row when `key` collides — `deduped: true`). §8 ruling 4: this is
290
+ * the ONLY create-shaped verb — there is no strict create that errors on
291
+ * an existing key.
292
+ */
293
+ upsertAppRow(appId: string, collection: string, body: {
294
+ key?: string;
295
+ data: unknown;
296
+ }): Promise<{
297
+ row: AppRow;
298
+ deduped?: true;
299
+ }>;
300
+ /** PATCH /v1/apps/:id/collections/:name/:key — optimistic-locked update. */
301
+ updateAppRow(appId: string, collection: string, key: string, body: {
302
+ data: unknown;
303
+ if_match?: number;
304
+ }): Promise<{
305
+ row: AppRow;
306
+ }>;
307
+ /** DELETE /v1/apps/:id/collections/:name/:key — soft-delete. */
308
+ deleteAppRow(appId: string, collection: string, key: string, opts?: {
309
+ ifMatch?: number;
310
+ }): Promise<void>;
311
+ /**
312
+ * GET /v1/apps/:id/feed — change-feed catch-up; the long-poll fallback
313
+ * transport for `watch` (spec-cli §2.3/§5). `wait` (0-30s) long-polls when
314
+ * the caller is already caught up.
315
+ */
316
+ getAppFeed(appId: string, opts: {
317
+ since: number;
318
+ limit?: number;
319
+ wait?: number;
320
+ }): Promise<AppFeedPage>;
321
+ }
322
+ /** Request body for `POST /v1/apps` — create (deploy) a new App. */
323
+ export interface DeployAppRequest {
324
+ html: string;
325
+ manifest: unknown;
326
+ /** Defaults to "private" server-side when omitted. */
327
+ visibility?: "private" | "link" | "public";
328
+ /** Accepted for visibility public|private (including the omitted default); rejected for link. */
329
+ slug?: string;
330
+ }
331
+ export interface DeployAppResponse {
332
+ app_id: string;
333
+ slug: string;
334
+ visibility: string;
335
+ url: string;
336
+ version: number;
337
+ created: true;
338
+ }
339
+ /** Request body for `POST /v1/apps/:id/versions` — redeploy (compat-gated). */
340
+ export interface RedeployAppRequest {
341
+ html: string;
342
+ manifest: unknown;
343
+ /** Bypass the compat gate; a narrowing collection is detached, not deleted. */
344
+ force?: boolean;
345
+ }
346
+ export interface RedeployAppResponse {
347
+ app_id: string;
348
+ version: number;
349
+ compat: "clean" | "forced";
350
+ breaks?: Array<{
351
+ path: string;
352
+ message: string;
353
+ }>;
354
+ }
355
+ /** Lean per-app summary — the shape `listApps` returns. */
356
+ export interface AppSummary {
357
+ id: string;
358
+ slug: string;
359
+ visibility: string;
360
+ status: string;
361
+ url: string;
362
+ created_at: string;
363
+ last_activity_at: string;
364
+ }
365
+ /** Full app detail — the shape `getApp` returns. */
366
+ export interface AppDetail extends AppSummary {
367
+ manifest: unknown;
368
+ current_version: number | null;
369
+ owner_human_id: string;
370
+ row_count: number;
371
+ storage_bytes: string;
372
+ }
373
+ export interface AppsPage {
374
+ items: AppSummary[];
375
+ next_cursor: string | null;
376
+ }
377
+ /**
378
+ * One member row — the shape `listAppMembers` returns and the `member` half
379
+ * of `addAppMember`'s union returns. Fields are camelCase on the wire (see
380
+ * the app-membership section above for why).
381
+ */
382
+ export interface AppMember {
383
+ humanId: string;
384
+ email: string;
385
+ role: string;
386
+ createdAt: string;
387
+ }
388
+ /**
389
+ * `POST /v1/apps/:id/members` response — either the member was attached
390
+ * immediately (existing Human) or an invite email was sent (no Human yet).
391
+ */
392
+ export type AddAppMemberResult = {
393
+ member: AppMember;
394
+ } | {
395
+ ok: true;
396
+ invited: string;
397
+ expires_at: string;
398
+ };
399
+ /** One row in an app collection — the shape every row CRUD op returns. */
400
+ export interface AppRow {
401
+ key: string;
402
+ data: unknown;
403
+ version: number;
404
+ author: {
405
+ kind: string;
406
+ id: string;
407
+ };
408
+ created_at: string;
409
+ updated_at: string;
410
+ deleted_at: string | null;
411
+ }
412
+ export interface AppRowsPage {
413
+ rows: AppRow[];
414
+ next_cursor: string | null;
415
+ has_more: boolean;
416
+ }
417
+ /**
418
+ * One entry in an app's change feed — the SAME shape whether it arrives via
419
+ * `GET /v1/apps/:id/feed` (long-poll) or the `/_hs/ws` live/batch frames
420
+ * (openAppStream, app-stream.ts) — the CLI's `apps watch` prints this object
421
+ * unchanged regardless of which transport served it (spec-cli §3.4/§5).
422
+ */
423
+ export interface AppFeedEntry {
424
+ seq: number;
425
+ op: string;
426
+ collection_name: string;
427
+ row_key: string;
428
+ row_version?: number | null;
429
+ data: unknown;
430
+ author: {
431
+ kind: string;
432
+ id: string;
433
+ };
434
+ ts: string;
435
+ }
436
+ export interface AppFeedPage {
437
+ entries: AppFeedEntry[];
438
+ cursor: number;
439
+ truncated: boolean;
440
+ }
441
+ /** Per-attachment metadata as returned by `POST /v1/attachments` and friends. */
442
+ export interface AttachmentRef {
443
+ attachment_id: string;
444
+ scope: "agent" | "app";
445
+ mime: string;
446
+ size: number;
447
+ sha256: string;
448
+ url?: string;
449
+ width?: number | null;
450
+ height?: number | null;
451
+ filename?: string | null;
452
+ status?: string;
453
+ app_id?: string | null;
454
+ created_at?: string;
455
+ confirmed_at?: string | null;
456
+ deleted_at?: string | null;
457
+ }
458
+ export interface UploadBlobOptions {
459
+ scope?: "agent" | "app";
460
+ appId?: string;
461
+ /** Declared Content-Type. Defaults to `application/octet-stream`. The
462
+ * relay sniffs leading bytes and may reject with `mime_mismatch`. */
463
+ mime?: string;
464
+ /** Optional display name (the relay records it for UX; never a path component). */
465
+ filename?: string;
466
+ }
467
+ export interface PresignBlobOptions {
468
+ mime: string;
469
+ size: number;
470
+ sha256: string;
471
+ scope?: "agent" | "app";
472
+ appId?: string;
473
+ filename?: string;
474
+ }
475
+ export interface AttachmentTokenMintResponse {
476
+ token_id: string;
477
+ token: string;
478
+ token_prefix: string;
479
+ url: string;
480
+ expires_at: string;
481
+ once: boolean;
482
+ }
483
+ /** Options for `listBlobs()` — opaque cursor + page-size knob. */
484
+ export interface ListBlobsOptions {
485
+ /** Opaque pagination cursor from a prior `next_cursor`. */
486
+ cursor?: string;
487
+ /** Page size; relay clamps to 1..100. Defaults to the relay default (50). */
488
+ limit?: number;
489
+ }
490
+ /** One row in the response from `listBlobTokens()`. */
491
+ export interface AttachmentTokenAuditEntry {
492
+ token_id: string;
493
+ token_prefix: string;
494
+ expires_at: string;
495
+ once: boolean;
496
+ created_at: string;
497
+ last_used_at: string | null;
498
+ use_count: number;
499
+ /** Non-null when the token has been revoked. Expired-but-unrevoked rows
500
+ * carry `revoked_at: null` and an `expires_at` in the past — both are
501
+ * useful for audit. */
502
+ revoked_at: string | null;
503
+ }
504
+ /** Shape returned by `listBlobTokens()`. */
505
+ export interface AttachmentTokenListResponse {
506
+ attachment_id: string;
507
+ items: AttachmentTokenAuditEntry[];
508
+ }