@homespunapps/core 1.0.0 → 1.4.2

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 CHANGED
@@ -143,6 +143,25 @@ export class HomespunClient {
143
143
  if (!r.ok)
144
144
  this.fail(r);
145
145
  }
146
+ /**
147
+ * POST /v1/keys: mint a NEW sibling API key for the calling agent's OWN
148
+ * identity. The relay derives the identity from the bearer token, so a caller
149
+ * can only ever mint a sibling of itself (never another agent's key). The new
150
+ * key has the same scope/ownership as the caller and shows up in a subsequent
151
+ * `listKeys()` call made WITH the new key.
152
+ *
153
+ * The raw `api_key` is returned exactly ONCE in this response and is never
154
+ * retrievable again (only its hash is stored). Bootstraps a fresh CLI /
155
+ * process credential from an MCP-driven agent that has no key of its own to
156
+ * hand off. The owner can revoke any minted sibling via the normal revoke
157
+ * path.
158
+ */
159
+ async mintKey() {
160
+ const r = await this.call("POST", "/v1/keys");
161
+ if (!r.ok)
162
+ this.fail(r);
163
+ return this.asObject(r);
164
+ }
146
165
  /**
147
166
  * POST /v1/agents/claim — bind this agent to a human via a one-shot
148
167
  * claim code the human generated in their settings UI. After a
@@ -305,6 +324,39 @@ export class HomespunClient {
305
324
  }
306
325
  return data;
307
326
  }
327
+ /**
328
+ * Upload a attachment from INLINE base64 bytes: the no-filesystem sibling of
329
+ * `uploadBlob()`. Use this when the bytes were produced in memory (an image
330
+ * the agent generated, a document it assembled) and there is no local file to
331
+ * stream: an MCP client running inside a Claude session has no filesystem to
332
+ * point `uploadBlob()` at.
333
+ *
334
+ * The bytes are sent as base64 in a JSON body to the relay's
335
+ * `POST /v1/attachments` inline variant ({ content_base64, filename?, mime?,
336
+ * scope?, app_id? }). The relay decodes them behind a pre-decode size guard and runs
337
+ * the IDENTICAL validation pipeline the multipart `uploadBlob()` path runs:
338
+ * magic-byte MIME sniff, BLOB_MIME_ALLOWLIST, per-attachment size cap, and the
339
+ * per-agent / per-app / per-account quota reservation. `mime` is advisory
340
+ * (the relay sniffs the real type regardless), and an oversized or disallowed
341
+ * inline upload returns the same errors the file path would.
342
+ *
343
+ * @param contentBase64 standard base64 (e.g. `Buffer.from(bytes).toString("base64")`).
344
+ */
345
+ async uploadBlobInline(contentBase64, opts = {}) {
346
+ const body = { content_base64: contentBase64 };
347
+ if (opts.scope)
348
+ body.scope = opts.scope;
349
+ if (opts.appId)
350
+ body.app_id = opts.appId;
351
+ if (opts.filename)
352
+ body.filename = opts.filename;
353
+ if (opts.mime)
354
+ body.mime = opts.mime;
355
+ const r = await this.call("POST", "/v1/attachments", body);
356
+ if (!r.ok)
357
+ this.fail(r);
358
+ return this.asObject(r);
359
+ }
308
360
  /** GET /v1/attachments/:id — download bytes as an ArrayBuffer. */
