@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/client.js ADDED
@@ -0,0 +1,619 @@
1
+ // Homespun relay HTTP client. Pure: no argv, no process.env reads, no MCP.
2
+ // The caller supplies the relay base URL + API key explicitly.
3
+ import { MAX_RESPONSE_SNIPPET_LENGTH } from "./limits.js";
4
+ /**
5
+ * An error thrown by the typed operations when the relay returns a non-2xx
6
+ * response (or the request fails outright). Carries the HTTP status and the
7
+ * relay error envelope so callers can branch on `code`.
8
+ */
9
+ export class HomespunApiError extends Error {
10
+ status;
11
+ code;
12
+ details;
13
+ /** Agent-friendly remediation hint, when the relay supplies one. */
14
+ hint;
15
+ /** Whether retrying the same request may succeed (e.g. 429). */
16
+ retryable;
17
+ /** Documentation URL for this error class (mapped from the wire's `docs_url`). */
18
+ docsUrl;
19
+ constructor(status, code, message, details, extra) {
20
+ super(message);
21
+ this.name = "HomespunApiError";
22
+ this.status = status;
23
+ this.code = code;
24
+ this.details = details;
25
+ this.hint = extra?.hint;
26
+ this.retryable = extra?.retryable;
27
+ this.docsUrl = extra?.docsUrl;
28
+ }
29
+ }
30
+ export class HomespunClient {
31
+ base;
32
+ apiKey;
33
+ fetchImpl;
34
+ cliVersion;
35
+ constructor(opts) {
36
+ this.base = opts.url.replace(/\/$/, "");
37
+ this.apiKey = opts.apiKey;
38
+ this.fetchImpl = opts.fetch ?? fetch;
39
+ this.cliVersion = opts.cliVersion;
40
+ }
41
+ /** Relay base URL (trailing slash trimmed). */
42
+ get baseUrl() {
43
+ return this.base;
44
+ }
45
+ /** WebSocket base URL derived from the relay base URL (http→ws, https→wss). */
46
+ get wsBaseUrl() {
47
+ const u = new URL(this.base);
48
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
49
+ return u.toString().replace(/\/$/, "");
50
+ }
51
+ /**
52
+ * Low-level HTTP helper. Mirrors the relay API contract: Bearer auth,
53
+ * JSON bodies, 204 handled. Never throws on non-2xx — returns `ok: false`.
54
+ * Network failures return `{ ok: false, status: 0, ... }`.
55
+ */
56
+ async call(method, path, body) {
57
+ const url = this.base + path;
58
+ let res;
59
+ try {
60
+ res = await this.fetchImpl(url, {
61
+ method,
62
+ headers: {
63
+ authorization: "Bearer " + this.apiKey,
64
+ ...(body ? { "content-type": "application/json" } : {}),
65
+ // x-homespun-cli-version drives the relay's version-skew check. Header
66
+ // is omitted entirely when no version was supplied so the relay
67
+ // can distinguish "old CLI" from "non-CLI caller".
68
+ ...(this.cliVersion
69
+ ? { "x-homespun-cli-version": this.cliVersion }
70
+ : {}),
71
+ },
72
+ body: body ? JSON.stringify(body) : undefined,
73
+ });
74
+ }
75
+ catch (e) {
76
+ const msg = e instanceof Error ? e.message : String(e);
77
+ return {
78
+ ok: false,
79
+ status: 0,
80
+ data: { error: { code: "fetch_error", message: msg } },
81
+ };
82
+ }
83
+ let data = null;
84
+ if (res.status !== 204) {
85
+ const text = await res.text().catch(() => "");
86
+ if (text !== "") {
87
+ try {
88
+ data = JSON.parse(text);
89
+ }
90
+ catch {
91
+ // Body was not JSON (HTML error page, plain-text proxy error, …).
92
+ // Don't discard it — app the raw text so callers can diagnose.
93
+ const snippet = text.length > MAX_RESPONSE_SNIPPET_LENGTH
94
+ ? text.slice(0, MAX_RESPONSE_SNIPPET_LENGTH) + "…"
95
+ : text;
96
+ data = {
97
+ error: {
98
+ code: "non_json_response",
99
+ message: `relay returned a non-JSON body (status ${res.status})`,
100
+ details: { body: snippet },
101
+ },
102
+ };
103
+ }
104
+ }
105
+ }
106
+ return { ok: res.ok, status: res.status, data };
107
+ }
108
+ /** Assert a 2xx body is a non-null object before treating it as typed JSON. */
109
+ asObject(r) {
110
+ if (r.data === null ||
111
+ typeof r.data !== "object" ||
112
+ Array.isArray(r.data)) {
113
+ throw new HomespunApiError(r.status, "invalid_response", `relay returned a ${r.status} with a non-object body`, { body: r.data });
114
+ }
115
+ return r.data;
116
+ }
117
+ /** Throw a HomespunApiError from a failed RelayResponse. */
118
+ fail(r) {
119
+ const err = r.data?.error;
120
+ throw new HomespunApiError(r.status, err?.code ?? "relay_error", err?.message ?? `relay returned ${r.status}`, err?.details, {
121
+ hint: err?.hint,
122
+ retryable: err?.retryable,
123
+ docsUrl: err?.docs_url,
124
+ });
125
+ }
126
+ /**
127
+ * GET /v1/keys — the calling agent's own key info. The relay scopes this to
128
+ * the authenticated agent: it returns one key (the caller's), not a list.
129
+ */
130
+ async listKeys() {
131
+ const r = await this.call("GET", "/v1/keys");
132
+ if (!r.ok)
133
+ this.fail(r);
134
+ return this.asObject(r);
135
+ }
136
+ /**
137
+ * DELETE /v1/keys/:id — revoke an API key. The relay only permits revoking
138
+ * the caller's OWN key (any other id is rejected 403): this is a
139
+ * self-destruct. Returns 204 with no body on success.
140
+ */
141
+ async revokeKey(id) {
142
+ const r = await this.call("DELETE", `/v1/keys/${encodeURIComponent(id)}`);
143
+ if (!r.ok)
144
+ this.fail(r);
145
+ }
146
+ /**
147
+ * POST /v1/agents/claim — bind this agent to a human via a one-shot
148
+ * claim code the human generated in their settings UI. After a
149
+ * successful claim the agent's existing API key continues to work,
150
+ * but the agent (and its apps/templates) now belong to the
151
+ * claiming human. One-way operation — there is no unclaim in v1.
152
+ */
153
+ async claimAgent(code) {
154
+ const r = await this.call("POST", "/v1/agents/claim", { code });
155
+ if (!r.ok)
156
+ this.fail(r);
157
+ return this.asObject(r);
158
+ }
159
+ /**
160
+ * GET /v1/taste — the calling agent's freeform "taste notes" markdown attachment:
161
+ * presentation preferences the agent has picked up from human feedback over
162
+ * time. Returns `{ taste: null, updated_at: null, bytes: 0 }` when the
163
+ * agent has never written notes. Read this before generating an template so
164
+ * the agent applies prior feedback.
165
+ */
166
+ async getTaste() {
167
+ const r = await this.call("GET", "/v1/taste");
168
+ if (!r.ok)
169
+ this.fail(r);
170
+ return this.asObject(r);
171
+ }
172
+ /**
173
+ * PUT /v1/taste — whole-attachment replace of the calling agent's taste notes.
174
+ * Empty/whitespace-only values are rejected by the relay; callers asking to
175
+ * clear must use {@link clearTaste}. The relay caps the payload at the
176
+ * server's `MAX_TASTE_BYTES` (utf8 bytes).
177
+ */
178
+ async setTaste(taste) {
179
+ const r = await this.call("PUT", "/v1/taste", { taste });
180
+ if (!r.ok)
181
+ this.fail(r);
182
+ return this.asObject(r);
183
+ }
184
+ /**
185
+ * DELETE /v1/taste — clear the calling agent's taste notes (idempotent on
186
+ * the relay; clearing already-empty notes still succeeds). Returns 204 with
187
+ * no body.
188
+ */
189
+ async clearTaste() {
190
+ const r = await this.call("DELETE", "/v1/taste");
191
+ if (!r.ok)
192
+ this.fail(r);
193
+ }
194
+ /**
195
+ * POST /v1/feedback — submit a one-shot bug report, feature request, or
196
+ * note to the relay operator. Returns the new row's id, type, and
197
+ * created_at; the message is not echoed.
198
+ */
199
+ async submitFeedback(req) {
200
+ const r = await this.call("POST", "/v1/feedback", {
201
+ type: req.type,
202
+ message: req.message,
203
+ app_id: req.appId,
204
+ });
205
+ if (!r.ok)
206
+ this.fail(r);
207
+ return this.asObject(r);
208
+ }
209
+ /**
210
+ * GET /v1/feedback — the calling agent's own submissions, newest first.
211
+ * `before` is an opaque cursor from a previous page's `next_before`.
212
+ */
213
+ async listFeedback(opts = {}) {
214
+ const q = new URLSearchParams();
215
+ if (opts.limit != null)
216
+ q.set("limit", String(opts.limit));
217
+ if (opts.before != null && opts.before !== "")
218
+ q.set("before", opts.before);
219
+ const qs = q.toString();
220
+ const r = await this.call("GET", `/v1/feedback${qs ? "?" + qs : ""}`);
221
+ if (!r.ok)
222
+ this.fail(r);
223
+ return this.asObject(r);
224
+ }
225
+ // ------------------------------------------------------------------------
226
+ // Blobs (v0.1.0). Two-scope binary attachments with multipart upload.
227
+ // See proposal hs#152 for the original design; scopes were cut over to
228
+ // the v2 App domain (agent | app) in the 2c2 rebrand.
229
+ // ------------------------------------------------------------------------
230
+ /**
231
+ * Upload a attachment to the relay. Returns a `AttachmentRef` that can be referenced
232
+ * in event payloads (the relay's `format: homespun-attachment-id` schema vocab
233
+ * validates the id) or in `homespun create --input-data`.
234
+ *
235
+ * Scope defaults to "agent" (reusable). For `scope: "app"` pass `appId`.
236
+ * The calling agent's owning human must own the referenced App;
237
+ * cross-tenant attempts return app_not_found.
238
+ *
239
+ * MIME is inferred from `mime` if supplied; otherwise the relay sniffs
240
+ * leading bytes and may reject with mime_mismatch / mime_disallowed.
241
+ *
242
+ * Backed by the relay's multipart `POST /v1/attachments` (the fallback path).
243
+ * For large uploads (>1 MB on hosted Azure) call `presignBlob()` +
244
+ * `confirmBlob()` instead — those use SAS direct-to-storage and don't
245
+ * stream bytes through the relay.
246
+ */
247
+ async uploadBlob(file, opts = {}) {
248
+ const fd = new FormData();
249
+ let attachment;
250
+ if (file instanceof Blob) {
251
+ attachment = file;
252
+ }
253
+ else {
254
+ // Buffer / Uint8Array path — wrap in a Blob with the declared MIME.
255
+ // Copy into a freshly allocated Uint8Array so the buffer type
256
+ // narrows from `ArrayBufferLike` (which includes SharedArrayBuffer)
257
+ // to `ArrayBuffer` specifically — the Blob constructor accepts only
258
+ // the latter under @types/node ≥25 + TS ≥5.7's generic narrowing of
259
+ // Uint8Array<TArrayBuffer>. `new Uint8Array(length)` returns
260
+ // `Uint8Array<ArrayBuffer>` by construction, satisfying AttachmentPart
261
+ // without a type cast. The extra copy is one walk over the bytes —
262
+ // negligible vs the network upload that follows.
263
+ const src = file instanceof Uint8Array ? file : new Uint8Array(file);
264
+ const u8 = new Uint8Array(src.byteLength);
265
+ u8.set(src);
266
+ attachment = new Blob([u8], {
267
+ type: opts.mime ?? "application/octet-stream",
268
+ });
269
+ }
270
+ fd.set("file", attachment, opts.filename ?? "attachment");
271
+ if (opts.scope)
272
+ fd.set("scope", opts.scope);
273
+ if (opts.appId)
274
+ fd.set("app_id", opts.appId);
275
+ if (opts.filename)
276
+ fd.set("filename", opts.filename);
277
+ const url = this.base + "/v1/attachments";
278
+ let res;
279
+ try {
280
+ res = await this.fetchImpl(url, {
281
+ method: "POST",
282
+ headers: {
283
+ authorization: "Bearer " + this.apiKey,
284
+ ...(this.cliVersion
285
+ ? { "x-homespun-cli-version": this.cliVersion }
286
+ : {}),
287
+ },
288
+ body: fd,
289
+ });
290
+ }
291
+ catch (e) {
292
+ const msg = e instanceof Error ? e.message : String(e);
293
+ throw new HomespunApiError(0, "fetch_error", msg);
294
+ }
295
+ const text = await res.text().catch(() => "");
296
+ let data;
297
+ try {
298
+ data = text ? JSON.parse(text) : null;
299
+ }
300
+ catch {
301
+ throw new HomespunApiError(res.status, "non_json_response", `relay returned a non-JSON body (status ${res.status})`);
302
+ }
303
+ if (!res.ok) {
304
+ this.fail({ ok: false, status: res.status, data });
305
+ }
306
+ return data;
307
+ }
308
+ /** GET /v1/attachments/:id — download bytes as an ArrayBuffer. */
309
+ async downloadBlob(attachmentId) {
310
+ const url = this.base + "/v1/attachments/" + encodeURIComponent(attachmentId);
311
+ const res = await this.fetchImpl(url, {
312
+ method: "GET",
313
+ headers: {
314
+ authorization: "Bearer " + this.apiKey,
315
+ ...(this.cliVersion
316
+ ? { "x-homespun-cli-version": this.cliVersion }
317
+ : {}),
318
+ },
319
+ });
320
+ if (!res.ok) {
321
+ const text = await res.text().catch(() => "");
322
+ let data;
323
+ try {
324
+ data = text ? JSON.parse(text) : null;
325
+ }
326
+ catch {
327
+ data = null;
328
+ }
329
+ this.fail({ ok: false, status: res.status, data });
330
+ }
331
+ return res.arrayBuffer();
332
+ }
333
+ /**
334
+ * GET a attachment's metadata only — useful before downloading large attachments, or
335
+ * for `homespun attachment show <id>` which doesn't want the bytes. Returns the full
336
+ * AttachmentRef (the same shape POST /v1/attachments returns): id, scope, mime, size,
337
+ * sha256, filename, width, height, status, scope FKs, timestamps.
338
+ *
339
+ * Backed by GET /v1/attachments/:id/metadata which serves the JSON AttachmentRef
340
+ * without streaming the bytes — cheap on the relay and avoids the
341
+ * encrypt-at-rest decrypt cost when only the metadata is needed.
342
+ */
343
+ async getBlob(attachmentId) {
344
+ const r = await this.call("GET", "/v1/attachments/" + encodeURIComponent(attachmentId) + "/metadata");
345
+ if (!r.ok)
346
+ this.fail(r);
347
+ return this.asObject(r);
348
+ }
349
+ /** DELETE /v1/attachments/:id — soft-delete (idempotent). */
350
+ async deleteBlob(attachmentId) {
351
+ const r = await this.call("DELETE", "/v1/attachments/" + encodeURIComponent(attachmentId));
352
+ if (!r.ok)
353
+ this.fail(r);
354
+ return { deleted: true };
355
+ }
356
+ /**
357
+ * Mint a `/b/<token>` capability URL for `attachmentId`. Default TTL is set by
358
+ * the relay (24h agent-scope, 30d app-scope). `once: true`
359
+ * tokens self-delete on first GET.
360
+ */
361
+ async mintBlobToken(attachmentId, opts = {}) {
362
+ const r = await this.call("POST", "/v1/attachments/" + encodeURIComponent(attachmentId) + "/tokens", { ttl_seconds: opts.ttlSeconds, once: opts.once });
363
+ if (!r.ok)
364
+ this.fail(r);
365
+ return r.data;
366
+ }
367
+ /** Revoke a previously-minted token. Idempotent. */
368
+ async revokeBlobToken(attachmentId, tokenId) {
369
+ const r = await this.call("DELETE", "/v1/attachments/" +
370
+ encodeURIComponent(attachmentId) +
371
+ "/tokens/" +
372
+ encodeURIComponent(tokenId));
373
+ if (!r.ok)
374
+ this.fail(r);
375
+ return r.data;
376
+ }
377
+ /**
378
+ * GET /v1/attachments — list YOUR agent's non-deleted attachments (newest first).
379
+ * Paginated via opaque cursor: when `next_cursor` is non-null, pass it
380
+ * back as `cursor` on the next call.
381
+ */
382
+ async listBlobs(opts = {}) {
383
+ const params = new URLSearchParams();
384
+ if (opts.cursor !== undefined)
385
+ params.set("cursor", opts.cursor);
386
+ if (opts.limit !== undefined)
387
+ params.set("limit", String(opts.limit));
388
+ const qs = params.toString();
389
+ const r = await this.call("GET", "/v1/attachments" + (qs ? "?" + qs : ""));
390
+ if (!r.ok)
391
+ this.fail(r);
392
+ return r.data;
393
+ }
394
+ /**
395
+ * GET /v1/attachments/:id/tokens — enumerate the capability tokens minted
396
+ * against one attachment, including revoked rows (for audit). The plaintext
397
+ * token is NEVER returned — it isn't stored, only its sha256 is.
398
+ */
399
+ async listBlobTokens(attachmentId) {
400
+ const r = await this.call("GET", "/v1/attachments/" + encodeURIComponent(attachmentId) + "/tokens");
401
+ if (!r.ok)
402
+ this.fail(r);
403
+ return r.data;
404
+ }
405
+ /**
406
+ * Issue a presigned PUT URL for direct-to-storage upload. Returns the
407
+ * upload URL + the attachment_id (already reserved in the relay's DB with
408
+ * status=pending) + expiry. After PUTting the bytes to the URL, call
409
+ * `confirmBlob(attachment_id)` to finalise.
410
+ *
411
+ * Filesystem backend returns 501 not_implemented — use uploadBlob()
412
+ * (multipart fallback) instead. Azure backend returns a SAS URL.
413
+ */
414
+ async presignBlob(opts) {
415
+ const r = await this.call("POST", "/v1/attachments/presign", {
416
+ mime: opts.mime,
417
+ size: opts.size,
418
+ sha256: opts.sha256,
419
+ scope: opts.scope,
420
+ app_id: opts.appId,
421
+ filename: opts.filename,
422
+ });
423
+ if (!r.ok)
424
+ this.fail(r);
425
+ return r.data;
426
+ }
427
+ /** Finalise a presigned upload — relay HEADs the bytes, verifies, flips ready. */
428
+ async confirmBlob(attachmentId) {
429
+ const r = await this.call("POST", "/v1/attachments/" + encodeURIComponent(attachmentId) + "/confirm");
430
+ if (!r.ok)
431
+ this.fail(r);
432
+ return r.data;
433
+ }
434
+ // -------------------------------------------------------------------------
435
+ // v2 app lifecycle (spec-cli §2.1). `:id` on every route below is always the
436
+ // App.id (cuid) — a slug is resolved to an id via `listApps({ slug })`
437
+ // first (the CLI's `resolveAppId` helper does this transparently).
438
+ // -------------------------------------------------------------------------
439
+ /** POST /v1/apps — create (deploy) a new App + its first AppVersion. */
440
+ async deployApp(req) {
441
+ const r = await this.call("POST", "/v1/apps", {
442
+ html: req.html,
443
+ manifest: req.manifest,
444
+ visibility: req.visibility,
445
+ slug: req.slug,
446
+ });
447
+ if (!r.ok)
448
+ this.fail(r);
449
+ return this.asObject(r);
450
+ }
451
+ /** POST /v1/apps/:id/versions — redeploy (compat-gated unless force). */
452
+ async redeployApp(appId, req) {
453
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/versions`, { html: req.html, manifest: req.manifest, force: req.force });
454
+ if (!r.ok)
455
+ this.fail(r);
456
+ return this.asObject(r);
457
+ }
458
+ /**
459
+ * GET /v1/apps — list apps scoped to the calling agent's owning human.
460
+ * `slug` is an exact-match filter — the one case a caller resolves a
461
+ * human-given slug to an id (see the class-level note above).
462
+ */
463
+ async listApps(opts = {}) {
464
+ const q = new URLSearchParams();
465
+ if (opts.status !== undefined)
466
+ q.set("status", opts.status);
467
+ if (opts.limit !== undefined)
468
+ q.set("limit", String(opts.limit));
469
+ if (opts.cursor !== undefined && opts.cursor !== "")
470
+ q.set("cursor", opts.cursor);
471
+ if (opts.slug !== undefined && opts.slug !== "")
472
+ q.set("slug", opts.slug);
473
+ const qs = q.toString();
474
+ const r = await this.call("GET", `/v1/apps${qs ? "?" + qs : ""}`);
475
+ if (!r.ok)
476
+ this.fail(r);
477
+ return this.asObject(r);
478
+ }
479
+ /** GET /v1/apps/:id — full app detail (manifest, current_version, quota). */
480
+ async getApp(appId) {
481
+ const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}`);
482
+ if (!r.ok)
483
+ this.fail(r);
484
+ return this.asObject(r);
485
+ }
486
+ /** PATCH /v1/apps/:id — update visibility (the only mutable app field). */
487
+ async updateApp(appId, visibility) {
488
+ const r = await this.call("PATCH", `/v1/apps/${encodeURIComponent(appId)}`, { visibility });
489
+ if (!r.ok)
490
+ this.fail(r);
491
+ return this.asObject(r);
492
+ }
493
+ /** DELETE /v1/apps/:id — soft-delete (idempotent). */
494
+ async deleteApp(appId) {
495
+ const r = await this.call("DELETE", `/v1/apps/${encodeURIComponent(appId)}`);
496
+ if (!r.ok)
497
+ this.fail(r);
498
+ }
499
+ /** POST /v1/apps/:id/wake — wake a dormant app; a no-op on a non-dormant one. */
500
+ async wakeApp(appId) {
501
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/wake`);
502
+ if (!r.ok)
503
+ this.fail(r);
504
+ return this.asObject(r);
505
+ }
506
+ // -------------------------------------------------------------------------
507
+ // v2 app membership (auth spec §6, spec-cli §2.5). Auth is owner-or-agent;
508
+ // this client always authenticates as the owning agent. Note: unlike most
509
+ // v1/v2 wire shapes, the member fields below are camelCase on the wire
510
+ // (humanId/createdAt), matching packages/relay/src/http/routes/app-members.ts
511
+ // exactly rather than the usual snake_case convention.
512
+ //
513
+ // Template `install`/`uninstall` is intentionally NOT wrapped here — that
514
+ // route is `requireHuman` (human login cookie only), not agent-key
515
+ // authorizable, so it has no place on this agent-facing client.
516
+ // -------------------------------------------------------------------------
517
+ /**
518
+ * POST /v1/apps/:id/members — add or invite a member by email. If a Human
519
+ * already exists for the email, the member row is created immediately
520
+ * (`{ member }`); otherwise the relay mints a signed invite and emails a
521
+ * magic link (`{ ok: true, invited, expires_at }`). 503s if the relay has
522
+ * EMAIL_PROVIDER=none (no invite-email path available).
523
+ */
524
+ async addAppMember(appId, opts) {
525
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/members`, { email: opts.email, role: opts.role });
526
+ if (!r.ok)
527
+ this.fail(r);
528
+ return this.asObject(r);
529
+ }
530
+ /** GET /v1/apps/:id/members — list the app's owner + member rows. */
531
+ async listAppMembers(appId) {
532
+ const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}/members`);
533
+ if (!r.ok)
534
+ this.fail(r);
535
+ return this.asObject(r);
536
+ }
537
+ /**
538
+ * DELETE /v1/apps/:id/members/:humanId — remove a member (idempotent);
539
+ * cascades to revoke that human's live app sessions. The app owner cannot
540
+ * be removed — the relay refuses with a 409 conflict.
541
+ */
542
+ async removeAppMember(appId, humanId) {
543
+ const r = await this.call("DELETE", `/v1/apps/${encodeURIComponent(appId)}/members/${encodeURIComponent(humanId)}`);
544
+ if (!r.ok)
545
+ this.fail(r);
546
+ }
547
+ // -------------------------------------------------------------------------
548
+ // v2 collection row CRUD + feed catch-up (spec-cli §2.2/§2.3). Same wire
549
+ // shapes as the v1 homespun-records methods above, mounted under
550
+ // /v1/apps/:id/collections/:name instead of /v1/apps/:id/records/:name.
551
+ // -------------------------------------------------------------------------
552
+ /** GET /v1/apps/:id/collections/:name — list rows (current-state page). */
553
+ async listAppRows(appId, collection, opts = {}) {
554
+ const q = new URLSearchParams();
555
+ if (opts.since !== undefined)
556
+ q.set("since", opts.since);
557
+ if (opts.limit !== undefined)
558
+ q.set("limit", String(opts.limit));
559
+ const qs = q.toString();
560
+ const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}/collections/${encodeURIComponent(collection)}${qs ? "?" + qs : ""}`);
561
+ if (!r.ok)
562
+ this.fail(r);
563
+ return this.asObject(r);
564
+ }
565
+ /**
566
+ * GET /v1/apps/:id/collections/:name/:key — read one row. A dedicated
567
+ * route (not a client-side scan like v1's `getRecord`) — spec-cli §8
568
+ * ruling 3 confirms it as a first-class relay route.
569
+ */
570
+ async getAppRow(appId, collection, key) {
571
+ const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(key)}`);
572
+ if (!r.ok)
573
+ this.fail(r);
574
+ return this.asObject(r);
575
+ }
576
+ /**
577
+ * POST /v1/apps/:id/collections/:name — upsert (create, or return the
578
+ * existing row when `key` collides — `deduped: true`). §8 ruling 4: this is
579
+ * the ONLY create-shaped verb — there is no strict create that errors on
580
+ * an existing key.
581
+ */
582
+ async upsertAppRow(appId, collection, body) {
583
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/collections/${encodeURIComponent(collection)}`, body);
584
+ if (!r.ok)
585
+ this.fail(r);
586
+ return this.asObject(r);
587
+ }
588
+ /** PATCH /v1/apps/:id/collections/:name/:key — optimistic-locked update. */
589
+ async updateAppRow(appId, collection, key, body) {
590
+ const r = await this.call("PATCH", `/v1/apps/${encodeURIComponent(appId)}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(key)}`, body);
591
+ if (!r.ok)
592
+ this.fail(r);
593
+ return this.asObject(r);
594
+ }
595
+ /** DELETE /v1/apps/:id/collections/:name/:key — soft-delete. */
596
+ async deleteAppRow(appId, collection, key, opts = {}) {
597
+ const body = opts.ifMatch != null ? { if_match: opts.ifMatch } : undefined;
598
+ const r = await this.call("DELETE", `/v1/apps/${encodeURIComponent(appId)}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(key)}`, body);
599
+ if (!r.ok)
600
+ this.fail(r);
601
+ }
602
+ /**
603
+ * GET /v1/apps/:id/feed — change-feed catch-up; the long-poll fallback
604
+ * transport for `watch` (spec-cli §2.3/§5). `wait` (0-30s) long-polls when
605
+ * the caller is already caught up.
606
+ */
607
+ async getAppFeed(appId, opts) {
608
+ const q = new URLSearchParams();
609
+ q.set("since", String(opts.since));
610
+ if (opts.limit !== undefined)
611
+ q.set("limit", String(opts.limit));
612
+ if (opts.wait !== undefined)
613
+ q.set("wait", String(opts.wait));
614
+ const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}/feed?${q.toString()}`);
615
+ if (!r.ok)
616
+ this.fail(r);
617
+ return this.asObject(r);
618
+ }
619
+ }
@@ -0,0 +1,24 @@
1
+ export declare const RASTER_ICON_MIME_ALLOWLIST: readonly ["image/png", "image/jpeg", "image/webp", "image/gif"];
2
+ export type RasterIconMime = (typeof RASTER_ICON_MIME_ALLOWLIST)[number];
3
+ /** True iff `mime` is a raster image type allowed as an icon. */
4
+ export declare function isRasterImageMime(mime: string | null | undefined): boolean;
5
+ export declare const MAX_ICON_EMOJI_BYTES = 64;
6
+ /**
7
+ * Validate an icon emoji. Returns `{ ok: true }` for a single emoji grapheme,
8
+ * or `{ ok: false, error }` with a human-readable reason otherwise.
9
+ *
10
+ * Rules:
11
+ * - non-empty, ≤ MAX_ICON_EMOJI_BYTES bytes
12
+ * - no control characters
13
+ * - exactly ONE grapheme cluster (via Intl.Segmenter)
14
+ * - that grapheme contains at least one Extended_Pictographic code point
15
+ * (so plain ASCII letters/digits and bare symbols are rejected)
16
+ */
17
+ export declare function validateIconEmoji(raw: string): {
18
+ ok: true;
19
+ } | {
20
+ ok: false;
21
+ error: string;
22
+ };
23
+ /** Convenience boolean form of {@link validateIconEmoji}. */
24
+ export declare function isValidIconEmoji(raw: string): boolean;