@homespunapps/core 1.6.1 → 1.6.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
@@ -192,6 +192,25 @@ export declare class HomespunClient {
192
192
  * @param contentBase64 standard base64 (e.g. `Buffer.from(bytes).toString("base64")`).
193
193
  */
194
194
  uploadBlobInline(contentBase64: string, opts?: UploadBlobOptions): Promise<AttachmentRef>;
195
+ /**
196
+ * POST /v1/attachments/fetch — server-side URL ingestion. The RELAY downloads
197
+ * the bytes from `sourceUrl` itself (SSRF-guarded: https only, no private /
198
+ * loopback / link-local / metadata hosts, DNS resolved-and-pinned against
199
+ * rebinding, redirects refused, body size-capped, request timed out), then
200
+ * runs the IDENTICAL validation pipeline every upload runs (magic-byte MIME
201
+ * sniff, allowlist, per-type size cap, sha256, scan hook, per-agent / per-app /
202
+ * per-account quota) before returning the ready `AttachmentRef`.
203
+ *
204
+ * The caller sends only the URL string, so the bytes never enter the model
205
+ * context and cost NO tokens — the highest-leverage zero-context path for an
206
+ * image/media source that already has a URL. `mime` is advisory (the relay
207
+ * sniffs the real type). Works on any storage backend.
208
+ */
209
+ fetchBlob(sourceUrl: string, opts?: {
210
+ scope?: "agent" | "app";
211
+ appId?: string;
212
+ mime?: string;
213
+ }): Promise<AttachmentRef>;
195
214
  /** GET /v1/attachments/:id — download bytes as an ArrayBuffer. */
196
215
  downloadBlob(attachmentId: string): Promise<ArrayBuffer>;
197
216
  /**
@@ -295,6 +314,14 @@ export declare class HomespunClient {
295
314
  }): Promise<AppsPage>;
296
315
  /** GET /v1/apps/:id — full app detail (manifest, current_version, quota). */
297
316
  getApp(appId: string): Promise<AppDetail>;
317
+ /**
318
+ * GET /v1/apps/:id/document: the app's AUTHORED HTML source (its current
319
+ * version), before the served page's injected prelude (SDK script, favicon,
320
+ * OG meta, trial banner). Use this to recover the exact HTML you deployed
321
+ * instead of scraping the public page and stripping the prelude. 404 if the
322
+ * app has no deployed version.
323
+ */
324
+ getAppDocument(appId: string): Promise<AppDocument>;
298
325
  /**
299
326
  * PATCH /v1/apps/:id updates the app's mutable settings. Both fields are
300
327
  * optional but at least one must be given: `visibility` (private|link|public)
@@ -465,6 +492,28 @@ export declare class HomespunClient {
465
492
  clearIngestSigningSecret(appId: string, name: string): Promise<{
466
493
  ok: boolean;
467
494
  }>;
495
+ /**
496
+ * POST /v1/apps/:id/ingest-hooks/:name/backfill bulk-loads an array of raw
497
+ * provider bodies through a hook's CURRENT manifest mapping, so a backfilled
498
+ * row is byte-identical to a live delivery (issue #966). Each body is an
499
+ * already-parsed JSON value (whatever the provider would have POSTed). It runs
500
+ * the SAME receive pipeline as the public URL, so map/dedupeKey/upsertOn/schema
501
+ * validation and the collection quota all apply, but it SKIPS the public-URL
502
+ * brakes (per-IP rate limit, per-app hourly cap) and never verifies a signature
503
+ * (owner/agent-authed). Wake is suppressed so a historical load never wakes a
504
+ * dormant app. Dedupe is ON: re-running an interrupted backfill is idempotent
505
+ * for a body-path dedupeKey (a header:<name> dedupeKey cannot resolve with no
506
+ * request headers, so it does not dedupe). Chunk arrays over the relay's
507
+ * INGEST_BACKFILL_MAX_BODIES (default 500), which return 400. Returns aggregate
508
+ * { accepted, dropped_duplicate, failed } counts plus a per-body `outcomes`
509
+ * array in input order.
510
+ */
511
+ backfillIngestHook(appId: string, name: string, bodies: unknown[]): Promise<{
512
+ accepted: number;
513
+ dropped_duplicate: number;
514
+ failed: number;
515
+ outcomes: string[];
516
+ }>;
468
517
  /**
469
518
  * POST /v1/apps/:id/grants: mint a grant link. `role` must be a declared
470
519
  * custom role for the app. `mode` is "once" (one-time, first-browser-claims)
@@ -598,19 +647,19 @@ export declare class HomespunClient {
598
647
  note?: string;
599
648
  }): Promise<CommunitySubmissionDetail>;
600
649
  /**
601
- * GET /v1/community/templates/:ref/config-contract: the install-time config
602
- * contract for an installable template (its settings collection + the ordered
603
- * keyed config/upload steps). Use it to discover what a template needs and to
604
- * pre-upload files for `upload` steps (POST /v1/attachments) before install.
650
+ * GET the install-time config contract for an installable template (its
651
+ * settings collection + the ordered keyed config/upload steps). Use it to
652
+ * discover what a template needs and to pre-upload files for `upload` steps
653
+ * (POST /v1/attachments) before install.
605
654
  */
606
655
  getCommunityConfigContract(ref: string): Promise<CommunityConfigContract>;
607
656
  /**
608
- * POST /v1/community/templates/:ref/install: install a community template for
609
- * the calling agent's owning human. `config` is `{ [stepKey]: value }` where a
610
- * `config` step's value is a string and an `upload` step's value is an
611
- * attachment id the agent pre-uploaded (agent-scoped) via `uploadBlob()` /
612
- * `uploadBlobInline()`; the relay re-points it to the new app on install.
613
- * Installs always create; returns the new app's id, slug, and url.
657
+ * POST to install a community template for the calling agent's owning human.
658
+ * `config` is `{ [stepKey]: value }` where a `config` step's value is a string
659
+ * and an `upload` step's value is an attachment id the agent pre-uploaded
660
+ * (agent-scoped) via `uploadBlob()` / `uploadBlobInline()`; the relay
661
+ * re-points it to the new app on install. Installs always create; returns the
662
+ * new app's id, slug, and url.
614
663
  */
615
664
  installCommunityTemplate(ref: string, config?: Record<string, unknown>): Promise<CommunityInstallResult>;
616
665
  /** GET /v1/publisher: the caller's own publisher profile. */
@@ -641,16 +690,29 @@ export declare class HomespunClient {
641
690
  unholdReview(reviewId: string): Promise<CommunityReview>;
642
691
  }
643
692
  /**
644
- * One multi-file-deploy asset: bytes the deployed page references by a stable,
645
- * app-relative, same-origin path (e.g. `frames/000.jpg`). `content_base64` is the
646
- * standard base64 of the raw bytes; `mime` is advisory (the relay sniffs the
647
- * real type from the leading bytes).
693
+ * One multi-file-deploy asset carrying its bytes inline as base64. The deployed
694
+ * page references it by a stable, app-relative, same-origin path (e.g.
695
+ * `frames/000.jpg`). `content_base64` is the standard base64 of the raw bytes;
696
+ * `mime` is advisory (the relay sniffs the real type from the leading bytes).
648
697
  */
649
- export interface AppAsset {
698
+ export interface AppAssetInline {
650
699
  path: string;
651
700
  content_base64: string;
652
701
  mime?: string;
653
702
  }
703
+ /**
704
+ * One multi-file-deploy asset referencing an ALREADY-uploaded attachment (via
705
+ * `attachments fetch` or presign + finalize) instead of carrying its bytes. The
706
+ * referenced attachment must be owned by the deploying agent, app-scoped to THIS
707
+ * app, and `ready`. Lets an image reach the app with zero base64 in the deploy
708
+ * body (and, with fetch, zero bytes in the model context at all).
709
+ */
710
+ export interface AppAssetRef {
711
+ path: string;
712
+ attachment_id: string;
713
+ }
714
+ /** A multi-file-deploy asset: inline bytes OR a by-reference attachment id. */
715
+ export type AppAsset = AppAssetInline | AppAssetRef;
654
716
  /** Request body for `POST /v1/apps` — create (deploy) a new App. */
655
717
  export interface DeployAppRequest {
656
718
  html: string;
@@ -804,6 +866,15 @@ export interface AppsPage {
804
866
  items: AppSummary[];
805
867
  next_cursor: string | null;
806
868
  }
869
+ /** `GET /v1/apps/:id/document`: an app's authored HTML source (current version). */
870
+ export interface AppDocument {
871
+ /** The authored HTML, exactly as deployed (no served-page prelude injected). */
872
+ html: string;
873
+ /** The current version number these bytes belong to. */
874
+ version: number;
875
+ /** Content hash of `html` (the app's `sourceHash`). */
876
+ source_hash: string;
877
+ }
807
878
  /** One DNS record the domain owner must publish (custom domains). */
808
879
  export interface AppDomainDnsRecord {
809
880
  type: string;
@@ -1001,6 +1072,14 @@ export interface ListSortSpec {
1001
1072
  field: string;
1002
1073
  dir?: "asc" | "desc";
1003
1074
  }
1075
+ /**
1076
+ * Path for a community-template ref action. A namespaced `<handle>/<slug>` ref
1077
+ * becomes a TWO-segment path (`/templates/<handle>/<slug>/<suffix>`) so it
1078
+ * survives a proxy/edge that normalizes a percent-encoded slash to `/` before
1079
+ * routing (#921); a snapshot id (no slash) stays single-segment. Mirrors the
1080
+ * server's `parseNamespacedId`: exactly two non-empty slash-separated parts.
1081
+ */
1082
+ export declare function communityTemplatePath(ref: string, suffix: string): string;
1004
1083
  /** One row of a batch write (Wave B). `key` absent => create; present => upsert. */
1005
1084
  export interface BatchRowInput {
1006
1085
  key?: string;
package/dist/client.js CHANGED
@@ -357,6 +357,33 @@ export class HomespunClient {
357
357
  this.fail(r);
358
358
  return this.asObject(r);
359
359
  }
360
+ /**
361
+ * POST /v1/attachments/fetch — server-side URL ingestion. The RELAY downloads
362
+ * the bytes from `sourceUrl` itself (SSRF-guarded: https only, no private /
363
+ * loopback / link-local / metadata hosts, DNS resolved-and-pinned against
364
+ * rebinding, redirects refused, body size-capped, request timed out), then
365
+ * runs the IDENTICAL validation pipeline every upload runs (magic-byte MIME
366
+ * sniff, allowlist, per-type size cap, sha256, scan hook, per-agent / per-app /
367
+ * per-account quota) before returning the ready `AttachmentRef`.
368
+ *
369
+ * The caller sends only the URL string, so the bytes never enter the model
370
+ * context and cost NO tokens — the highest-leverage zero-context path for an
371
+ * image/media source that already has a URL. `mime` is advisory (the relay
372
+ * sniffs the real type). Works on any storage backend.
373
+ */
374
+ async fetchBlob(sourceUrl, opts = {}) {
375
+ const body = { source_url: sourceUrl };
376
+ if (opts.scope)
377
+ body.scope = opts.scope;
378
+ if (opts.appId)
379
+ body.app_id = opts.appId;
380
+ if (opts.mime)
381
+ body.mime = opts.mime;
382
+ const r = await this.call("POST", "/v1/attachments/fetch", body);
383
+ if (!r.ok)
384
+ this.fail(r);
385
+ return this.asObject(r);
386
+ }
360
387
  /** GET /v1/attachments/:id — download bytes as an ArrayBuffer. */
361
388
  async downloadBlob(attachmentId) {
362
389
  const url = this.base + "/v1/attachments/" + encodeURIComponent(attachmentId);
@@ -584,6 +611,19 @@ export class HomespunClient {
584
611
  this.fail(r);
585
612
  return this.asObject(r);
586
613
  }
614
+ /**
615
+ * GET /v1/apps/:id/document: the app's AUTHORED HTML source (its current
616
+ * version), before the served page's injected prelude (SDK script, favicon,
617
+ * OG meta, trial banner). Use this to recover the exact HTML you deployed
618
+ * instead of scraping the public page and stripping the prelude. 404 if the
619
+ * app has no deployed version.
620
+ */
621
+ async getAppDocument(appId) {
622
+ const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}/document`);
623
+ if (!r.ok)
624
+ this.fail(r);
625
+ return this.asObject(r);
626
+ }
587
627
  /**
588
628
  * PATCH /v1/apps/:id updates the app's mutable settings. Both fields are
589
629
  * optional but at least one must be given: `visibility` (private|link|public)
@@ -841,6 +881,28 @@ export class HomespunClient {
841
881
  this.fail(r);
842
882
  return this.asObject(r);
843
883
  }
884
+ /**
885
+ * POST /v1/apps/:id/ingest-hooks/:name/backfill bulk-loads an array of raw
886
+ * provider bodies through a hook's CURRENT manifest mapping, so a backfilled
887
+ * row is byte-identical to a live delivery (issue #966). Each body is an
888
+ * already-parsed JSON value (whatever the provider would have POSTed). It runs
889
+ * the SAME receive pipeline as the public URL, so map/dedupeKey/upsertOn/schema
890
+ * validation and the collection quota all apply, but it SKIPS the public-URL
891
+ * brakes (per-IP rate limit, per-app hourly cap) and never verifies a signature
892
+ * (owner/agent-authed). Wake is suppressed so a historical load never wakes a
893
+ * dormant app. Dedupe is ON: re-running an interrupted backfill is idempotent
894
+ * for a body-path dedupeKey (a header:<name> dedupeKey cannot resolve with no
895
+ * request headers, so it does not dedupe). Chunk arrays over the relay's
896
+ * INGEST_BACKFILL_MAX_BODIES (default 500), which return 400. Returns aggregate
897
+ * { accepted, dropped_duplicate, failed } counts plus a per-body `outcomes`
898
+ * array in input order.
899
+ */
900
+ async backfillIngestHook(appId, name, bodies) {
901
+ const r = await this.call("POST", `/v1/apps/${encodeURIComponent(appId)}/ingest-hooks/${encodeURIComponent(name)}/backfill`, { bodies });
902
+ if (!r.ok)
903
+ this.fail(r);
904
+ return this.asObject(r);
905
+ }
844
906
  // -------------------------------------------------------------------------
845
907
  // v2 grant links (M5). An owner/agent mints a capability URL carrying a
846
908
  // DECLARED custom role (x-homespun-manifest.roles) plus an optional row/filter
@@ -1083,30 +1145,31 @@ export class HomespunClient {
1083
1145
  // Agent-key community install (install-config programme, PR 4). Both act AS
1084
1146
  // the calling agent's owning human, who becomes the installed app's owner. A
1085
1147
  // `ref` is a namespaced `<handle>/<slug>` OR a community snapshot id;
1086
- // encodeURIComponent keeps a namespaced ref one path segment on the wire.
1148
+ // `communityTemplatePath` sends a namespaced ref as two path segments so it
1149
+ // survives an edge that decodes `%2F` to `/` before routing (#921).
1087
1150
  // -------------------------------------------------------------------------
1088
1151
  /**
1089
- * GET /v1/community/templates/:ref/config-contract: the install-time config
1090
- * contract for an installable template (its settings collection + the ordered
1091
- * keyed config/upload steps). Use it to discover what a template needs and to
1092
- * pre-upload files for `upload` steps (POST /v1/attachments) before install.
1152
+ * GET the install-time config contract for an installable template (its
1153
+ * settings collection + the ordered keyed config/upload steps). Use it to
1154
+ * discover what a template needs and to pre-upload files for `upload` steps
1155
+ * (POST /v1/attachments) before install.
1093
1156
  */
1094
1157
  async getCommunityConfigContract(ref) {
1095
- const r = await this.call("GET", `/v1/community/templates/${encodeURIComponent(ref)}/config-contract`);
1158
+ const r = await this.call("GET", communityTemplatePath(ref, "config-contract"));
1096
1159
  if (!r.ok)
1097
1160
  this.fail(r);
1098
1161
  return this.asObject(r);
1099
1162
  }
1100
1163
  /**
1101
- * POST /v1/community/templates/:ref/install: install a community template for
1102
- * the calling agent's owning human. `config` is `{ [stepKey]: value }` where a
1103
- * `config` step's value is a string and an `upload` step's value is an
1104
- * attachment id the agent pre-uploaded (agent-scoped) via `uploadBlob()` /
1105
- * `uploadBlobInline()`; the relay re-points it to the new app on install.
1106
- * Installs always create; returns the new app's id, slug, and url.
1164
+ * POST to install a community template for the calling agent's owning human.
1165
+ * `config` is `{ [stepKey]: value }` where a `config` step's value is a string
1166
+ * and an `upload` step's value is an attachment id the agent pre-uploaded
1167
+ * (agent-scoped) via `uploadBlob()` / `uploadBlobInline()`; the relay
1168
+ * re-points it to the new app on install. Installs always create; returns the
1169
+ * new app's id, slug, and url.
1107
1170
  */
1108
1171
  async installCommunityTemplate(ref, config) {
1109
- const r = await this.call("POST", `/v1/community/templates/${encodeURIComponent(ref)}/install`, config !== undefined ? { config } : {});
1172
+ const r = await this.call("POST", communityTemplatePath(ref, "install"), config !== undefined ? { config } : {});
1110
1173
  if (!r.ok)
1111
1174
  this.fail(r);
1112
1175
  return this.asObject(r);
@@ -1203,6 +1266,20 @@ export class HomespunClient {
1203
1266
  return this.asObject(r);
1204
1267
  }
1205
1268
  }
1269
+ /**
1270
+ * Path for a community-template ref action. A namespaced `<handle>/<slug>` ref
1271
+ * becomes a TWO-segment path (`/templates/<handle>/<slug>/<suffix>`) so it
1272
+ * survives a proxy/edge that normalizes a percent-encoded slash to `/` before
1273
+ * routing (#921); a snapshot id (no slash) stays single-segment. Mirrors the
1274
+ * server's `parseNamespacedId`: exactly two non-empty slash-separated parts.
1275
+ */
1276
+ export function communityTemplatePath(ref, suffix) {
1277
+ const parts = ref.split("/");
1278
+ if (parts.length === 2 && parts[0] && parts[1]) {
1279
+ return `/v1/community/templates/${encodeURIComponent(parts[0])}/${encodeURIComponent(parts[1])}/${suffix}`;
1280
+ }
1281
+ return `/v1/community/templates/${encodeURIComponent(ref)}/${suffix}`;
1282
+ }
1206
1283
  // Serialize `where`/`sort` into the `q` query param (URL-encoded JSON), or
1207
1284
  // undefined when neither is given. URLSearchParams handles the percent-encoding.
1208
1285
  function buildListQueryParam(where, sort) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homespunapps/core",
3
- "version": "1.6.1",
3
+ "version": "1.6.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",