@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.d.ts +776 -4
- package/dist/client.js +505 -7
- package/dist/index.d.ts +1 -1
- package/package.json +11 -11
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
|
-
/**
|
|
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`, {
|
|
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
|
-
/**
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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://
|
|
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/
|
|
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.
|
|
44
|
+
"ws": "^8.21.1",
|
|
50
45
|
"zod": "^4.4.3"
|
|
51
46
|
},
|
|
52
47
|
"devDependencies": {
|
|
53
|
-
"@types/node": "^
|
|
48
|
+
"@types/node": "^26.1.1",
|
|
54
49
|
"@types/ws": "^8.18.1",
|
|
55
|
-
"typescript": "^
|
|
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
|
}
|