309
361
  async downloadBlob(attachmentId) {
310
362
  const url = this.base + "/v1/attachments/" + encodeURIComponent(attachmentId);
@@ -424,13 +476,29 @@ export class HomespunClient {
424
476
  this.fail(r);
425
477
  return r.data;
426
478
  }
427
- /** Finalise a presigned upload — relay HEADs the bytes, verifies, flips ready. */
479
+ /**
480
+ * Finalise a presigned upload. After the client PUTs the bytes to the
481
+ * `upload_url` from `presignBlob()`, the relay re-reads the stored bytes and
482
+ * runs the SAME validation a normal upload runs: it BYTE-SNIFFS the actual
483
+ * content and derives the stored/served mime from that (never the
484
+ * presign-declared mime), re-verifies size + sha256, enforces the allowlist +
485
+ * quota, and runs the scan hook, before flipping the attachment to `ready`.
486
+ * A finalize whose bytes fail any check leaves the attachment unready (it is
487
+ * never served) and returns a clean error. Returns the ready `AttachmentRef`.
488
+ */
428
489
  async confirmBlob(attachmentId) {
429
490
  const r = await this.call("POST", "/v1/attachments/" + encodeURIComponent(attachmentId) + "/confirm");
430
491
  if (!r.ok)
431
492
  this.fail(r);
432
493
  return r.data;
433
494
  }
495
+ /**
496
+ * Alias for {@link confirmBlob}: the "finalize" half of the presign ->
497
+ * PUT -> finalize flow, named to match the MCP `attachments` action.
498
+ */
499
+ async finalizeBlob(attachmentId) {
500
+ return this.confirmBlob(attachmentId);
501
+ }
434
502
  // -------------------------------------------------------------------------
435
503
  // v2 app lifecycle (spec-cli §2.1). `:id` on every route below is always the
436
504
  // App.id (cuid) — a slug is resolved to an id via `listApps({ slug })`
@@ -443,6 +511,7 @@ export class HomespunClient {
443
511
  manifest: req.manifest,
444
512
  visibility: req.visibility,
445
513
  slug: req.slug,
514
+ assets: req.assets,
446
515
  });
447
516
  if (!r.ok)
448
517
  this.fail(r);
@@ -450,7 +519,39 @@ export class HomespunClient {
450
519
  }
451
520
  /** POST /v1/apps/:id/versions — redeploy (compat-gated unless force). */
452
521
  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 });
