@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/LICENSE +21 -0
- package/README.md +31 -0
- package/dist/app-stream.d.ts +68 -0
- package/dist/app-stream.js +131 -0
- package/dist/client.d.ts +508 -0
- package/dist/client.js +619 -0
- package/dist/icons.d.ts +24 -0
- package/dist/icons.js +99 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +9 -0
- package/dist/limits.d.ts +8 -0
- package/dist/limits.js +10 -0
- package/dist/register.d.ts +32 -0
- package/dist/register.js +65 -0
- package/dist/schemas.d.ts +15 -0
- package/dist/schemas.js +24 -0
- package/dist/stream.d.ts +70 -0
- package/dist/stream.js +113 -0
- package/dist/types.d.ts +144 -0
- package/dist/types.js +7 -0
- package/package.json +58 -0
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
|
+
}
|
package/dist/icons.d.ts
ADDED
|
@@ -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;
|