@homespunapps/core 1.0.1 → 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 CHANGED
@@ -90,6 +90,20 @@ export declare class HomespunClient {
90
90
  * self-destruct. Returns 204 with no body on success.
91
91
  */
92
92
  revokeKey(id: string): Promise<void>;
93
+ /**
94
+ * POST /v1/keys: mint a NEW sibling API key for the calling agent's OWN
95
+ * identity. The relay derives the identity from the bearer token, so a caller
96
+ * can only ever mint a sibling of itself (never another agent's key). The new
97
+ * key has the same scope/ownership as the caller and shows up in a subsequent
98
+ * `listKeys()` call made WITH the new key.
99
+ *
100
+ * The raw `api_key` is returned exactly ONCE in this response and is never
101
+ * retrievable again (only its hash is stored). Bootstraps a fresh CLI /
102
+ * process credential from an MCP-driven agent that has no key of its own to
103
+ * hand off. The owner can revoke any minted sibling via the normal revoke
104
+ * path.
105
+ */
106
+ mintKey(): Promise<KeyMintResult>;
93
107
  /**
94
108
  * POST /v1/agents/claim — bind this agent to a human via a one-shot
95
109
  * claim code the human generated in their settings UI. After a
@@ -258,6 +272,16 @@ export declare class HomespunClient {
258
272
  deployApp(req: DeployAppRequest): Promise<DeployAppResponse>;
259
273
  /** POST /v1/apps/:id/versions — redeploy (compat-gated unless force). */
260
274
  redeployApp(appId: string, req: RedeployAppRequest): Promise<RedeployAppResponse>;
275
+ /**
276
+ * Dry-run a deploy: validate the manifest + asset shapes, run the compat gate
277
+ * (for a redeploy) and compute the deploy advisories, WITHOUT creating a
278
+ * version or mutating anything. Posts the deploy body with `dry_run: true` to
279
+ * the same route the real deploy uses (`POST /v1/apps` for a create check,
280
+ * `POST /v1/apps/:id/versions` for a redeploy check), so the relay returns the
281
+ * SAME validation error a real deploy would for an invalid manifest, and the
282
+ * SAME warnings/compat result for a valid one. Nothing is persisted.
283
+ */
284
+ checkDeploy(req: DeployCheckRequest): Promise<DeployCheckResult>;
261
285
  /**
262
286
  * GET /v1/apps — list apps scoped to the calling agent's owning human.
263
287
  * `slug` is an exact-match filter — the one case a caller resolves a
@@ -271,10 +295,35 @@ export declare class HomespunClient {
271
295
  }): Promise<AppsPage>;
272
296
  /** GET /v1/apps/:id — full app detail (manifest, current_version, quota). */
273
297
  getApp(appId: string): Promise<AppDetail>;
274
- /** PATCH /v1/apps/:id — update visibility (the only mutable app field). */
275
- updateApp(appId: string, visibility: "private" | "link" | "public"): Promise<{
298
+ /**
299
+ * PATCH /v1/apps/:id updates the app's mutable settings. Both fields are
300
+ * optional but at least one must be given: `visibility` (private|link|public)
301
+ * and/or `timezone` (an IANA zone name like "Europe/Berlin", used for
302
+ * `schedules` reminders). Returns the updated `{ id, visibility, timezone }`.
303
+ * A transition INTO `link` visibility also returns a `share_url` carrying the
304
+ * app's freshly minted share token in its `#k=` fragment, shown ONCE (the
305
+ * token is hashed at rest and never recoverable). A transition AWAY from
306
+ * `link` clears the token (every prior share URL stops working).
307
+ */
308
+ updateApp(appId: string, update: {
309
+ visibility?: "private" | "link" | "public";
310
+ timezone?: string;
311
+ }): Promise<{
276
312
  id: string;
277
313
  visibility: string;
314
+ timezone: string | null;
315
+ share_url?: string;
316
+ }>;
317
+ /**
318
+ * POST /v1/apps/:id/share-link/rotate rotates a `link` app's share token.
319
+ * Mints a fresh token and returns the new `share_url` (its `#k=` fragment
320
+ * carries the raw token, shown ONCE). Rotating instantly revokes the previous
321
+ * share URL and every pass derived from it. Also serves as "generate" for a
322
+ * `link` app that has no share token yet. Only valid for a `link` app; a
323
+ * public/private app returns a conflict.
324
+ */
325
+ rotateShareLink(appId: string): Promise<{
326
+ share_url: string;
278
327
  }>;
279
328
  /** DELETE /v1/apps/:id — soft-delete (idempotent). */
280
329
  deleteApp(appId: string): Promise<void>;
@@ -310,21 +359,107 @@ export declare class HomespunClient {
310
359
  addAppMember(appId: string, opts: {
311
360
  email: string;
312
361
  role?: "member";
362
+ customRole?: string;
313
363
  }): Promise<AddAppMemberResult>;
314
364
  /** GET /v1/apps/:id/members — list the app's owner + member rows. */
315
365
  listAppMembers(appId: string): Promise<{
316
366
  members: AppMember[];
317
367
  }>;
368
+ /**
369
+ * PATCH /v1/apps/:id/members/:humanId — change an existing member's custom
370
+ * role in place. `customRole: null` clears it back to a plain member. The
371
+ * role must be one the app's manifest declares (a reserved base role or an
372
+ * undeclared name is a 400), and the app owner's own membership cannot be
373
+ * re-roled.
374
+ *
375
+ * Unlike remove-then-re-add, this does NOT revoke the member's live app
376
+ * sessions: they stay signed in and pick up the new role on their next
377
+ * request. Roles are not cached: they are re-derived from the Member row on
378
+ * every request, so a downgrade takes effect immediately and a live session
379
+ * can never escalate.
380
+ */
381
+ setAppMemberRole(appId: string, humanId: string, opts: {
382
+ customRole: string | null;
383
+ }): Promise<{
384
+ member: AppMember;
385
+ }>;
386
+ /**
387
+ * GET /v1/apps/:id/roles — the derived roles summary: every custom role the
388
+ * app's manifest declares, what holding it actually allows per collection
389
+ * (reported separately for members and for grant-link holders, whose role
390
+ * floors differ), and how many members and live grant links hold it.
391
+ * Read-only and computed on the fly; permissions themselves stay
392
+ * manifest-declared. An app that declares no roles returns an empty list.
393
+ */
394
+ listAppRoles(appId: string): Promise<{
395
+ roles: AppRoleSummary[];
396
+ }>;
318
397
  /**
319
398
  * DELETE /v1/apps/:id/members/:humanId — remove a member (idempotent);
320
399
  * cascades to revoke that human's live app sessions. The app owner cannot
321
400
  * be removed — the relay refuses with a 409 conflict.
322
401
  */
323
402
  removeAppMember(appId: string, humanId: string): Promise<void>;
324
- /** GET /v1/apps/:id/collections/:name — list rows (current-state page). */
403
+ /**
404
+ * GET /v1/apps/:id/ingest-hooks lists the app's inbound catch-hooks with the
405
+ * full secret URL (decrypted server-side), the current rule metadata
406
+ * (collection/mode/wake/handshake), and per-hook delivery counts by status.
407
+ * The URL is the app owner's to share with a sender; it is never exposed on a
408
+ * public path, only here to the app's own owner/agent.
409
+ */
410
+ listIngestHooks(appId: string): Promise<{
411
+ hooks: IngestHookInfo[];
412
+ }>;
413
+ /**
414
+ * POST /v1/apps/:id/ingest-hooks/:name/rotate mints a fresh secret for one
415
+ * hook and return its new full URL once. No redeploy needed; the old URL stops
416
+ * working immediately.
417
+ */
418
+ rotateIngestHook(appId: string, name: string): Promise<{
419
+ hook: {
420
+ name: string;
421
+ url: string;
422
+ };
423
+ }>;
424
+ /**
425
+ * POST /v1/apps/:id/grants: mint a grant link. `role` must be a declared
426
+ * custom role for the app. `mode` is "once" (one-time, first-browser-claims)
427
+ * or "multi" (shared, capped by `maxUses` within expiry); defaults to "multi".
428
+ * An optional `pin` NARROWS the holder to a single `rowKey` OR a `where`
429
+ * filter (never widens). Returns the once-only `grant_url` carrying the token.
430
+ */
431
+ mintAppGrant(appId: string, opts: {
432
+ role: string;
433
+ mode?: "once" | "multi";
434
+ maxUses?: number;
435
+ label?: string;
436
+ ttlSeconds?: number;
437
+ pin?: {
438
+ rowKey?: string;
439
+ where?: ListWhereCondition[];
440
+ };
441
+ }): Promise<MintAppGrantResult>;
442
+ /** GET /v1/apps/:id/grants: list the app's grant links (never any token). */
443
+ listAppGrants(appId: string): Promise<{
444
+ grants: AppGrantSummary[];
445
+ }>;
446
+ /** DELETE /v1/apps/:id/grants/:grantId: revoke one grant link (idempotent). */
447
+ revokeAppGrant(appId: string, grantId: string): Promise<void>;
448
+ /**
449
+ * GET /v1/apps/:id/collections/:name — list rows (current-state page).
450
+ *
451
+ * Wave C2 structured read query: pass `where` (an AND of `{field, op, value}`
452
+ * conditions) and/or `sort` (`{field, dir}` list) to filter/order DB-side. The
453
+ * relay applies read permission + author scoping FIRST, then the filter, so a
454
+ * filtered read is always a subset of what the caller could already read. The
455
+ * query is serialized into the `q` param as URL-encoded JSON. Note: a custom
456
+ * `sort` cannot be combined with cursor pagination (`since`) in this version.
457
+ */
325
458
  listAppRows(appId: string, collection: string, opts?: {
326
459
  since?: string;
327
460
  limit?: number;
461
+ where?: ListWhereCondition[];
462
+ sort?: ListSortSpec[];
328
463
  }): Promise<AppRowsPage>;
329
464
  /**
330
465
  * GET /v1/apps/:id/collections/:name/:key — read one row. A dedicated
@@ -343,10 +478,28 @@ export declare class HomespunClient {
343
478
  upsertAppRow(appId: string, collection: string, body: {
344
479
  key?: string;
345
480
  data: unknown;
481
+ on?: string;
346
482
  }): Promise<{
347
483
  row: AppRow;
348
484
  deduped?: true;
349
485
  }>;
486
+ /**
487
+ * POST /v1/apps/:id/collections/:name with `{ on, data }`, natural-key upsert
488
+ * (Wave C1). Matches the existing row whose `<field>` value equals data[field]
489
+ * and updates it in place (idempotent re-import), else creates a fresh row.
490
+ * `field` must be declared in the collection's manifest `unique` list.
491
+ */
492
+ upsertAppRowOn(appId: string, collection: string, field: string, data: unknown): Promise<{
493
+ row: AppRow;
494
+ deduped?: true;
495
+ }>;
496
+ /**
497
+ * DELETE /v1/apps/:id/collections/:name/:key/purge, owner/agent-only removal
498
+ * that bypasses an append-only collection (Wave C1). Frees the row's unique
499
+ * values and writes an audited delete feed entry. A missing/already-deleted key
500
+ * is a 404 (HomespunApiError `row_not_found`), matching a normal delete.
501
+ */
502
+ purgeAppRow(appId: string, collection: string, key: string): Promise<void>;
350
503
  /** PATCH /v1/apps/:id/collections/:name/:key — optimistic-locked update. */
351
504
  updateAppRow(appId: string, collection: string, key: string, body: {
352
505
  data: unknown;
@@ -358,6 +511,20 @@ export declare class HomespunClient {
358
511
  deleteAppRow(appId: string, collection: string, key: string, opts?: {
359
512
  ifMatch?: number;
360
513
  }): Promise<void>;
514
+ /**
515
+ * POST /v1/apps/:id/collections/:name/batch: bulk create/upsert (Wave B).
516
+ * Returns a per-row result array: a single invalid row is reported by index
517
+ * without aborting the good rows. A batch over the server's BATCH_MAX_ROWS cap
518
+ * is a clean 400 (throws HomespunApiError `invalid_request`). DEFAULTS TO
519
+ * SILENT (suppresses notify + webhooks): pass `{emitEffects:true}` or
520
+ * `{suppress:[]}` to fire effects. Owner/agent key only for suppression control.
521
+ */
522
+ batchRows(appId: string, collection: string, rows: BatchRowInput[], opts?: BatchWriteOptions): Promise<BatchResult>;
523
+ /**
524
+ * DELETE /v1/apps/:id/collections/:name/batch: bulk soft-delete (Wave B).
525
+ * Same per-row result shape + cap as `batchRows`.
526
+ */
527
+ deleteRows(appId: string, collection: string, keys: string[]): Promise<BatchResult>;
361
528
  /**
362
529
  * GET /v1/apps/:id/feed — change-feed catch-up; the long-poll fallback
363
530
  * transport for `watch` (spec-cli §2.3/§5). `wait` (0-30s) long-polls when
@@ -386,6 +553,32 @@ export declare class HomespunClient {
386
553
  decision: "approve" | "reject";
387
554
  note?: string;
388
555
  }): Promise<CommunitySubmissionDetail>;
556
+ /** GET /v1/publisher: the caller's own publisher profile. */
557
+ getPublisher(): Promise<PublisherProfile>;
558
+ /** POST /v1/publisher/claim: set the handle once (permanent after claiming). */
559
+ claimPublisherHandle(handle: string): Promise<PublisherProfile>;
560
+ /** POST /v1/publisher/update: update displayName/bio/url. */
561
+ updatePublisher(update: PublisherProfileUpdate): Promise<PublisherProfile>;
562
+ /**
563
+ * POST /v1/community/publishers/:handle/trust (operator, marketplace PR 11):
564
+ * set a publisher's trust level ("new" | "established"), the MVP promotion
565
+ * path for the review fast-track. Operator-gated server-side; returns the
566
+ * updated publisher profile.
567
+ */
568
+ setPublisherTrustLevel(handle: string, trustLevel: "new" | "established"): Promise<PublisherProfile>;
569
+ /**
570
+ * POST /v1/reviews: review a template the caller installed. Identify the
571
+ * template by its namespaced `<handle>/<slug>` OR by explicit handle + slug.
572
+ */
573
+ createReview(req: CreateCommunityReviewRequest): Promise<CommunityReview>;
574
+ /** POST /v1/reviews/:id/respond: respond to a review as the publisher. */
575
+ respondToReview(reviewId: string, response: string | null): Promise<CommunityReview>;
576
+ /** POST /v1/reviews/:id/report: flag a review for operator attention. */
577
+ reportReview(reviewId: string, reason: string): Promise<CommunityReviewReportResult>;
578
+ /** POST /v1/reviews/:id/remove (operator): take a review down. */
579
+ removeReview(reviewId: string): Promise<CommunityReview>;
580
+ /** POST /v1/reviews/:id/unhold (operator): publish a held review. */
581
+ unholdReview(reviewId: string): Promise<CommunityReview>;
389
582
  }
390
583
  /**
391
584
  * One multi-file-deploy asset: bytes the deployed page references by a stable,
@@ -421,6 +614,20 @@ export interface DeployAppResponse {
421
614
  url: string;
422
615
  version: number;
423
616
  created: true;
617
+ /**
618
+ * The tokenized share URL, present ONLY when the app was created as `link`. Its
619
+ * `#k=` fragment carries the raw share token and is shown exactly ONCE (the
620
+ * token is hashed at rest and never recoverable). Anyone with this exact URL
621
+ * can open the app until it is rotated (`rotateShareLink`).
622
+ */
623
+ share_url?: string;
624
+ /**
625
+ * Non-fatal deploy advisories, present only when there is at least one. Today
626
+ * the schedules-without-timezone case: an app that declares `schedules` but has
627
+ * no timezone set will fire reminders at 08:00 UTC until one is set. Relay these
628
+ * to the human.
629
+ */
630
+ warnings?: string[];
424
631
  }
425
632
  /** Request body for `POST /v1/apps/:id/versions` — redeploy (compat-gated). */
426
633
  export interface RedeployAppRequest {
@@ -442,6 +649,66 @@ export interface RedeployAppResponse {
442
649
  path: string;
443
650
  message: string;
444
651
  }>;
652
+ /**
653
+ * Non-fatal deploy advisories, present only when there is at least one; see
654
+ * DeployAppResponse.warnings. On redeploy the schedules-without-timezone
655
+ * warning fires only when the app STILL has no timezone set.
656
+ */
657
+ warnings?: string[];
658
+ }
659
+ /**
660
+ * Result of a DRY-RUN deploy (`checkDeploy`): the deploy validation + compat
661
+ * gate run WITHOUT creating a version or mutating anything. Mirrors what a real
662
+ * deploy would report: the manifest is validated (a structurally invalid
663
+ * manifest throws the SAME error a real deploy would), asset shapes are checked,
664
+ * the schedule-timezone / migration-mode advisories are computed, and (for a
665
+ * redeploy) the compat gate runs against the current version.
666
+ *
667
+ * - `ok` is true when a REAL deploy with the same inputs would succeed: a
668
+ * clean deploy, or a narrowing redeploy with `force: true`. It is false
669
+ * when a real un-forced redeploy would be REJECTED for narrowing the
670
+ * manifest (see `compat: "incompatible"` + `breaks`).
671
+ * - `compat` is present only for a redeploy check: "clean" (no narrowing),
672
+ * "forced" (narrows, but force was set so the real deploy would detach and
673
+ * proceed), or "incompatible" (narrows and force was NOT set, so the real
674
+ * deploy would return manifest_incompatible_redeploy).
675
+ * - `breaks` lists the narrowings, present only when there is at least one.
676
+ */
677
+ export interface DeployCheckResult {
678
+ ok: boolean;
679
+ warnings: string[];
680
+ compat?: "clean" | "forced" | "incompatible";
681
+ breaks?: Array<{
682
+ path: string;
683
+ message: string;
684
+ }>;
685
+ }
686
+ /**
687
+ * Request for a dry-run deploy check. Omit `app_id` to check a CREATE; pass it
688
+ * to check a REDEPLOY of an existing app (the compat gate then runs against its
689
+ * current version, and `force` decides whether a narrowing would be accepted).
690
+ */
691
+ export interface DeployCheckRequest {
692
+ app_id?: string;
693
+ html: string;
694
+ manifest: unknown;
695
+ force?: boolean;
696
+ assets?: AppAsset[];
697
+ }
698
+ /**
699
+ * Result of minting a sibling API key for the calling agent's own identity
700
+ * (`mintKey`). The raw `api_key` is returned exactly ONCE here and is never
701
+ * retrievable again (only its hash is stored). The new key has the same
702
+ * scope/ownership as the caller and can be revoked by the owner via the normal
703
+ * revoke path.
704
+ */
705
+ export interface KeyMintResult {
706
+ agent_id: string;
707
+ /** The raw sibling key, shown ONCE, never returned again. */
708
+ api_key: string;
709
+ key_prefix: string;
710
+ name: string | null;
711
+ created_at: string;
445
712
  }
446
713
  /** Lean per-app summary — the shape `listApps` returns. */
447
714
  export interface AppSummary {
@@ -450,6 +717,12 @@ export interface AppSummary {
450
717
  visibility: string;
451
718
  status: string;
452
719
  url: string;
720
+ /**
721
+ * Whether the app currently has an active share token (a `link` app with a
722
+ * live, revocable share link). NEVER the token itself: the raw token is
723
+ * returned only at create/rotate. False for public/private apps.
724
+ */
725
+ has_share_token: boolean;
453
726
  created_at: string;
454
727
  last_activity_at: string;
455
728
  }
@@ -460,6 +733,12 @@ export interface AppDetail extends AppSummary {
460
733
  owner_human_id: string;
461
734
  row_count: number;
462
735
  storage_bytes: string;
736
+ /**
737
+ * The app's IANA timezone for `schedules` reminders, or null when unset. Set
738
+ * it with `updateApp(id, { timezone })`; an app that declares schedules with no
739
+ * timezone fires reminders at 08:00 UTC.
740
+ */
741
+ timezone: string | null;
463
742
  }
464
743
  export interface AppsPage {
465
744
  items: AppSummary[];
@@ -492,7 +771,70 @@ export interface AppMember {
492
771
  humanId: string;
493
772
  email: string;
494
773
  role: string;
774
+ /** M5: an optional DECLARED custom role attached alongside base member powers. */
775
+ customRole?: string | null;
776
+ createdAt: string;
777
+ }
778
+ /**
779
+ * One inbound catch-hook row from `listIngestHooks`. `url` is the full secret
780
+ * ingest URL to hand a sender. Rule-derived fields (collection/mode/wake/
781
+ * handshake) are null when the hook's rule has left the manifest (disabledAt
782
+ * set). `deliveries` are per-status counts of the hook's inbound journal.
783
+ */
784
+ export interface IngestHookInfo {
785
+ name: string;
786
+ url: string;
787
+ collection: string | null;
788
+ mode: string | null;
789
+ wake: boolean | null;
790
+ handshake: string | null;
791
+ disabledAt: string | null;
495
792
  createdAt: string;
793
+ deliveries: {
794
+ accepted: number;
795
+ failed: number;
796
+ dropped_duplicate: number;
797
+ };
798
+ }
799
+ /**
800
+ * Effective per-verb access from `listAppRoles`: "all" = every row, "own" =
801
+ * only rows the holder authored (the `:own` / author narrowing), "none" =
802
+ * nothing. `create` is never "own" (no pre-existing row to be the author of).
803
+ * The relay computes these by probing its real enforcement functions, so they
804
+ * report what a holder can ACTUALLY do, including floors like `anyone`.
805
+ */
806
+ export interface AppRoleVerbAccess {
807
+ read: "all" | "own" | "none";
808
+ create: "all" | "none";
809
+ update: "all" | "own" | "none";
810
+ delete: "all" | "own" | "none";
811
+ }
812
+ /**
813
+ * One collection's access from `listAppRoles`, reported PER POPULATION:
814
+ * `member_access` is what a signed-in member holding the role can do (their
815
+ * principal also carries the member floor), `grant_access` what a grant-link
816
+ * holder of the same role can do (no member floor, so the two can differ).
817
+ * `append_only` is context: such a collection refuses update/delete for every
818
+ * role while still allowing create, and the access tables reflect that.
819
+ */
820
+ export interface AppRoleCollectionAccess {
821
+ name: string;
822
+ member_access: AppRoleVerbAccess;
823
+ grant_access: AppRoleVerbAccess;
824
+ append_only: boolean;
825
+ }
826
+ /**
827
+ * One declared custom role from `listAppRoles`. `member_count` and
828
+ * `active_grant_count` are separate numbers, never summed: members are
829
+ * signed-in Humans, grant-link holders are anonymous per-holder identities.
830
+ */
831
+ export interface AppRoleSummary {
832
+ name: string;
833
+ label: string;
834
+ description: string | null;
835
+ collections: AppRoleCollectionAccess[];
836
+ member_count: number;
837
+ active_grant_count: number;
496
838
  }
497
839
  /**
498
840
  * `POST /v1/apps/:id/members` response — either the member was attached
@@ -505,6 +847,32 @@ export type AddAppMemberResult = {
505
847
  invited: string;
506
848
  expires_at: string;
507
849
  };
850
+ /** `POST /v1/apps/:id/grants` response. `grant_url` carries the raw link token
851
+ * in its #g= fragment and is shown ONCE (it is never recoverable afterward). */
852
+ export interface MintAppGrantResult {
853
+ id: string;
854
+ grant_url: string;
855
+ role: string;
856
+ mode: string;
857
+ max_uses: number | null;
858
+ expires_at: string;
859
+ }
860
+ /** One grant-link row from `listAppGrants` (never carries any token material). */
861
+ export interface AppGrantSummary {
862
+ id: string;
863
+ role: string;
864
+ mode: string;
865
+ max_uses: number | null;
866
+ use_count: number;
867
+ claim_count: number;
868
+ pin_row_key: string | null;
869
+ pin_where: unknown;
870
+ label: string | null;
871
+ active: boolean;
872
+ expires_at: string;
873
+ revoked_at: string | null;
874
+ created_at: string;
875
+ }
508
876
  /** One row in an app collection — the shape every row CRUD op returns. */
509
877
  export interface AppRow {
510
878
  key: string;
@@ -523,6 +891,68 @@ export interface AppRowsPage {
523
891
  next_cursor: string | null;
524
892
  has_more: boolean;
525
893
  }
894
+ /** A scalar operand for a structured read-query condition (Wave C2). */
895
+ export type ListScalar = string | number | boolean;
896
+ /**
897
+ * One structured read-query condition (Wave C2). `op` is one of eq / neq / in /
898
+ * notIn / gt / lt / gte / lte, with the SAME type semantics as a notify `when`:
899
+ * comparisons are same-type only (a number never matches a string operand), and
900
+ * dates are compared as ISO-8601 strings. `value` is a single scalar for the
901
+ * scalar ops and a non-empty scalar array for `in`/`notIn`.
902
+ */
903
+ export interface ListWhereCondition {
904
+ field: string;
905
+ op: "eq" | "neq" | "in" | "notIn" | "gt" | "lt" | "gte" | "lte";
906
+ value: ListScalar | ListScalar[];
907
+ }
908
+ /** One structured read-query sort key (Wave C2). `dir` defaults to "asc". */
909
+ export interface ListSortSpec {
910
+ field: string;
911
+ dir?: "asc" | "desc";
912
+ }
913
+ /** One row of a batch write (Wave B). `key` absent => create; present => upsert. */
914
+ export interface BatchRowInput {
915
+ key?: string;
916
+ data: unknown;
917
+ }
918
+ /**
919
+ * The outcome of one row in a batch, addressed by its `index` in the input array
920
+ * so a caller maps a failure back to the exact row it sent. `ok:true` carries the
921
+ * written `key`; `ok:false` carries the per-row `error`.
922
+ */
923
+ export interface BatchRowResult {
924
+ index: number;
925
+ ok: boolean;
926
+ key?: string;
927
+ error?: {
928
+ code: string;
929
+ message: string;
930
+ status: number;
931
+ details?: unknown;
932
+ };
933
+ }
934
+ /** The batch write / delete envelope: per-row results plus convenience counts. */
935
+ export interface BatchResult {
936
+ results: BatchRowResult[];
937
+ ok_count: number;
938
+ error_count: number;
939
+ }
940
+ /**
941
+ * Effect-suppression options for a batch (silent migration, Wave B). The batch
942
+ * endpoint DEFAULTS to silent (suppresses both notify and webhooks). Pass
943
+ * `emitEffects:true` (or `suppress:[]`) to fire effects, or `suppress` to mute a
944
+ * specific subset. Honored only for an owner/agent key.
945
+ */
946
+ export interface BatchWriteOptions {
947
+ suppress?: ("notify" | "webhooks")[];
948
+ emitEffects?: boolean;
949
+ /**
950
+ * Wave C1 natural-key upsert: match (or create) each row on this
951
+ * declared-unique field's value instead of the row id, making a re-import
952
+ * idempotent. Mutually exclusive with per-row `key`.
953
+ */
954
+ on?: string;
955
+ }
526
956
  /**
527
957
  * One entry in an app's change feed — the SAME shape whether it arrives via
528
958
  * `GET /v1/apps/:id/feed` (long-poll) or the `/_hs/ws` live/batch frames
@@ -548,6 +978,30 @@ export interface AppFeedPage {
548
978
  truncated: boolean;
549
979
  }
550
980
  /** Request body for `publishCommunityTemplate()`. */
981
+ /**
982
+ * One typed agent-setup step (marketplace PR 9). An ordered list of these tells
983
+ * an installing agent what to configure after install. Carries only the
984
+ * publisher's own defaults + hints, never an installer's value; `secret: true`
985
+ * marks a step whose eventual value is sensitive (its default is masked on the
986
+ * public detail page). The relay validates + normalizes on publish.
987
+ */
988
+ export interface CommunitySetupStep {
989
+ kind: "config" | "seed-data" | "connect" | "note" | "upload";
990
+ label: string;
991
+ description?: string;
992
+ required?: boolean;
993
+ secret?: boolean;
994
+ default?: string;
995
+ choices?: string[];
996
+ valueHint?: string;
997
+ /**
998
+ * The settings-collection field this step's answer is written into
999
+ * (install-config programme). Required for an `upload` step, optional for a
1000
+ * `config` step, and not allowed on the other kinds. Field-key identifier
1001
+ * (letters, digits, '_', up to 64 chars).
1002
+ */
1003
+ key?: string;
1004
+ }
551
1005
  export interface PublishCommunityTemplateRequest {
552
1006
  /** The owned app to publish. */
553
1007
  appId: string;
@@ -555,22 +1009,77 @@ export interface PublishCommunityTemplateRequest {
555
1009
  title?: string;
556
1010
  /** Listing blurb; defaults server-side to the manifest description. */
557
1011
  description?: string;
558
- /** Optional single-word category. */
1012
+ /**
1013
+ * Optional long-form description (template-experience PR 5a): richer prose
1014
+ * rendered on the detail page below the short blurb. Plain text, length-capped
1015
+ * server-side; blank lines become escaped paragraph breaks (never raw HTML).
1016
+ */
1017
+ longDescription?: string;
1018
+ /** Optional category (validated server-side against the fixed enum). */
559
1019
  category?: string;
560
1020
  /** Optional curation tags. */
561
1021
  tags?: string[];
1022
+ /**
1023
+ * Optional per-publisher slug (marketplace PR 2). When set, the template gets
1024
+ * a namespaced id `<handle>/<slug>` and joins a versioned line.
1025
+ */
1026
+ slug?: string;
1027
+ /** Optional semver version; defaults server-side to "1.0.0". */
1028
+ version?: string;
1029
+ /** Optional note recorded in this version's changelog entry. */
1030
+ changelogNote?: string;
1031
+ /**
1032
+ * Optional ordered typed setup steps (marketplace PR 9). Validated + stored
1033
+ * server-side; read back through the detail page + get_submission.
1034
+ */
1035
+ setupSteps?: CommunitySetupStep[];
1036
+ /** Optional remix/fork lineage: the snapshot id this was derived from. */
1037
+ derivedFromSnapshotId?: string;
1038
+ /**
1039
+ * The example-only attestation (marketplace PR 10). Set true to attest the
1040
+ * captured template content AND its captured seed rows contain no real
1041
+ * personal data: publishing makes both PUBLIC to all platform users. Recorded
1042
+ * (not a hard gate) and surfaced to the operator review payload; omitting it
1043
+ * still publishes but is shown to the reviewer as "not attested".
1044
+ */
1045
+ attestExampleOnly?: boolean;
562
1046
  }
563
1047
  export interface PublishCommunityTemplateResponse {
564
1048
  snapshot_id: string;
565
1049
  review_status: "pending" | "approved" | "rejected";
566
1050
  name: string;
567
1051
  seeded_row_count: number;
1052
+ slug: string | null;
1053
+ version: string;
1054
+ /** How many typed setup steps were stored (marketplace PR 9). */
1055
+ setup_step_count: number;
1056
+ /**
1057
+ * The example-only attestation as recorded (marketplace PR 10): true/false as
1058
+ * attested, or null when the publish carried no attestation.
1059
+ */
1060
+ attest_example_only: boolean | null;
1061
+ /**
1062
+ * Fast-track outcome (marketplace PR 11). `expedited`: the publisher is
1063
+ * `established` and the submission still landed `pending`, flagged to sort
1064
+ * first in the operator review queue. `auto_approved`: the publisher is
1065
+ * `established` AND the relay enables true auto-approve, so it was approved
1066
+ * immediately at publish (review_status === "approved"). Both false for a
1067
+ * `new` publisher (ordinary full review).
1068
+ */
1069
+ expedited: boolean;
1070
+ auto_approved: boolean;
568
1071
  }
569
1072
  /** A pending-submission summary in the operator review queue. */
570
1073
  export interface CommunitySubmissionSummary {
571
1074
  snapshot_id: string;
572
1075
  name: string;
573
1076
  description: string | null;
1077
+ /**
1078
+ * Publisher-provided long-form description (template-experience PR 5a),
1079
+ * surfaced in the review payload so the operator sees the full prose that
1080
+ * renders on the detail page. Null when the publish carried none.
1081
+ */
1082
+ long_description: string | null;
574
1083
  category: string | null;
575
1084
  tags: string[];
576
1085
  review_status: "pending" | "approved" | "rejected";
@@ -579,20 +1088,127 @@ export interface CommunitySubmissionSummary {
579
1088
  source_app_id: string | null;
580
1089
  seeded_row_count: number;
581
1090
  published_at: string;
1091
+ /** Namespaced identity + version (marketplace PR 2); null when unset. */
1092
+ slug: string | null;
1093
+ version: string | null;
1094
+ /**
1095
+ * Publish-time PII attestation (marketplace PR 10): true/false as attested by
1096
+ * the publisher, or null when the publish carried no attestation. Surfaced in
1097
+ * the review queue so a missing attestation is visible to the operator.
1098
+ */
1099
+ attest_example_only: boolean | null;
1100
+ /**
1101
+ * Fast-track flag (marketplace PR 11): true when this pending submission was
1102
+ * expedited (published by an `established` publisher, sorted first in the
1103
+ * queue). False for a `new` publisher and pre-PR-11 rows.
1104
+ */
1105
+ expedited: boolean;
1106
+ /** The publisher's trust level (marketplace PR 11): "new" | "established". */
1107
+ publisher_trust_level: string;
582
1108
  }
583
1109
  export interface CommunitySubmissionsPage {
584
1110
  items: CommunitySubmissionSummary[];
585
1111
  next_cursor: string | null;
586
1112
  }
1113
+ /** One collection's captured-seed footprint (marketplace PR 10). */
1114
+ export interface CommunitySeedCollectionSummary {
1115
+ collection: string;
1116
+ row_count: number;
1117
+ bytes: number;
1118
+ }
1119
+ /** A per-collection digest of a submission's captured seed rows (PR 10). */
1120
+ export interface CommunitySeedRowSummary {
1121
+ collections: CommunitySeedCollectionSummary[];
1122
+ total_rows: number;
1123
+ total_bytes: number;
1124
+ }
587
1125
  /** A submission's FULL content, for operator review. */
588
1126
  export interface CommunitySubmissionDetail extends CommunitySubmissionSummary {
589
1127
  html: string;
590
1128
  manifest: unknown;
591
1129
  seed_rows: unknown;
1130
+ /**
1131
+ * A digest of seed_rows (marketplace PR 10): per-collection row counts + byte
1132
+ * sizes plus totals, so the operator sees how much (and which) captured
1133
+ * starter data would become public without parsing the raw rows.
1134
+ */
1135
+ seed_summary: CommunitySeedRowSummary;
592
1136
  version_id: string | null;
593
1137
  reviewed_at: string | null;
594
1138
  review_note: string | null;
595
1139
  superseded_at: string | null;
1140
+ /** Namespaced identity + fork/license fields (marketplace PR 2). */
1141
+ changelog: unknown;
1142
+ license: string | null;
1143
+ license_terms: string | null;
1144
+ derived_from_snapshot_id: string | null;
1145
+ /**
1146
+ * Typed agent-setup steps (marketplace PR 9): the machine-readable structure
1147
+ * an agent reads. Full structure incl. secret-flagged steps (a step stores
1148
+ * only the publisher's default/hint, never an installer value). `null` when
1149
+ * the template declares no steps.
1150
+ */
1151
+ setup_steps: CommunitySetupStep[] | null;
1152
+ }
1153
+ /** A publisher's own profile, as returned by the `/v1/publisher` routes. */
1154
+ export interface PublisherProfile {
1155
+ handle: string;
1156
+ display_name: string | null;
1157
+ bio: string | null;
1158
+ url: string | null;
1159
+ /** True once the handle has been claimed (and is therefore permanent). */
1160
+ claimed: boolean;
1161
+ claimed_at: string | null;
1162
+ created_at: string;
1163
+ first_published_at: string | null;
1164
+ approved_template_count: number;
1165
+ rating_count: number;
1166
+ rating_sum: number;
1167
+ trust_level: string;
1168
+ }
1169
+ /**
1170
+ * The mutable profile fields for `updatePublisher()`. A field left `undefined`
1171
+ * is untouched; `null` clears it.
1172
+ */
1173
+ export interface PublisherProfileUpdate {
1174
+ displayName?: string | null;
1175
+ bio?: string | null;
1176
+ url?: string | null;
1177
+ }
1178
+ /** Request body for `createReview()`. Give `template` OR (`handle` + `slug`). */
1179
+ export interface CreateCommunityReviewRequest {
1180
+ /** Namespaced template id, "<handle>/<slug>". */
1181
+ template?: string;
1182
+ /** Publisher handle (with `slug`, as an alternative to `template`). */
1183
+ handle?: string;
1184
+ /** Per-publisher slug (with `handle`). */
1185
+ slug?: string;
1186
+ /** Star rating, integer 1..5. */
1187
+ stars: number;
1188
+ /** Optional written review body. */
1189
+ body?: string;
1190
+ }
1191
+ /** A review as returned by the `/v1/reviews` routes. */
1192
+ export interface CommunityReview {
1193
+ id: string;
1194
+ template_publisher_human_id: string;
1195
+ template_slug: string | null;
1196
+ snapshot_id: string;
1197
+ install_id: string | null;
1198
+ reviewer_human_id: string;
1199
+ stars: number;
1200
+ body: string | null;
1201
+ /** "visible" | "held" | "removed". A body with a link/email lands "held". */
1202
+ status: string;
1203
+ held_reason: string | null;
1204
+ publisher_response: string | null;
1205
+ publisher_responded_at: string | null;
1206
+ created_at: string;
1207
+ }
1208
+ /** Result of `reportReview()`. */
1209
+ export interface CommunityReviewReportResult {
1210
+ report_id: string;
1211
+ review_id: string;
596
1212
  }
597
1213
  /** Per-attachment metadata as returned by `POST /v1/attachments` and friends. */
598
1214
  export interface AttachmentRef {
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
@@ -510,6 +529,33 @@ export class HomespunClient {
510
529
  this.fail(r);
511
530
  return this.asObject(r);
512
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);
555
+ if (!r.ok)
556
+ this.fail(r);
557
+ return this.asObject(r);
558
+ }
513
559
  /**
514
560
  * GET /v1/apps — list apps scoped to the calling agent's owning human.
515
561
  * `slug` is an exact-match filter — the one case a caller resolves a
@@ -538,9 +584,37 @@ export class HomespunClient {
538
584
  this.fail(r);
539
585
  return this.asObject(r);
540
586
  }
541
- /** PATCH /v1/apps/:id — update visibility (the only mutable app field). */
542
- async updateApp(appId, visibility) {
543
- 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`, {});
544
618
  if (!r.ok)
545
619
  this.fail(r);
546
620
  return this.asObject(r);
@@ -613,7 +687,7 @@ export class HomespunClient {
613
687
  * EMAIL_PROVIDER=none (no invite-email path available).
614
688
  */
615
689
  async addAppMember(appId, opts) {
616
- 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 });
617
691
  if (!r.ok)
618
692
  this.fail(r);
619
693
  return this.asObject(r);
@@ -625,6 +699,39 @@ export class HomespunClient {
625
699
  this.fail(r);
626
700
  return this.asObject(r);
627
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
+ }
628
735
  /**
629
736
  * DELETE /v1/apps/:id/members/:humanId — remove a member (idempotent);
630
737
  * cascades to revoke that human's live app sessions. The app owner cannot
@@ -636,17 +743,106 @@ export class HomespunClient {
636
743
  this.fail(r);
637
744
  }
638
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
+ // -------------------------------------------------------------------------
639
823
  // v2 collection row CRUD + feed catch-up (spec-cli §2.2/§2.3). Same wire
640
824
  // shapes as the v1 homespun-records methods above, mounted under
641
825
  // /v1/apps/:id/collections/:name instead of /v1/apps/:id/records/:name.
642
826
  // -------------------------------------------------------------------------
643
- /** 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
+ */
644
837
  async listAppRows(appId, collection, opts = {}) {
645
838
  const q = new URLSearchParams();
646
839
  if (opts.since !== undefined)
647
840
  q.set("since", opts.since);
648
841
  if (opts.limit !== undefined)
649
842
  q.set("limit", String(opts.limit));
843
+ const query = buildListQueryParam(opts.where, opts.sort);
844
+ if (query !== undefined)
845
+ q.set("q", query);
650
846
  const qs = q.toString();
651
847
  const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}/collections/${encodeURIComponent(collection)}${qs ? "?" + qs : ""}`);
652
848
  if (!r.ok)
@@ -676,6 +872,26 @@ export class HomespunClient {
676
872
  this.fail(r);
677
873
  return this.asObject(r);
678
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
+ }
679
895
  /** PATCH /v1/apps/:id/collections/:name/:key — optimistic-locked update. */
680
896
  async updateAppRow(appId, collection, key, body) {
681
897
  const r = await this.call("PATCH", `/v1/apps/${encodeURIComponent(appId)}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(key)}`, body);
@@ -690,6 +906,37 @@ export class HomespunClient {
690
906
  if (!r.ok)
691
907
  this.fail(r);
692
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
+ }
693
940
  /**
694
941
  * GET /v1/apps/:id/feed — change-feed catch-up; the long-poll fallback
695
942
  * transport for `watch` (spec-cli §2.3/§5). `wait` (0-30s) long-polls when
@@ -718,8 +965,15 @@ export class HomespunClient {
718
965
  app_id: req.appId,
719
966
  title: req.title,
720
967
  description: req.description,
968
+ long_description: req.longDescription,
721
969
  category: req.category,
722
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,
723
977
  });
724
978
  if (!r.ok)
725
979
  this.fail(r);
@@ -757,4 +1011,107 @@ export class HomespunClient {
757
1011
  this.fail(r);
758
1012
  return this.asObject(r);
759
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);
760
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, AppAsset, DeployAppRequest, DeployAppResponse, RedeployAppRequest, RedeployAppResponse, AppSummary, AppDetail, AppsPage, AppDomain, AppDomainDnsRecord, 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.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,9 +11,9 @@
11
11
  "relay",
12
12
  "human-in-the-loop"
13
13
  ],
14
- "homepage": "https://homespun.dev",
14
+ "homepage": "https://docs.homespun.dev",
15
15
  "bugs": {
16
- "url": "mailto:support@homespun.dev"
16
+ "url": "https://github.com/homespunapps/homespun/issues"
17
17
  },
18
18
  "engines": {
19
19
  "node": ">=20"
@@ -41,13 +41,18 @@
41
41
  "test:unit": "vitest run"
42
42
  },
43
43
  "dependencies": {
44
- "ws": "^8.20.1",
44
+ "ws": "^8.21.1",
45
45
  "zod": "^4.4.3"
46
46
  },
47
47
  "devDependencies": {
48
- "@types/node": "^25.9.2",
48
+ "@types/node": "^26.1.1",
49
49
  "@types/ws": "^8.18.1",
50
- "typescript": "^6.0.3",
50
+ "typescript": "^7.0.2",
51
51
  "vitest": "^4.1.8"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/homespunapps/homespun.git",
56
+ "directory": "packages/core"
52
57
  }
53
58
  }