522
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/versions`, {
523
+ html: req.html,
524
+ manifest: req.manifest,
525
+ force: req.force,
526
+ assets: req.assets,
527
+ });
528
+ if (!r.ok)
529
+ this.fail(r);
530
+ return this.asObject(r);
531
+ }
532
+ /**
533
+ * Dry-run a deploy: validate the manifest + asset shapes, run the compat gate
534
+ * (for a redeploy) and compute the deploy advisories, WITHOUT creating a
535
+ * version or mutating anything. Posts the deploy body with `dry_run: true` to
536
+ * the same route the real deploy uses (`POST /v1/apps` for a create check,
537
+ * `POST /v1/apps/:id/versions` for a redeploy check), so the relay returns the
538
+ * SAME validation error a real deploy would for an invalid manifest, and the
539
+ * SAME warnings/compat result for a valid one. Nothing is persisted.
540
+ */
541
+ async checkDeploy(req) {
542
+ const path = req.app_id !== undefined
543
+ ? `/v1/apps/${encodeURIComponent(req.app_id)}/versions`
544
+ : "/v1/apps";
545
+ const body = {
546
+ html: req.html,
547
+ manifest: req.manifest,
548
+ dry_run: true,
549
+ };
550
+ if (req.app_id !== undefined && req.force !== undefined)
551
+ body["force"] = req.force;
552
+ if (req.assets !== undefined)
553
+ body["assets"] = req.assets;
554
+ const r = await this.call("POST", path, body);
454
555
  if (!r.ok)
455
556
  this.fail(r);
456
557
  return this.asObject(r);
@@ -483,9 +584,37 @@ export class HomespunClient {
483
584
  this.fail(r);
484
585
  return this.asObject(r);
485
586
  }
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 });
587
+ /**
588
+ * PATCH /v1/apps/:id updates the app's mutable settings. Both fields are
589
+ * optional but at least one must be given: `visibility` (private|link|public)
590
+ * and/or `timezone` (an IANA zone name like "Europe/Berlin", used for
591
+ * `schedules` reminders). Returns the updated `{ id, visibility, timezone }`.
592
+ * A transition INTO `link` visibility also returns a `share_url` carrying the
593
+ * app's freshly minted share token in its `#k=` fragment, shown ONCE (the
594
+ * token is hashed at rest and never recoverable). A transition AWAY from
595
+ * `link` clears the token (every prior share URL stops working).
596
+ */
597
+ async updateApp(appId, update) {
598
+ const body = {};
599
+ if (update.visibility !== undefined)
600
+ body["visibility"] = update.visibility;
601
+ if (update.timezone !== undefined)
602
+ body["timezone"] = update.timezone;
603
+ const r = await this.call("PATCH", `/v1/apps/${encodeURIComponent(appId)}`, body);
604
+ if (!r.ok)
605
+ this.fail(r);
606
+ return this.asObject(r);
607
+ }
608
+ /**
609
+ * POST /v1/apps/:id/share-link/rotate rotates a `link` app's share token.
610
+ * Mints a fresh token and returns the new `share_url` (its `#k=` fragment
611
+ * carries the raw token, shown ONCE). Rotating instantly revokes the previous
612
+ * share URL and every pass derived from it. Also serves as "generate" for a
613
+ * `link` app that has no share token yet. Only valid for a `link` app; a
614
+ * public/private app returns a conflict.
615
+ */
616
+ async rotateShareLink(appId) {
617
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/share-link/rotate`, {});
489
618
  if (!r.ok)
490
619
  this.fail(r);
491
620
  return this.asObject(r);
@@ -504,6 +633,42 @@ export class HomespunClient {
504
633
  return this.asObject(r);
505
634
  }
506
635
  // -------------------------------------------------------------------------
636
+ // Custom domains (Cloudflare for SaaS). One primary domain per app. The
637
+ // relay rejects setAppDomain with `custom_domains_not_enabled` (501) when
638
+ // the deployment has no Cloudflare configuration.
639
+ // -------------------------------------------------------------------------
640
+ /**
641
+ * POST /v1/apps/:id/domain - bind a custom domain to the app. Returns the
642
+ * record including `dns_records`: the DNS entries the domain owner must
643
+ * publish (the routing CNAME plus any Cloudflare validation records).
644
+ */
645
+ async setAppDomain(appId, domain) {
646
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/domain`, { domain });
647
+ if (!r.ok)
648
+ this.fail(r);
649
+ return this.asObject(r);
650
+ }
651
+ /**
652
+ * GET /v1/apps/:id/domain - the app's domain record, live-refreshed against
653
+ * Cloudflare when the feature is enabled (status/last_error/last_checked_at
654
+ * update as a side effect).
655
+ */
656
+ async getAppDomain(appId) {
657
+ const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}/domain`);
658
+ if (!r.ok)
659
+ this.fail(r);
660
+ return this.asObject(r);
661
+ }
662
+ /**
663
+ * DELETE /v1/apps/:id/domain - remove the domain binding (Cloudflare
664
+ * hostname best-effort + record + quota release). Idempotent.
665
+ */
666
+ async deleteAppDomain(appId) {
667
+ const r = await this.call("DELETE", `/v1/apps/${encodeURIComponent(appId)}/domain`);
668
+ if (!r.ok)
669
+ this.fail(r);
670
+ }
671
+ // -------------------------------------------------------------------------
507
672
  // v2 app membership (auth spec §6, spec-cli §2.5). Auth is owner-or-agent;
508
673
  // this client always authenticates as the owning agent. Note: unlike most
509
674
  // v1/v2 wire shapes, the member fields below are camelCase on the wire
@@ -522,7 +687,7 @@ export class HomespunClient {
522
687
  * EMAIL_PROVIDER=none (no invite-email path available).
523
688
  */
524
689
  async addAppMember(appId, opts) {
525
- const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/members`, { email: opts.email, role: opts.role });
690
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/members`, { email: opts.email, role: opts.role, custom_role: opts.customRole });
526
691
  if (!r.ok)
527
692
  this.fail(r);
528
693
  return this.asObject(r);
@@ -534,6 +699,39 @@ export class HomespunClient {
534
699
  this.fail(r);
535
700
  return this.asObject(r);
536
701
  }
702
+ /**
703
+ * PATCH /v1/apps/:id/members/:humanId — change an existing member's custom
704
+ * role in place. `customRole: null` clears it back to a plain member. The
705
+ * role must be one the app's manifest declares (a reserved base role or an
706
+ * undeclared name is a 400), and the app owner's own membership cannot be
707
+ * re-roled.
708
+ *
709
+ * Unlike remove-then-re-add, this does NOT revoke the member's live app
710
+ * sessions: they stay signed in and pick up the new role on their next
711
+ * request. Roles are not cached: they are re-derived from the Member row on
712
+ * every request, so a downgrade takes effect immediately and a live session
713
+ * can never escalate.
714
+ */
715
+ async setAppMemberRole(appId, humanId, opts) {
716
+ const r = await this.call("PATCH", `/v1/apps/${encodeURIComponent(appId)}/members/${encodeURIComponent(humanId)}`, { custom_role: opts.customRole });
717
+ if (!r.ok)
718
+ this.fail(r);
719
+ return this.asObject(r);
720
+ }
721
+ /**
722
+ * GET /v1/apps/:id/roles — the derived roles summary: every custom role the
723
+ * app's manifest declares, what holding it actually allows per collection
724
+ * (reported separately for members and for grant-link holders, whose role
725
+ * floors differ), and how many members and live grant links hold it.
726
+ * Read-only and computed on the fly; permissions themselves stay
727
+ * manifest-declared. An app that declares no roles returns an empty list.
728
+ */
729
+ async listAppRoles(appId) {
730
+ const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}/roles`);
731
+ if (!r.ok)
732
+ this.fail(r);
733
+ return this.asObject(r);
734
+ }
537
735
  /**
538
736
  * DELETE /v1/apps/:id/members/:humanId — remove a member (idempotent);
539
737
  * cascades to revoke that human's live app sessions. The app owner cannot
@@ -545,17 +743,106 @@ export class HomespunClient {
545
743
  this.fail(r);
546
744
  }
547
745
  // -------------------------------------------------------------------------
746
+ // Inbound catch-hooks (inbound-webhooks PR 3). Read + rotate the smallest
747
+ // surface an agent needs: list an app's declared hooks with their full secret
748
+ // URL (so the agent can tell its owner the exact URL to paste into an external
749
+ // system during app setup), and rotate a leaked secret. Hooks themselves are
750
+ // manifest-declared, so there is no create/delete here.
751
+ // -------------------------------------------------------------------------
752
+ /**
753
+ * GET /v1/apps/:id/ingest-hooks lists the app's inbound catch-hooks with the
754
+ * full secret URL (decrypted server-side), the current rule metadata
755
+ * (collection/mode/wake/handshake), and per-hook delivery counts by status.
756
+ * The URL is the app owner's to share with a sender; it is never exposed on a
757
+ * public path, only here to the app's own owner/agent.
758
+ */
759
+ async listIngestHooks(appId) {
760
+ const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}/ingest-hooks`);
761
+ if (!r.ok)
762
+ this.fail(r);
763
+ return this.asObject(r);
764
+ }
765
+ /**
766
+ * POST /v1/apps/:id/ingest-hooks/:name/rotate mints a fresh secret for one
767
+ * hook and return its new full URL once. No redeploy needed; the old URL stops
768
+ * working immediately.
769
+ */
770
+ async rotateIngestHook(appId, name) {
771
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/ingest-hooks/${encodeURIComponent(name)}/rotate`);
772
+ if (!r.ok)
773
+ this.fail(r);
774
+ return this.asObject(r);
775
+ }
776
+ // -------------------------------------------------------------------------
777
+ // v2 grant links (M5). An owner/agent mints a capability URL carrying a
778
+ // DECLARED custom role (x-homespun-manifest.roles) plus an optional row/filter
779
+ // pin. The raw link token rides the returned grant_url's FRAGMENT (#g=), never
780
+ // a query param, so it is never logged; the page SDK exchanges it once for a
781
+ // per-holder credential. A grant can NEVER carry a built-in role and never
782
+ // escalates past its custom role on a stable per-holder identity.
783
+ // -------------------------------------------------------------------------
784
+ /**
785
+ * POST /v1/apps/:id/grants: mint a grant link. `role` must be a declared
786
+ * custom role for the app. `mode` is "once" (one-time, first-browser-claims)
787
+ * or "multi" (shared, capped by `maxUses` within expiry); defaults to "multi".
788
+ * An optional `pin` NARROWS the holder to a single `rowKey` OR a `where`
789
+ * filter (never widens). Returns the once-only `grant_url` carrying the token.
790
+ */
791
+ async mintAppGrant(appId, opts) {
792
+ const pin = opts.pin
793
+ ? opts.pin.rowKey !== undefined
794
+ ? { row_key: opts.pin.rowKey }
795
+ : { where: opts.pin.where }
796
+ : undefined;
797
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/grants`, {
798
+ role: opts.role,
799
+ mode: opts.mode,
800
+ max_uses: opts.maxUses,
801
+ label: opts.label,
802
+ ttl_seconds: opts.ttlSeconds,
803
+ pin,
804
+ });
805
+ if (!r.ok)
806
+ this.fail(r);
807
+ return this.asObject(r);
808
+ }
809
+ /** GET /v1/apps/:id/grants: list the app's grant links (never any token). */
810
+ async listAppGrants(appId) {
811
+ const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}/grants`);
812
+ if (!r.ok)
813
+ this.fail(r);
814
+ return this.asObject(r);
815
+ }
816
+ /** DELETE /v1/apps/:id/grants/:grantId: revoke one grant link (idempotent). */
817
+ async revokeAppGrant(appId, grantId) {
818
+ const r = await this.call("DELETE", `/v1/apps/${encodeURIComponent(appId)}/grants/${encodeURIComponent(grantId)}`);
819
+ if (!r.ok)
820
+ this.fail(r);
821
+ }
822
+ // -------------------------------------------------------------------------
548
823
  // v2 collection row CRUD + feed catch-up (spec-cli §2.2/§2.3). Same wire
