@homespunapps/core 1.0.0 → 1.0.1

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.d.ts CHANGED
@@ -159,6 +159,25 @@ export declare class HomespunClient {
159
159
  * stream bytes through the relay.
160
160
  */
161
161
  uploadBlob(file: Blob | Buffer | Uint8Array, opts?: UploadBlobOptions): Promise<AttachmentRef>;
162
+ /**
163
+ * Upload a attachment from INLINE base64 bytes: the no-filesystem sibling of
164
+ * `uploadBlob()`. Use this when the bytes were produced in memory (an image
165
+ * the agent generated, a document it assembled) and there is no local file to
166
+ * stream: an MCP client running inside a Claude session has no filesystem to
167
+ * point `uploadBlob()` at.
168
+ *
169
+ * The bytes are sent as base64 in a JSON body to the relay's
170
+ * `POST /v1/attachments` inline variant ({ content_base64, filename?, mime?,
171
+ * scope?, app_id? }). The relay decodes them behind a pre-decode size guard and runs
172
+ * the IDENTICAL validation pipeline the multipart `uploadBlob()` path runs:
173
+ * magic-byte MIME sniff, BLOB_MIME_ALLOWLIST, per-attachment size cap, and the
174
+ * per-agent / per-app / per-account quota reservation. `mime` is advisory
175
+ * (the relay sniffs the real type regardless), and an oversized or disallowed
176
+ * inline upload returns the same errors the file path would.
177
+ *
178
+ * @param contentBase64 standard base64 (e.g. `Buffer.from(bytes).toString("base64")`).
179
+ */
180
+ uploadBlobInline(contentBase64: string, opts?: UploadBlobOptions): Promise<AttachmentRef>;
162
181
  /** GET /v1/attachments/:id — download bytes as an ArrayBuffer. */
163
182
  downloadBlob(attachmentId: string): Promise<ArrayBuffer>;
164
183
  /**
@@ -219,8 +238,22 @@ export declare class HomespunClient {
219
238
  upload_url: string;
220
239
  expires_at: string;
221
240
  }>;
222
- /** Finalise a presigned upload — relay HEADs the bytes, verifies, flips ready. */
241
+ /**
242
+ * Finalise a presigned upload. After the client PUTs the bytes to the
243
+ * `upload_url` from `presignBlob()`, the relay re-reads the stored bytes and
244
+ * runs the SAME validation a normal upload runs: it BYTE-SNIFFS the actual
245
+ * content and derives the stored/served mime from that (never the
246
+ * presign-declared mime), re-verifies size + sha256, enforces the allowlist +
247
+ * quota, and runs the scan hook, before flipping the attachment to `ready`.
248
+ * A finalize whose bytes fail any check leaves the attachment unready (it is
249
+ * never served) and returns a clean error. Returns the ready `AttachmentRef`.
250
+ */
223
251
  confirmBlob(attachmentId: string): Promise<AttachmentRef>;
252
+ /**
253
+ * Alias for {@link confirmBlob}: the "finalize" half of the presign ->
254
+ * PUT -> finalize flow, named to match the MCP `attachments` action.
255
+ */
256
+ finalizeBlob(attachmentId: string): Promise<AttachmentRef>;
224
257
  /** POST /v1/apps — create (deploy) a new App + its first AppVersion. */
225
258
  deployApp(req: DeployAppRequest): Promise<DeployAppResponse>;
226
259
  /** POST /v1/apps/:id/versions — redeploy (compat-gated unless force). */
@@ -250,6 +283,23 @@ export declare class HomespunClient {
250
283
  id: string;
251
284
  status: string;
252
285
  }>;
286
+ /**
287
+ * POST /v1/apps/:id/domain - bind a custom domain to the app. Returns the
288
+ * record including `dns_records`: the DNS entries the domain owner must
289
+ * publish (the routing CNAME plus any Cloudflare validation records).
290
+ */
291
+ setAppDomain(appId: string, domain: string): Promise<AppDomain>;
292
+ /**
293
+ * GET /v1/apps/:id/domain - the app's domain record, live-refreshed against
294
+ * Cloudflare when the feature is enabled (status/last_error/last_checked_at
295
+ * update as a side effect).
296
+ */
297
+ getAppDomain(appId: string): Promise<AppDomain>;
298
+ /**
299
+ * DELETE /v1/apps/:id/domain - remove the domain binding (Cloudflare
300
+ * hostname best-effort + record + quota release). Idempotent.
301
+ */
302
+ deleteAppDomain(appId: string): Promise<void>;
253
303
  /**
254
304
  * POST /v1/apps/:id/members — add or invite a member by email. If a Human
255
305
  * already exists for the email, the member row is created immediately
@@ -318,6 +368,35 @@ export declare class HomespunClient {
318
368
  limit?: number;
319
369
  wait?: number;
320
370
  }): Promise<AppFeedPage>;
371
+ /** POST /v1/community/publish: publish an owned app as a pending template. */
372
+ publishCommunityTemplate(req: PublishCommunityTemplateRequest): Promise<PublishCommunityTemplateResponse>;
373
+ /** GET /v1/community/pending (operator): list pending submissions. */
374
+ listCommunitySubmissions(opts?: {
375
+ limit?: number;
376
+ cursor?: string;
377
+ }): Promise<CommunitySubmissionsPage>;
378
+ /** GET /v1/community/submissions/:id (operator): a submission's full content. */
379
+ getCommunitySubmission(snapshotId: string): Promise<CommunitySubmissionDetail>;
380
+ /**
381
+ * POST /v1/community/submissions/:id/{approve,reject} (operator): review a
382
+ * submission. `decision` "reject" requires a `note` (delivered to the
383
+ * publisher's app feed).
384
+ */
385
+ reviewCommunitySubmission(snapshotId: string, review: {
386
+ decision: "approve" | "reject";
387
+ note?: string;
388
+ }): Promise<CommunitySubmissionDetail>;
389
+ }
390
+ /**
391
+ * One multi-file-deploy asset: bytes the deployed page references by a stable,
392
+ * app-relative, same-origin path (e.g. `frames/000.jpg`). `content_base64` is the
393
+ * standard base64 of the raw bytes; `mime` is advisory (the relay sniffs the
394
+ * real type from the leading bytes).
395
+ */
396
+ export interface AppAsset {
397
+ path: string;
398
+ content_base64: string;
399
+ mime?: string;
321
400
  }
322
401
  /** Request body for `POST /v1/apps` — create (deploy) a new App. */
323
402
  export interface DeployAppRequest {
@@ -327,6 +406,13 @@ export interface DeployAppRequest {
327
406
  visibility?: "private" | "link" | "public";
328
407
  /** Accepted for visibility public|private (including the omitted default); rejected for link. */
329
408
  slug?: string;
409
+ /**
410
+ * Optional asset bundle shipped alongside the HTML in ONE deploy. Each asset
411
+ * is validated + stored app-scoped exactly like an attachment and served at
412
+ * its `path` on the app's own origin, so `<img src="frames/000.jpg">` just
413
+ * works. Rejected atomically if any asset fails validation.
414
+ */
415
+ assets?: AppAsset[];
330
416
  }
331
417
  export interface DeployAppResponse {
332
418
  app_id: string;
@@ -342,6 +428,11 @@ export interface RedeployAppRequest {
342
428
  manifest: unknown;
343
429
  /** Bypass the compat gate; a narrowing collection is detached, not deleted. */
344
430
  force?: boolean;
431
+ /**
432
+ * Optional asset bundle for this version. Replaces the previous version's asset
433
+ * set atomically (the new version carries its own map; old assets are detached).
434
+ */
435
+ assets?: AppAsset[];
345
436
  }
346
437
  export interface RedeployAppResponse {
347
438
  app_id: string;
@@ -374,6 +465,24 @@ export interface AppsPage {
374
465
  items: AppSummary[];
375
466
  next_cursor: string | null;
376
467
  }
468
+ /** One DNS record the domain owner must publish (custom domains). */
469
+ export interface AppDomainDnsRecord {
470
+ type: string;
471
+ name: string;
472
+ value: string;
473
+ purpose: "routing" | "ownership" | "certificate_validation";
474
+ }
475
+ /** The app's custom-domain record - the shape the /domain routes return. */
476
+ export interface AppDomain {
477
+ domain: string;
478
+ status: "pending" | "active" | "error" | "removed";
479
+ dns_records: AppDomainDnsRecord[];
480
+ cf_hostname_id: string | null;
481
+ last_error: string | null;
482
+ last_checked_at: string | null;
483
+ created_at: string;
484
+ updated_at: string;
485
+ }
377
486
  /**
378
487
  * One member row — the shape `listAppMembers` returns and the `member` half
379
488
  * of `addAppMember`'s union returns. Fields are camelCase on the wire (see
@@ -438,6 +547,53 @@ export interface AppFeedPage {
438
547
  cursor: number;
439
548
  truncated: boolean;
440
549
  }
550
+ /** Request body for `publishCommunityTemplate()`. */
551
+ export interface PublishCommunityTemplateRequest {
552
+ /** The owned app to publish. */
553
+ appId: string;
554
+ /** Listing title; defaults server-side to the app's manifest name. */
555
+ title?: string;
556
+ /** Listing blurb; defaults server-side to the manifest description. */
557
+ description?: string;
558
+ /** Optional single-word category. */
559
+ category?: string;
560
+ /** Optional curation tags. */
561
+ tags?: string[];
562
+ }
563
+ export interface PublishCommunityTemplateResponse {
564
+ snapshot_id: string;
565
+ review_status: "pending" | "approved" | "rejected";
566
+ name: string;
567
+ seeded_row_count: number;
568
+ }
569
+ /** A pending-submission summary in the operator review queue. */
570
+ export interface CommunitySubmissionSummary {
571
+ snapshot_id: string;
572
+ name: string;
573
+ description: string | null;
574
+ category: string | null;
575
+ tags: string[];
576
+ review_status: "pending" | "approved" | "rejected";
577
+ publisher_human_id: string | null;
578
+ publisher_name: string | null;
579
+ source_app_id: string | null;
580
+ seeded_row_count: number;
581
+ published_at: string;
582
+ }
583
+ export interface CommunitySubmissionsPage {
584
+ items: CommunitySubmissionSummary[];
585
+ next_cursor: string | null;
586
+ }
587
+ /** A submission's FULL content, for operator review. */
588
+ export interface CommunitySubmissionDetail extends CommunitySubmissionSummary {
589
+ html: string;
590
+ manifest: unknown;
591
+ seed_rows: unknown;
592
+ version_id: string | null;
593
+ reviewed_at: string | null;
594
+ review_note: string | null;
595
+ superseded_at: string | null;
596
+ }
441
597
  /** Per-attachment metadata as returned by `POST /v1/attachments` and friends. */
442
598
  export interface AttachmentRef {
443
599
  attachment_id: string;
package/dist/client.js CHANGED
@@ -305,6 +305,39 @@ export class HomespunClient {
305
305
  }
306
306
  return data;
307
307
  }
308
+ /**
309
+ * Upload a attachment from INLINE base64 bytes: the no-filesystem sibling of
310
+ * `uploadBlob()`. Use this when the bytes were produced in memory (an image
311
+ * the agent generated, a document it assembled) and there is no local file to
312
+ * stream: an MCP client running inside a Claude session has no filesystem to
313
+ * point `uploadBlob()` at.
314
+ *
315
+ * The bytes are sent as base64 in a JSON body to the relay's
316
+ * `POST /v1/attachments` inline variant ({ content_base64, filename?, mime?,
317
+ * scope?, app_id? }). The relay decodes them behind a pre-decode size guard and runs
318
+ * the IDENTICAL validation pipeline the multipart `uploadBlob()` path runs:
319
+ * magic-byte MIME sniff, BLOB_MIME_ALLOWLIST, per-attachment size cap, and the
320
+ * per-agent / per-app / per-account quota reservation. `mime` is advisory
321
+ * (the relay sniffs the real type regardless), and an oversized or disallowed
322
+ * inline upload returns the same errors the file path would.
323
+ *
324
+ * @param contentBase64 standard base64 (e.g. `Buffer.from(bytes).toString("base64")`).
325
+ */
326
+ async uploadBlobInline(contentBase64, opts = {}) {
327
+ const body = { content_base64: contentBase64 };
328
+ if (opts.scope)
329
+ body.scope = opts.scope;
330
+ if (opts.appId)
331
+ body.app_id = opts.appId;
332
+ if (opts.filename)
333
+ body.filename = opts.filename;
334
+ if (opts.mime)
335
+ body.mime = opts.mime;
336
+ const r = await this.call("POST", "/v1/attachments", body);
337
+ if (!r.ok)
338
+ this.fail(r);
339
+ return this.asObject(r);
340
+ }
308
341
  /** GET /v1/attachments/:id — download bytes as an ArrayBuffer. */
309
342
  async downloadBlob(attachmentId) {
310
343
  const url = this.base + "/v1/attachments/" + encodeURIComponent(attachmentId);
@@ -424,13 +457,29 @@ export class HomespunClient {
424
457
  this.fail(r);
425
458
  return r.data;
426
459
  }
427
- /** Finalise a presigned upload — relay HEADs the bytes, verifies, flips ready. */
460
+ /**
461
+ * Finalise a presigned upload. After the client PUTs the bytes to the
462
+ * `upload_url` from `presignBlob()`, the relay re-reads the stored bytes and
463
+ * runs the SAME validation a normal upload runs: it BYTE-SNIFFS the actual
464
+ * content and derives the stored/served mime from that (never the
465
+ * presign-declared mime), re-verifies size + sha256, enforces the allowlist +
466
+ * quota, and runs the scan hook, before flipping the attachment to `ready`.
467
+ * A finalize whose bytes fail any check leaves the attachment unready (it is
468
+ * never served) and returns a clean error. Returns the ready `AttachmentRef`.
469
+ */
428
470
  async confirmBlob(attachmentId) {
429
471
  const r = await this.call("POST", "/v1/attachments/" + encodeURIComponent(attachmentId) + "/confirm");
430
472
  if (!r.ok)
431
473
  this.fail(r);
432
474
  return r.data;
433
475
  }
476
+ /**
477
+ * Alias for {@link confirmBlob}: the "finalize" half of the presign ->
478
+ * PUT -> finalize flow, named to match the MCP `attachments` action.
479
+ */
480
+ async finalizeBlob(attachmentId) {
481
+ return this.confirmBlob(attachmentId);
482
+ }
434
483
  // -------------------------------------------------------------------------
435
484
  // v2 app lifecycle (spec-cli §2.1). `:id` on every route below is always the
436
485
  // App.id (cuid) — a slug is resolved to an id via `listApps({ slug })`
@@ -443,6 +492,7 @@ export class HomespunClient {
443
492
  manifest: req.manifest,
444
493
  visibility: req.visibility,
445
494
  slug: req.slug,
495
+ assets: req.assets,
446
496
  });
447
497
  if (!r.ok)
448
498
  this.fail(r);
@@ -450,7 +500,12 @@ export class HomespunClient {
450
500
  }
451
501
  /** POST /v1/apps/:id/versions — redeploy (compat-gated unless force). */
452
502
  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 });
503
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/versions`, {
504
+ html: req.html,
505
+ manifest: req.manifest,
506
+ force: req.force,
507
+ assets: req.assets,
508
+ });
454
509
  if (!r.ok)
455
510
  this.fail(r);
456
511
  return this.asObject(r);
@@ -504,6 +559,42 @@ export class HomespunClient {
504
559
  return this.asObject(r);
505
560
  }
506
561
  // -------------------------------------------------------------------------
562
+ // Custom domains (Cloudflare for SaaS). One primary domain per app. The
563
+ // relay rejects setAppDomain with `custom_domains_not_enabled` (501) when
564
+ // the deployment has no Cloudflare configuration.
565
+ // -------------------------------------------------------------------------
566
+ /**
567
+ * POST /v1/apps/:id/domain - bind a custom domain to the app. Returns the
568
+ * record including `dns_records`: the DNS entries the domain owner must
569
+ * publish (the routing CNAME plus any Cloudflare validation records).
570
+ */
571
+ async setAppDomain(appId, domain) {
572
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/domain`, { domain });
573
+ if (!r.ok)
574
+ this.fail(r);
575
+ return this.asObject(r);
576
+ }
577
+ /**
578
+ * GET /v1/apps/:id/domain - the app's domain record, live-refreshed against
579
+ * Cloudflare when the feature is enabled (status/last_error/last_checked_at
580
+ * update as a side effect).
581
+ */
582
+ async getAppDomain(appId) {
583
+ const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}/domain`);
584
+ if (!r.ok)
585
+ this.fail(r);
586
+ return this.asObject(r);
587
+ }
588
+ /**
589
+ * DELETE /v1/apps/:id/domain - remove the domain binding (Cloudflare
590
+ * hostname best-effort + record + quota release). Idempotent.
591
+ */
592
+ async deleteAppDomain(appId) {
593
+ const r = await this.call("DELETE", `/v1/apps/${encodeURIComponent(appId)}/domain`);
594
+ if (!r.ok)
595
+ this.fail(r);
596
+ }
597
+ // -------------------------------------------------------------------------
507
598
  // v2 app membership (auth spec §6, spec-cli §2.5). Auth is owner-or-agent;
508
599
  // this client always authenticates as the owning agent. Note: unlike most
509
600
  // v1/v2 wire shapes, the member fields below are camelCase on the wire
@@ -616,4 +707,54 @@ export class HomespunClient {
616
707
  this.fail(r);
617
708
  return this.asObject(r);
618
709
  }
710
+ // -------------------------------------------------------------------------
711
+ // Community publish + review (M4b). publishCommunityTemplate is an OWNER
712
+ // action (the calling agent's owning human); the review methods are
713
+ // operator-gated server-side.
714
+ // -------------------------------------------------------------------------
715
+ /** POST /v1/community/publish: publish an owned app as a pending template. */
716
+ async publishCommunityTemplate(req) {
717
+ const r = await this.call("POST", "/v1/community/publish", {
718
+ app_id: req.appId,
719
+ title: req.title,
720
+ description: req.description,
721
+ category: req.category,
722
+ tags: req.tags,
723
+ });
724
+ if (!r.ok)
725
+ this.fail(r);
726
+ return this.asObject(r);
727
+ }
728
+ /** GET /v1/community/pending (operator): list pending submissions. */
729
+ async listCommunitySubmissions(opts = {}) {
730
+ const q = new URLSearchParams();
731
+ if (opts.limit !== undefined)
732
+ q.set("limit", String(opts.limit));
733
+ if (opts.cursor !== undefined && opts.cursor !== "")
734
+ q.set("cursor", opts.cursor);
735
+ const qs = q.toString();
736
+ const r = await this.call("GET", `/v1/community/pending${qs ? "?" + qs : ""}`);
737
+ if (!r.ok)
738
+ this.fail(r);
739
+ return this.asObject(r);
740
+ }
741
+ /** GET /v1/community/submissions/:id (operator): a submission's full content. */
742
+ async getCommunitySubmission(snapshotId) {
743
+ const r = await this.call("GET", `/v1/community/submissions/${encodeURIComponent(snapshotId)}`);
744
+ if (!r.ok)
745
+ this.fail(r);
746
+ return this.asObject(r);
747
+ }
748
+ /**
749
+ * POST /v1/community/submissions/:id/{approve,reject} (operator): review a
750
+ * submission. `decision` "reject" requires a `note` (delivered to the
751
+ * publisher's app feed).
752
+ */
753
+ async reviewCommunitySubmission(snapshotId, review) {
754
+ const path = `/v1/community/submissions/${encodeURIComponent(snapshotId)}/${review.decision}`;
755
+ const r = await this.call("POST", path, review.decision === "reject" ? { note: review.note } : undefined);
756
+ if (!r.ok)
757
+ this.fail(r);
758
+ return this.asObject(r);
759
+ }
619
760
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
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";
2
+ export type { ClientOptions, RelayResponse, AttachmentRef, UploadBlobOptions, PresignBlobOptions, AttachmentTokenMintResponse, ListBlobsOptions, AttachmentTokenAuditEntry, AttachmentTokenListResponse, QueryResponse, AppAsset, DeployAppRequest, DeployAppResponse, RedeployAppRequest, RedeployAppResponse, AppSummary, AppDetail, AppsPage, AppDomain, AppDomainDnsRecord, AppMember, AddAppMemberResult, AppRow, AppRowsPage, AppFeedEntry, AppFeedPage, } from "./client.js";
3
3
  export { openStream } from "./stream.js";
4
4
  export type { OpenStreamOptions, StreamHandlers, StreamHandle, } from "./stream.js";
5
5
  export { openAppStream, appWsUrlFromAppUrl } from "./app-stream.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homespunapps/core",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Homespun relay client: typed HTTP + WebSocket operations against a Homespun relay. Framework-free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,14 +11,9 @@
11
11
  "relay",
12
12
  "human-in-the-loop"
13
13
  ],
14
- "homepage": "https://github.com/aerolalit/homespun#readme",
15
- "repository": {
16
- "type": "git",
17
- "url": "git+https://github.com/aerolalit/homespun.git",
18
- "directory": "packages/core"
19
- },
14
+ "homepage": "https://homespun.dev",
20
15
  "bugs": {
21
- "url": "https://github.com/aerolalit/homespun/issues"
16
+ "url": "mailto:support@homespun.dev"
22
17
  },
23
18
  "engines": {
24
19
  "node": ">=20"