549
824
  // shapes as the v1 homespun-records methods above, mounted under
550
825
  // /v1/apps/:id/collections/:name instead of /v1/apps/:id/records/:name.
551
826
  // -------------------------------------------------------------------------
552
- /** GET /v1/apps/:id/collections/:name — list rows (current-state page). */
827
+ /**
828
+ * GET /v1/apps/:id/collections/:name — list rows (current-state page).
829
+ *
830
+ * Wave C2 structured read query: pass `where` (an AND of `{field, op, value}`
831
+ * conditions) and/or `sort` (`{field, dir}` list) to filter/order DB-side. The
832
+ * relay applies read permission + author scoping FIRST, then the filter, so a
833
+ * filtered read is always a subset of what the caller could already read. The
834
+ * query is serialized into the `q` param as URL-encoded JSON. Note: a custom
835
+ * `sort` cannot be combined with cursor pagination (`since`) in this version.
836
+ */
553
837
  async listAppRows(appId, collection, opts = {}) {
554
838
  const q = new URLSearchParams();
555
839
  if (opts.since !== undefined)
556
840
  q.set("since", opts.since);
557
841
  if (opts.limit !== undefined)
558
842
  q.set("limit", String(opts.limit));
843
+ const query = buildListQueryParam(opts.where, opts.sort);
844
+ if (query !== undefined)
845
+ q.set("q", query);
559
846
  const qs = q.toString();
560
847
  const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}/collections/${encodeURIComponent(collection)}${qs ? "?" + qs : ""}`);
561
848
  if (!r.ok)
@@ -585,6 +872,26 @@ export class HomespunClient {
585
872
  this.fail(r);
586
873
  return this.asObject(r);
587
874
  }
875
+ /**
876
+ * POST /v1/apps/:id/collections/:name with `{ on, data }`, natural-key upsert
877
+ * (Wave C1). Matches the existing row whose `<field>` value equals data[field]
878
+ * and updates it in place (idempotent re-import), else creates a fresh row.
879
+ * `field` must be declared in the collection's manifest `unique` list.
880
+ */
881
+ async upsertAppRowOn(appId, collection, field, data) {
882
+ return this.upsertAppRow(appId, collection, { on: field, data });
883
+ }
884
+ /**
885
+ * DELETE /v1/apps/:id/collections/:name/:key/purge, owner/agent-only removal
886
+ * that bypasses an append-only collection (Wave C1). Frees the row's unique
887
+ * values and writes an audited delete feed entry. A missing/already-deleted key
888
+ * is a 404 (HomespunApiError `row_not_found`), matching a normal delete.
889
+ */
890
+ async purgeAppRow(appId, collection, key) {
891
+ const r = await this.call("DELETE", `/v1/apps/${encodeURIComponent(appId)}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(key)}/purge`);
892
+ if (!r.ok)
893
+ this.fail(r);
894
+ }
588
895
  /** PATCH /v1/apps/:id/collections/:name/:key — optimistic-locked update. */
589
896
  async updateAppRow(appId, collection, key, body) {
590
897
  const r = await this.call("PATCH", `/v1/apps/${encodeURIComponent(appId)}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(key)}`, body);
@@ -599,6 +906,37 @@ export class HomespunClient {
599
906
  if (!r.ok)
600
907
  this.fail(r);
601
908
  }
909
+ /**
910
+ * POST /v1/apps/:id/collections/:name/batch: bulk create/upsert (Wave B).
911
+ * Returns a per-row result array: a single invalid row is reported by index
912
+ * without aborting the good rows. A batch over the server's BATCH_MAX_ROWS cap
913
+ * is a clean 400 (throws HomespunApiError `invalid_request`). DEFAULTS TO
914
+ * SILENT (suppresses notify + webhooks): pass `{emitEffects:true}` or
915
+ * `{suppress:[]}` to fire effects. Owner/agent key only for suppression control.
916
+ */
917
+ async batchRows(appId, collection, rows, opts = {}) {
918
+ const body = { rows };
919
+ if (opts.suppress !== undefined)
920
+ body.suppress = opts.suppress;
921
+ if (opts.emitEffects !== undefined)
922
+ body.emitEffects = opts.emitEffects;
923
+ if (opts.on !== undefined)
924
+ body.on = opts.on;
925
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/collections/${encodeURIComponent(collection)}/batch`, body);
926
+ if (!r.ok)
927
+ this.fail(r);
928
+ return this.asObject(r);
929
+ }
930
+ /**
931
+ * DELETE /v1/apps/:id/collections/:name/batch: bulk soft-delete (Wave B).
932
+ * Same per-row result shape + cap as `batchRows`.
933
+ */
934
+ async deleteRows(appId, collection, keys) {
935
+ const r = await this.call("DELETE", `/v1/apps/${encodeURIComponent(appId)}/collections/${encodeURIComponent(collection)}/batch`, { keys });
936
+ if (!r.ok)
937
+ this.fail(r);
938
+ return this.asObject(r);
939
+ }
602
940
  /**
603
941
  * GET /v1/apps/:id/feed — change-feed catch-up; the long-poll fallback
604
942
  * transport for `watch` (spec-cli §2.3/§5). `wait` (0-30s) long-polls when
@@ -616,4 +954,164 @@ export class HomespunClient {
616
954
  this.fail(r);
617
955
  return this.asObject(r);
618
956
  }
957
+ // -------------------------------------------------------------------------
958
+ // Community publish + review (M4b). publishCommunityTemplate is an OWNER
959
+ // action (the calling agent's owning human); the review methods are
960
+ // operator-gated server-side.
961
+ // -------------------------------------------------------------------------
962
+ /** POST /v1/community/publish: publish an owned app as a pending template. */
963
+ async publishCommunityTemplate(req) {
964
+ const r = await this.call("POST", "/v1/community/publish", {
965
+ app_id: req.appId,
966
+ title: req.title,
967
+ description: req.description,
968
+ long_description: req.longDescription,
969
+ category: req.category,
970
+ tags: req.tags,
971
+ slug: req.slug,
972
+ version: req.version,
973
+ changelog_note: req.changelogNote,
974
+ setup_steps: req.setupSteps,
975
+ derived_from_snapshot_id: req.derivedFromSnapshotId,
976
+ attest_example_only: req.attestExampleOnly,
977
+ });
978
+ if (!r.ok)
979
+ this.fail(r);
980
+ return this.asObject(r);
981
+ }
982
+ /** GET /v1/community/pending (operator): list pending submissions. */
983
+ async listCommunitySubmissions(opts = {}) {
984
+ const q = new URLSearchParams();
985
+ if (opts.limit !== undefined)
986
+ q.set("limit", String(opts.limit));
987
+ if (opts.cursor !== undefined && opts.cursor !== "")
988
+ q.set("cursor", opts.cursor);
989
+ const qs = q.toString();
990
+ const r = await this.call("GET", `/v1/community/pending${qs ? "?" + qs : ""}`);
991
+ if (!r.ok)
992
+ this.fail(r);
993
+ return this.asObject(r);
994
+ }
995
+ /** GET /v1/community/submissions/:id (operator): a submission's full content. */
996
+ async getCommunitySubmission(snapshotId) {
997
+ const r = await this.call("GET", `/v1/community/submissions/${encodeURIComponent(snapshotId)}`);
998
+ if (!r.ok)
999
+ this.fail(r);
1000
+ return this.asObject(r);
1001
+ }
1002
+ /**
1003
+ * POST /v1/community/submissions/:id/{approve,reject} (operator): review a
1004
+ * submission. `decision` "reject" requires a `note` (delivered to the
1005
+ * publisher's app feed).
1006
+ */
1007
+ async reviewCommunitySubmission(snapshotId, review) {
1008
+ const path = `/v1/community/submissions/${encodeURIComponent(snapshotId)}/${review.decision}`;
1009
+ const r = await this.call("POST", path, review.decision === "reject" ? { note: review.note } : undefined);
1010
+ if (!r.ok)
1011
+ this.fail(r);
1012
+ return this.asObject(r);
1013
+ }
1014
+ // -------------------------------------------------------------------------
1015
+ // Community publisher identity (marketplace PR 1). All three act AS the
1016
+ // calling agent's owning human. claim/update additionally need a verified
1017
+ // email server-side.
1018
+ // -------------------------------------------------------------------------
1019
+ /** GET /v1/publisher: the caller's own publisher profile. */
1020
+ async getPublisher() {
1021
+ const r = await this.call("GET", "/v1/publisher");
1022
+ if (!r.ok)
1023
+ this.fail(r);
1024
+ return this.asObject(r);
1025
+ }
1026
+ /** POST /v1/publisher/claim: set the handle once (permanent after claiming). */
1027
+ async claimPublisherHandle(handle) {
1028
+ const r = await this.call("POST", "/v1/publisher/claim", { handle });
1029
+ if (!r.ok)
1030
+ this.fail(r);
1031
+ return this.asObject(r);
1032
+ }
1033
+ /** POST /v1/publisher/update: update displayName/bio/url. */
1034
+ async updatePublisher(update) {
1035
+ const r = await this.call("POST", "/v1/publisher/update", {
1036
+ display_name: update.displayName,
1037
+ bio: update.bio,
1038
+ url: update.url,
1039
+ });
1040
+ if (!r.ok)
1041
+ this.fail(r);
1042
+ return this.asObject(r);
1043
+ }
1044
+ /**
1045
+ * POST /v1/community/publishers/:handle/trust (operator, marketplace PR 11):
1046
+ * set a publisher's trust level ("new" | "established"), the MVP promotion
1047
+ * path for the review fast-track. Operator-gated server-side; returns the
1048
+ * updated publisher profile.
1049
+ */
1050
+ async setPublisherTrustLevel(handle, trustLevel) {
1051
+ const r = await this.call("POST", `/v1/community/publishers/${encodeURIComponent(handle)}/trust`, { trust_level: trustLevel });
1052
+ if (!r.ok)
1053
+ this.fail(r);
1054
+ return this.asObject(r);
1055
+ }
1056
+ // -------------------------------------------------------------------------
1057
+ // Community reviews (marketplace PR 7). createReview / respondToReview /
1058
+ // reportReview act AS the calling agent's owning human; the moderation
1059
+ // methods (removeReview / unholdReview) are operator-gated server-side.
1060
+ // -------------------------------------------------------------------------
1061
+ /**
1062
+ * POST /v1/reviews: review a template the caller installed. Identify the
1063
+ * template by its namespaced `<handle>/<slug>` OR by explicit handle + slug.
1064
+ */
1065
+ async createReview(req) {
1066
+ const r = await this.call("POST", "/v1/reviews", {
1067
+ template: req.template,
1068
+ handle: req.handle,
1069
+ slug: req.slug,
1070
+ stars: req.stars,
1071
+ body: req.body,
1072
+ });
1073
+ if (!r.ok)
1074
+ this.fail(r);
1075
+ return this.asObject(r);
1076
+ }
1077
+ /** POST /v1/reviews/:id/respond: respond to a review as the publisher. */
1078
+ async respondToReview(reviewId, response) {
1079
+ const r = await this.call("POST", `/v1/reviews/${encodeURIComponent(reviewId)}/respond`, { response });
1080
+ if (!r.ok)
1081
+ this.fail(r);
1082
+ return this.asObject(r);
1083
+ }
1084
+ /** POST /v1/reviews/:id/report: flag a review for operator attention. */
1085
+ async reportReview(reviewId, reason) {
1086
+ const r = await this.call("POST", `/v1/reviews/${encodeURIComponent(reviewId)}/report`, { reason });
1087
+ if (!r.ok)
1088
+ this.fail(r);
1089
+ return this.asObject(r);
1090
+ }
1091
+ /** POST /v1/reviews/:id/remove (operator): take a review down. */
1092
+ async removeReview(reviewId) {
1093
+ const r = await this.call("POST", `/v1/reviews/${encodeURIComponent(reviewId)}/remove`);
1094
+ if (!r.ok)
1095
+ this.fail(r);
1096
+ return this.asObject(r);
1097
+ }
1098
+ /** POST /v1/reviews/:id/unhold (operator): publish a held review. */
1099
+ async unholdReview(reviewId) {
1100
+ const r = await this.call("POST", `/v1/reviews/${encodeURIComponent(reviewId)}/unhold`);
1101
+ if (!r.ok)
1102
+ this.fail(r);
1103
+ return this.asObject(r);
1104
+ }
1105
+ }
1106
+ // Serialize `where`/`sort` into the `q` query param (URL-encoded JSON), or
1107
+ // undefined when neither is given. URLSearchParams handles the percent-encoding.
1108
+ function buildListQueryParam(where, sort) {
1109
+ const q = {};
1110
+ if (where !== undefined && where.length > 0)
1111
+ q.where = where;
1112
+ if (sort !== undefined && sort.length > 0)
1113
+ q.sort = sort;
1114
+ if (q.where === undefined && q.sort === undefined)
1115
+ return undefined;
1116
+ return JSON.stringify(q);
619
1117
  }
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, DeployCheckRequest, DeployCheckResult, KeyMintResult, AppSummary, AppDetail, AppsPage, AppDomain, AppDomainDnsRecord, AppMember, AddAppMemberResult, AppRow, AppRowsPage, ListScalar, ListWhereCondition, ListSortSpec, BatchRowInput, BatchRowResult, BatchResult, BatchWriteOptions, AppFeedEntry, AppFeedPage, CommunitySetupStep, } 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.4.2",
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://docs.homespun.dev",
20
15
  "bugs": {
21
- "url": "https://github.com/aerolalit/homespun/issues"
16
+ "url": "https://github.com/homespunapps/homespun/issues"
22
17
  },
23
18
  "engines": {
24
19
  "node": ">=20"
@@ -46,13 +41,18 @@
46
41
  "test:unit": "vitest run"
47
42
  },
48
43
  "dependencies": {
49
- "ws": "^8.20.1",
44
+ "ws": "^8.21.1",
50
45
  "zod": "^4.4.3"
51
46
  },
52
47
  "devDependencies": {
53
- "@types/node": "^25.9.2",
48
+ "@types/node": "^26.1.1",
54
49
  "@types/ws": "^8.18.1",
55
- "typescript": "^6.0.3",
50
+ "typescript": "^7.0.2",
56
51
  "vitest": "^4.1.8"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/homespunapps/homespun.git",
56
+ "directory": "packages/core"
57
57
  }
58
58
  }