import type { FeedbackPage, FeedbackSubmission, FeedbackType, KeyInfo, TasteInfo } from "./types.js"; export interface ClientOptions { /** Relay base URL, e.g. https://homespun.example.com. Trailing slash is trimmed. */ url: string; /** Agent API key (bearer token). */ apiKey: string; /** Optional fetch override (defaults to global fetch). */ fetch?: typeof fetch; /** * Optional client version string sent as `x-homespun-cli-version` on every * request. The CLI passes its own `VERSION` constant here so a relay can * detect version skew and respond with a `cli_upgrade_required` error * (HTTP 426) when the CLI is below the relay's minimum supported version. * Library callers (non-CLI) can leave this unset — the header is omitted * and the relay treats the request as version-unknown. */ cliVersion?: string; } /** Low-level relay response: ok flag, HTTP status, parsed JSON body. */ export interface RelayResponse { ok: boolean; status: number; data: unknown; } /** Response from POST /v1/query. */ export interface QueryResponse { /** Ordered column names exactly as DuckDB returned them. */ columns: string[]; /** Result rows; each row is an array of values aligned to `columns`. */ rows: unknown[][]; /** True if the result was capped by the relay's per-query row cap. */ truncated: boolean; /** Tells the caller which apps the query saw and how it was scoped. */ scope: { kind: "human" | "agent"; app_count: number; }; /** Wall-clock milliseconds the relay spent serving the query. */ elapsed_ms: number; } /** * An error thrown by the typed operations when the relay returns a non-2xx * response (or the request fails outright). Carries the HTTP status and the * relay error envelope so callers can branch on `code`. */ export declare class HomespunApiError extends Error { readonly status: number; readonly code: string; readonly details: unknown; /** Agent-friendly remediation hint, when the relay supplies one. */ readonly hint?: string; /** Whether retrying the same request may succeed (e.g. 429). */ readonly retryable?: boolean; /** Documentation URL for this error class (mapped from the wire's `docs_url`). */ readonly docsUrl?: string; constructor(status: number, code: string, message: string, details?: unknown, extra?: { hint?: string; retryable?: boolean; docsUrl?: string; }); } export declare class HomespunClient { private readonly base; private readonly apiKey; private readonly fetchImpl; private readonly cliVersion; constructor(opts: ClientOptions); /** Relay base URL (trailing slash trimmed). */ get baseUrl(): string; /** WebSocket base URL derived from the relay base URL (http→ws, https→wss). */ get wsBaseUrl(): string; /** * Low-level HTTP helper. Mirrors the relay API contract: Bearer auth, * JSON bodies, 204 handled. Never throws on non-2xx — returns `ok: false`. * Network failures return `{ ok: false, status: 0, ... }`. */ call(method: string, path: string, body?: object): Promise; /** Assert a 2xx body is a non-null object before treating it as typed JSON. */ private asObject; /** Throw a HomespunApiError from a failed RelayResponse. */ private fail; /** * GET /v1/keys — the calling agent's own key info. The relay scopes this to * the authenticated agent: it returns one key (the caller's), not a list. */ listKeys(): Promise; /** * DELETE /v1/keys/:id — revoke an API key. The relay only permits revoking * the caller's OWN key (any other id is rejected 403): this is a * self-destruct. Returns 204 with no body on success. */ revokeKey(id: string): Promise; /** * POST /v1/keys: mint a NEW sibling API key for the calling agent's OWN * identity. The relay derives the identity from the bearer token, so a caller * can only ever mint a sibling of itself (never another agent's key). The new * key has the same scope/ownership as the caller and shows up in a subsequent * `listKeys()` call made WITH the new key. * * The raw `api_key` is returned exactly ONCE in this response and is never * retrievable again (only its hash is stored). Bootstraps a fresh CLI / * process credential from an MCP-driven agent that has no key of its own to * hand off. The owner can revoke any minted sibling via the normal revoke * path. */ mintKey(): Promise; /** * POST /v1/agents/claim — bind this agent to a human via a one-shot * claim code the human generated in their settings UI. After a * successful claim the agent's existing API key continues to work, * but the agent (and its apps/templates) now belong to the * claiming human. One-way operation — there is no unclaim in v1. */ claimAgent(code: string): Promise<{ ok: true; owner_human_id: string; claimed_at: string; }>; /** * GET /v1/taste — the calling agent's freeform "taste notes" markdown attachment: * presentation preferences the agent has picked up from human feedback over * time. Returns `{ taste: null, updated_at: null, bytes: 0 }` when the * agent has never written notes. Read this before generating an template so * the agent applies prior feedback. */ getTaste(): Promise; /** * PUT /v1/taste — whole-attachment replace of the calling agent's taste notes. * Empty/whitespace-only values are rejected by the relay; callers asking to * clear must use {@link clearTaste}. The relay caps the payload at the * server's `MAX_TASTE_BYTES` (utf8 bytes). */ setTaste(taste: string): Promise; /** * DELETE /v1/taste — clear the calling agent's taste notes (idempotent on * the relay; clearing already-empty notes still succeeds). Returns 204 with * no body. */ clearTaste(): Promise; /** * POST /v1/feedback — submit a one-shot bug report, feature request, or * note to the relay operator. Returns the new row's id, type, and * created_at; the message is not echoed. */ submitFeedback(req: { type: FeedbackType; message: string; appId?: string; }): Promise; /** * GET /v1/feedback — the calling agent's own submissions, newest first. * `before` is an opaque cursor from a previous page's `next_before`. */ listFeedback(opts?: { limit?: number; before?: string; }): Promise; /** * Upload a attachment to the relay. Returns a `AttachmentRef` that can be referenced * in event payloads (the relay's `format: homespun-attachment-id` schema vocab * validates the id) or in `homespun create --input-data`. * * Scope defaults to "agent" (reusable). For `scope: "app"` pass `appId`. * The calling agent's owning human must own the referenced App; * cross-tenant attempts return app_not_found. * * MIME is inferred from `mime` if supplied; otherwise the relay sniffs * leading bytes and may reject with mime_mismatch / mime_disallowed. * * Backed by the relay's multipart `POST /v1/attachments` (the fallback path). * For large uploads (>1 MB on hosted Azure) call `presignBlob()` + * `confirmBlob()` instead — those use SAS direct-to-storage and don't * stream bytes through the relay. */ uploadBlob(file: Blob | Buffer | Uint8Array, opts?: UploadBlobOptions): Promise; /** * Upload a attachment from INLINE base64 bytes: the no-filesystem sibling of * `uploadBlob()`. Use this when the bytes were produced in memory (an image * the agent generated, a document it assembled) and there is no local file to * stream: an MCP client running inside a Claude session has no filesystem to * point `uploadBlob()` at. * * The bytes are sent as base64 in a JSON body to the relay's * `POST /v1/attachments` inline variant ({ content_base64, filename?, mime?, * scope?, app_id? }). The relay decodes them behind a pre-decode size guard and runs * the IDENTICAL validation pipeline the multipart `uploadBlob()` path runs: * magic-byte MIME sniff, BLOB_MIME_ALLOWLIST, per-attachment size cap, and the * per-agent / per-app / per-account quota reservation. `mime` is advisory * (the relay sniffs the real type regardless), and an oversized or disallowed * inline upload returns the same errors the file path would. * * @param contentBase64 standard base64 (e.g. `Buffer.from(bytes).toString("base64")`). */ uploadBlobInline(contentBase64: string, opts?: UploadBlobOptions): Promise; /** * POST /v1/attachments/fetch — server-side URL ingestion. The RELAY downloads * the bytes from `sourceUrl` itself (SSRF-guarded: https only, no private / * loopback / link-local / metadata hosts, DNS resolved-and-pinned against * rebinding, redirects refused, body size-capped, request timed out), then * runs the IDENTICAL validation pipeline every upload runs (magic-byte MIME * sniff, allowlist, per-type size cap, sha256, scan hook, per-agent / per-app / * per-account quota) before returning the ready `AttachmentRef`. * * The caller sends only the URL string, so the bytes never enter the model * context and cost NO tokens — the highest-leverage zero-context path for an * image/media source that already has a URL. `mime` is advisory (the relay * sniffs the real type). Works on any storage backend. */ fetchBlob(sourceUrl: string, opts?: { scope?: "agent" | "app"; appId?: string; mime?: string; }): Promise; /** GET /v1/attachments/:id — download bytes as an ArrayBuffer. */ downloadBlob(attachmentId: string): Promise; /** * GET a attachment's metadata only — useful before downloading large attachments, or * for `homespun attachment show ` which doesn't want the bytes. Returns the full * AttachmentRef (the same shape POST /v1/attachments returns): id, scope, mime, size, * sha256, filename, width, height, status, scope FKs, timestamps. * * Backed by GET /v1/attachments/:id/metadata which serves the JSON AttachmentRef * without streaming the bytes — cheap on the relay and avoids the * encrypt-at-rest decrypt cost when only the metadata is needed. */ getBlob(attachmentId: string): Promise; /** DELETE /v1/attachments/:id — soft-delete (idempotent). */ deleteBlob(attachmentId: string): Promise<{ deleted: true; }>; /** * Mint a `/b/` capability URL for `attachmentId`. Default TTL is set by * the relay (24h agent-scope, 30d app-scope). `once: true` * tokens self-delete on first GET. */ mintBlobToken(attachmentId: string, opts?: { ttlSeconds?: number; once?: boolean; }): Promise; /** Revoke a previously-minted token. Idempotent. */ revokeBlobToken(attachmentId: string, tokenId: string): Promise<{ token_id: string; revoked: true; }>; /** * GET /v1/attachments — list YOUR agent's non-deleted attachments (newest first). * Paginated via opaque cursor: when `next_cursor` is non-null, pass it * back as `cursor` on the next call. */ listBlobs(opts?: ListBlobsOptions): Promise<{ items: AttachmentRef[]; next_cursor: string | null; }>; /** * GET /v1/attachments/:id/tokens — enumerate the capability tokens minted * against one attachment, including revoked rows (for audit). The plaintext * token is NEVER returned — it isn't stored, only its sha256 is. */ listBlobTokens(attachmentId: string): Promise; /** * Issue a presigned PUT URL for direct-to-storage upload. Returns the * upload URL + the attachment_id (already reserved in the relay's DB with * status=pending) + expiry. After PUTting the bytes to the URL, call * `confirmBlob(attachment_id)` to finalise. * * Filesystem backend returns 501 not_implemented — use uploadBlob() * (multipart fallback) instead. Azure backend returns a SAS URL. */ presignBlob(opts: PresignBlobOptions): Promise<{ attachment_id: string; upload_url: string; expires_at: string; }>; /** * Finalise a presigned upload. After the client PUTs the bytes to the * `upload_url` from `presignBlob()`, the relay re-reads the stored bytes and * runs the SAME validation a normal upload runs: it BYTE-SNIFFS the actual * content and derives the stored/served mime from that (never the * presign-declared mime), re-verifies size + sha256, enforces the allowlist + * quota, and runs the scan hook, before flipping the attachment to `ready`. * A finalize whose bytes fail any check leaves the attachment unready (it is * never served) and returns a clean error. Returns the ready `AttachmentRef`. */ confirmBlob(attachmentId: string): Promise; /** * Alias for {@link confirmBlob}: the "finalize" half of the presign -> * PUT -> finalize flow, named to match the MCP `attachments` action. */ finalizeBlob(attachmentId: string): Promise; /** POST /v1/apps — create (deploy) a new App + its first AppVersion. */ deployApp(req: DeployAppRequest): Promise; /** * POST /v1/apps/:id/versions: redeploy (compat-gated unless force). * * An omitted field keeps what is live (html, manifest and assets alike), so * send only what changes. Undefined fields are dropped by the JSON encoder, * which is exactly the "omitted" the relay reads. */ redeployApp(appId: string, req: RedeployAppRequest): Promise; /** * Dry-run a deploy: validate the manifest + asset shapes, run the compat gate * (for a redeploy) and compute the deploy advisories, WITHOUT creating a * version or mutating anything. Posts the deploy body with `dry_run: true` to * the same route the real deploy uses (`POST /v1/apps` for a create check, * `POST /v1/apps/:id/versions` for a redeploy check), so the relay returns the * SAME validation error a real deploy would for an invalid manifest, and the * SAME warnings/compat result for a valid one. Nothing is persisted. */ checkDeploy(req: DeployCheckRequest): Promise; /** * GET /v1/apps — list apps scoped to the calling agent's owning human. * `slug` is an exact-match filter — the one case a caller resolves a * human-given slug to an id (see the class-level note above). */ listApps(opts?: { /** * `deleted` is the trash listing and the only value that returns * soft-deleted apps; `all` means every LIVE status, as it always has. */ status?: "active" | "dormant" | "archived" | "suspended" | "deleted" | "all"; limit?: number; cursor?: string; slug?: string; }): Promise; /** * GET /v1/apps/advisories — the owner security audit. * * Every security advisory the caller's OWN live apps earn, computed from * their stored manifests rather than at deploy time. That is the point: a * deploy-time warning only ever reaches an app that redeploys, so an app * deployed once and never touched keeps its shape forever with nobody told. * * Read-only. It never changes an app; the repair is one redeploy per app, * and the correct fix genuinely differs per app. */ appAdvisories(opts?: { severity?: "high" | "medium" | "low"; }): Promise; /** GET /v1/apps/:id — full app detail (manifest, current_version, quota). */ getApp(appId: string, opts?: { includeDeleted?: boolean; }): Promise; /** * GET /v1/apps/:id/document: the app's AUTHORED HTML source (its current * version), before the served page's injected prelude (SDK script, favicon, * OG meta, trial banner). Use this to recover the exact HTML you deployed * instead of scraping the public page and stripping the prelude. 404 if the * app has no deployed version. */ getAppDocument(appId: string): Promise; /** * PATCH /v1/apps/:id updates the app's mutable settings. Both fields are * optional but at least one must be given: `visibility` (private|link|public) * and/or `timezone` (an IANA zone name like "Europe/Berlin", used for * `schedules` reminders). Returns the updated `{ id, visibility, timezone }`. * A transition INTO `link` visibility also returns a `share_url` carrying the * app's freshly minted share token in its `#k=` fragment, shown ONCE (the * token is hashed at rest and never recoverable). A transition AWAY from * `link` clears the token (every prior share URL stops working). */ updateApp(appId: string, update: { visibility?: "private" | "link" | "public"; timezone?: string; }): Promise<{ id: string; visibility: string; timezone: string | null; share_url?: string; }>; /** * POST /v1/apps/:id/share-link/rotate rotates a `link` app's share token. * Mints a fresh token and returns the new `share_url` (its `#k=` fragment * carries the raw token, shown ONCE). Rotating instantly revokes the previous * share URL and every pass derived from it. Also serves as "generate" for a * `link` app that has no share token yet. Only valid for a `link` app; a * public/private app returns a conflict. */ rotateShareLink(appId: string): Promise<{ share_url: string; }>; /** * DELETE /v1/apps/:id — soft-delete (idempotent). * * Recoverable, not destructive: the app stops serving immediately but keeps * every version, collection, row, attachment, member and grant it had, and * `restoreApp` brings all of it back until the retention window elapses. * `purgeApp` is the irreversible one. */ deleteApp(appId: string): Promise; /** * POST /v1/apps/:id/restore — undo a soft delete, with all the app's data. * * Idempotent on a live app. Fails with 409 when the account is at its app * cap (the delete released the slot, so the restore has to re-take it), when * the app was an expired trial, or when the owning account is itself * deleted. An app suspended by an operator comes back SUSPENDED, not active. */ restoreApp(appId: string): Promise; /** * POST /v1/apps/:id/purge — destroy a deleted app and all its data NOW, * without waiting out the retention window. Irreversible. * * The app must already be soft-deleted; purging a live one fails with 409. * That two-step is deliberate, so no single call takes a serving app to * unrecoverable. */ purgeApp(appId: string): Promise; /** POST /v1/apps/:id/wake — wake a dormant app; a no-op on a non-dormant one. */ wakeApp(appId: string): Promise<{ id: string; status: string; }>; /** * POST /v1/apps/:id/domain - bind a custom domain to the app. The FIRST * domain bound serves the app; every one after it becomes an alias that * redirects there (`redirect_to`). Returns the record including * `dns_records`: the DNS entries the domain owner must publish (the routing * CNAME plus any Cloudflare validation records). */ setAppDomain(appId: string, domain: string): Promise; /** * GET /v1/apps/:id/domain - the app's SERVING domain at the top level with * its `aliases` beside it, live-refreshed against Cloudflare when the * feature is enabled (status/last_error/last_checked_at update as a side * effect). */ getAppDomain(appId: string): Promise; /** * DELETE /v1/apps/:id/domain - remove the domain binding (Cloudflare * hostname best-effort + record + quota release). Idempotent. */ /** * Unbind the app's custom domains. With no `domain` every binding goes; * with one, only that binding, except that removing the primary takes its * aliases with it (an alias pointing at a domain the app no longer holds * would redirect visitors into nothing). */ deleteAppDomain(appId: string, domain?: string): Promise; /** * POST /v1/apps/:id/members — add or invite a member by email. If a Human * already exists for the email, the member row is created immediately * (`{ member }`); otherwise the relay mints a signed invite and emails a * magic link (`{ ok: true, invited, expires_at }`). 503s if the relay has * EMAIL_PROVIDER=none (no invite-email path available). */ addAppMember(appId: string, opts: { email: string; role?: "member"; customRoles?: string[]; }): Promise; /** GET /v1/apps/:id/members — list the app's owner + member rows. */ listAppMembers(appId: string): Promise<{ members: AppMember[]; }>; /** * PATCH /v1/apps/:id/members/:humanId — replace an existing member's declared * roles in place. An empty array clears them back to a plain member. Every * role must be one the app's manifest declares (a reserved base role or an * undeclared name is a 400), and the app owner's own membership cannot be * re-roled. * * Unlike remove-then-re-add, this does NOT revoke the member's live app * sessions: they stay signed in and pick up the new role on their next * request. Roles are not cached: they are re-derived from the Member row on * every request, so a downgrade takes effect immediately and a live session * can never escalate. */ setAppMemberRole(appId: string, humanId: string, opts: { customRoles: string[]; }): Promise<{ member: AppMember; }>; /** * GET /v1/apps/:id/roles — the derived roles summary: every custom role the * app's manifest declares, what holding it actually allows per collection * (reported separately for members and for grant-link holders, whose role * floors differ), and how many members and live grant links hold it. * Read-only and computed on the fly; permissions themselves stay * manifest-declared. An app that declares no roles returns an empty list. */ listAppRoles(appId: string): Promise<{ roles: AppRoleSummary[]; }>; /** * DELETE /v1/apps/:id/members/:humanId — remove a member (idempotent); * cascades to revoke that human's live app sessions. The app owner cannot * be removed — the relay refuses with a 409 conflict. */ removeAppMember(appId: string, humanId: string): Promise; /** * GET /v1/apps/:id/collections/:name/retention returns the collection's * effective retention (the bounds in force), the author default, the raw owner * override, and how many live rows those bounds would prune on the next sweep. */ getCollectionRetention(appId: string, collection: string): Promise; /** * PATCH /v1/apps/:id/collections/:name/retention sets or clears the owner * retention override, per axis. A number sets that axis's override; `null` * clears it (reverts to the author default); omit an axis to leave it * unchanged. Returns the new effective retention plus the would-prune count. */ setCollectionRetention(appId: string, collection: string, override: { maxRows?: number | null; maxAgeDays?: number | null; }): Promise; /** * GET /v1/apps/:id/ingest-hooks lists the app's inbound catch-hooks with the * full secret URL (decrypted server-side), the current rule metadata * (collection/mode/wake/handshake), and per-hook delivery counts by status. * The URL is the app owner's to share with a sender; it is never exposed on a * public path, only here to the app's own owner/agent. */ listIngestHooks(appId: string): Promise<{ hooks: IngestHookInfo[]; }>; /** * POST /v1/apps/:id/ingest-hooks/:name/rotate mints a fresh secret for one * hook and return its new full URL once. No redeploy needed; the old URL stops * working immediately. */ rotateIngestHook(appId: string, name: string): Promise<{ hook: { name: string; url: string; }; }>; /** * PUT /v1/apps/:id/ingest-hooks/:name/signing-secret provisions or rotates a * hook's SIGNING secret (opt-in webhook signature verification, issue #935, * shipped dark: nothing verifies a signature yet). This is a DIFFERENT secret * from the URL secret rotateIngestHook rotates: it is what a provider (GitHub, * Stripe, ...) HMACs the request body with. * * Omit `secret` to have the relay MINT one (`ihk_...`): the response includes * the value ONCE (paste it into the provider). Pass `secret` to store a * provider-generated value verbatim: the value is never echoed back, only the * fingerprint. A rotation keeps the previous secret valid for `graceSeconds` * (default 3600, max 86400) so deliveries verify while you update the provider. */ setIngestSigningSecret(appId: string, name: string, opts?: { secret?: string; graceSeconds?: number; }): Promise<{ secret?: string; fingerprint: string; setAt: string; }>; /** * DELETE /v1/apps/:id/ingest-hooks/:name/signing-secret clears a hook's * signing secret (current + previous + grace window). Idempotent. */ clearIngestSigningSecret(appId: string, name: string): Promise<{ ok: boolean; }>; /** * POST /v1/apps/:id/ingest-hooks/:name/backfill bulk-loads an array of raw * provider bodies through a hook's CURRENT manifest mapping, so a backfilled * row is byte-identical to a live delivery (issue #966). Each body is an * already-parsed JSON value (whatever the provider would have POSTed). It runs * the SAME receive pipeline as the public URL, so map/dedupeKey/upsertOn/schema * validation and the collection quota all apply, but it SKIPS the public-URL * brakes (per-IP rate limit, per-app hourly cap) and never verifies a signature * (owner/agent-authed). Wake is suppressed so a historical load never wakes a * dormant app. Dedupe is ON: re-running an interrupted backfill is idempotent * for a body-path dedupeKey (a header: dedupeKey cannot resolve with no * request headers, so it does not dedupe). Chunk arrays over the relay's * INGEST_BACKFILL_MAX_BODIES (default 500), which return 400. Returns aggregate * { accepted, dropped_duplicate, failed } counts plus a per-body `outcomes` * array in input order. */ backfillIngestHook(appId: string, name: string, bodies: unknown[]): Promise<{ accepted: number; dropped_duplicate: number; failed: number; outcomes: string[]; }>; /** * POST /v1/apps/:id/grants: mint a grant link. `role` must be a declared * custom role for the app. `mode` is "once" (one-time, first-browser-claims) * or "multi" (shared, capped by `maxUses` within expiry); defaults to "multi". * An optional `pin` NARROWS the holder to a single `rowKey` OR a `where` * filter (never widens). Returns the once-only `grant_url` carrying the token. */ mintAppGrant(appId: string, opts: { role: string; mode?: "once" | "multi"; maxUses?: number; label?: string; ttlSeconds?: number; pin?: { rowKey?: string; where?: ListWhereCondition[]; }; }): Promise; /** GET /v1/apps/:id/grants: list the app's grant links (never any token). */ listAppGrants(appId: string): Promise<{ grants: AppGrantSummary[]; }>; /** DELETE /v1/apps/:id/grants/:grantId: revoke one grant link (idempotent). */ revokeAppGrant(appId: string, grantId: string): Promise; /** * POST /v1/apps/:id/credentials: mint a service credential scoped to the * named collections and operations. `mode` defaults to "explicit" (an * unnamed collection is denied); "following" tracks the app as it grows, * with each `grants` entry narrowing one collection. `ttlSeconds: null` is * the explicit opt-in to NO EXPIRY; omit for the server's bounded default. * Returns the raw `token` once: only its hash is stored. */ mintAppCredential(appId: string, opts?: { mode?: "explicit" | "following"; grants?: ServiceCredentialGrant[]; members?: boolean; label?: string; ttlSeconds?: number | null; }): Promise; /** * GET /v1/apps/:id/credentials: list the app's service credentials with * their allowlist and status. Never any token material beyond the display * prefix, which is not an authenticator. */ listAppCredentials(appId: string): Promise<{ credentials: AppCredentialSummary[]; }>; /** * POST /v1/apps/:id/credentials/:credentialId/pause: reversible stop, in * force on the credential's very next request. Idempotent. */ pauseAppCredential(appId: string, credentialId: string): Promise; /** * POST /v1/apps/:id/credentials/:credentialId/resume: undo a pause. Never * undoes a revoke, which is permanent. Idempotent. */ resumeAppCredential(appId: string, credentialId: string): Promise; /** * POST /v1/apps/:id/credentials/:credentialId/rotate: issue a fresh token * and keep the superseded one resolving for `overlapSeconds` (default the * server's own default) so a running backend can pick up the new one * without a gap. Returns the new raw token once, exactly as mint does. */ rotateAppCredential(appId: string, credentialId: string, opts?: { overlapSeconds?: number; }): Promise; /** * DELETE /v1/apps/:id/credentials/:credentialId: revoke one credential * permanently. Also kills any superseded token still inside a rotation * overlap window. Idempotent. */ revokeAppCredential(appId: string, credentialId: string): Promise; /** * POST /v1/apps/:id/connections: create a connection. `kind` defaults to * "static" (name + allowedHost + headerName + headerValue). For * `kind: "oauth2"` the caller supplies the WHOLE provider config * (authorizeUrl, tokenEndpoint, clientId, clientSecret, allowedHost, and * the optional scopes/authScheme/instanceField/authParams/tokenParams); * the row starts in `pending_auth` until the signed-in OWNER completes * consent in a browser at `connectionAuthorizeUrl` (an agent key cannot * complete OAuth consent). Every stored secret is encrypted at rest and * never returned by any call. */ createConnection(appId: string, body: { name: string; kind?: "static" | "oauth2"; provider?: string; label?: string; allowedHost: string; headerName?: string; headerValue?: string; authorizeUrl?: string; tokenEndpoint?: string; clientId?: string; clientSecret?: string; scopes?: string; authScheme?: string; instanceField?: string; authParams?: Record; tokenParams?: Record; }): Promise<{ connection: ConnectionSummary; }>; /** GET /v1/apps/:id/connections: list metadata only, never the secret. */ listConnections(appId: string): Promise<{ connections: ConnectionSummary[]; }>; /** DELETE /v1/apps/:id/connections/:name: idempotent. */ deleteConnection(appId: string, name: string): Promise; /** * GET /v1/apps/:id/webhooks/deliveries: the outbound delivery journal, newest * first. This is a ROLLING window (the retention sweeper prunes terminal rows * past WEBHOOKS_DELIVERY_RETENTION_DAYS), not the app's whole history. * * `payload` is the request body as it was rendered and sent, truncated * server-side; `payloadTruncated` says whether it was cut. Together with * `responseStatus`/`responseBody` that is what answers "why did the target * reject this", which is the reason to reach for this call at all. */ listWebhookDeliveries(appId: string, opts?: { status?: string; collection?: string; limit?: number; }): Promise<{ deliveries: Record[]; }>; /** * POST /v1/apps/:id/webhooks/deliveries/:deliveryId/replay: re-send a stored * delivery's own rule/url/payload as a fresh row, now, without waiting out * its remaining backoff. Nothing caller-supplied enters the new row. * * NOT idempotent in effect: the target receives the same request again, so a * target without its own dedupe ends up with a duplicate record. */ replayWebhookDelivery(appId: string, deliveryId: string): Promise<{ delivery: Record; }>; /** * The browser URL that completes an oauth2 connection's consent. Build * only, never fetch this yourself: it 302s the caller to the third-party * provider, and the relay refuses an agent-key caller here (consent is * inherently a human-in-a-browser step). Hand the URL to the signed-in * owner to open. */ connectionAuthorizeUrl(appId: string, name: string): string; /** * GET /v1/apps/:id/collections/:name — list rows (current-state page). * * Wave C2 structured read query: pass `where` (an AND of `{field, op, value}` * conditions) and/or `sort` (`{field, dir}` list) to filter/order DB-side. The * relay applies read permission + author scoping FIRST, then the filter, so a * filtered read is always a subset of what the caller could already read. The * query is serialized into the `q` param as URL-encoded JSON. Cursor * pagination (`since`) works with a custom `sort` too (issue #1057): pass * back the same `sort` a page's `next_cursor` came from, since a cursor is * only valid for the exact sort it was issued under. */ listAppRows(appId: string, collection: string, opts?: { since?: string; limit?: number; where?: ListWhereCondition[]; sort?: ListSortSpec[]; }): Promise; /** * GET /v1/apps/:id/collections/:name/count: live row count (spec B4, * issue #1056), the same aggregate the browser `/_hs/count/:name` door and * the `count` MCP tool / CLI verb call through. Gated by the collection's * `countRead` opt-in, INDEPENDENT of `read`: a caller who cannot list the * rows can still get the count when the manifest opted the collection in, * and a collection that never declared `countRead` refuses with * `collection_count_forbidden` even for a caller who could otherwise list. * No filter and no paging: it is a whole-scope total. */ countAppRows(appId: string, collection: string): Promise<{ count: number; }>; /** * GET /v1/apps/:id/collections/:name/:key — read one row. A dedicated * route (not a client-side scan like v1's `getRecord`) — spec-cli §8 * ruling 3 confirms it as a first-class relay route. */ getAppRow(appId: string, collection: string, key: string): Promise<{ row: AppRow; }>; /** * POST /v1/apps/:id/collections/:name — upsert (create, or return the * existing row when `key` collides — `deduped: true`). §8 ruling 4: this is * the ONLY create-shaped verb — there is no strict create that errors on * an existing key. * * A collision on a row the collection's `read` list does not reach for this * caller answers `row_not_found` (404) rather than the row, exactly as a get * on that key would, so the create door never returns a body the read door * would have withheld. */ upsertAppRow(appId: string, collection: string, body: { key?: string; data: unknown; on?: string; }): Promise<{ row: AppRow; deduped?: true; }>; /** * POST /v1/apps/:id/collections/:name with `{ on, data }`, natural-key upsert * (Wave C1). Matches the existing row whose `` value equals data[field] * and updates it in place (idempotent re-import), else creates a fresh row. * `field` must be declared in the collection's manifest `unique` list. */ upsertAppRowOn(appId: string, collection: string, field: string, data: unknown): Promise<{ row: AppRow; deduped?: true; }>; /** * DELETE /v1/apps/:id/collections/:name/:key/purge, owner/agent-only removal * that bypasses an append-only collection (Wave C1). Frees the row's unique * values and writes an audited delete feed entry. A missing/already-deleted key * is a 404 (HomespunApiError `row_not_found`), matching a normal delete. */ purgeAppRow(appId: string, collection: string, key: string): Promise; /** * POST /v1/apps/:id/collections/:name/:key/restore: undo a soft delete. * * Owner/agent only, independent of the collection's permission lists. A row * that was PURGED is not restorable (410 `restore_expired`), and a restore * can legitimately fail on quota or on a unique value another live row took * while this one was deleted (409 `restore_conflict`). */ restoreAppRow(appId: string, collection: string, key: string): Promise<{ row: AppRow; }>; /** * GET /v1/apps/:id/collections/:name/deleted: the recovery bin, newest * deletion first. Owner/agent only. Page with `before`, passing back the * previous page's `next_before`. */ listDeletedAppRows(appId: string, collection: string, opts?: { limit?: number; before?: string; }): Promise; /** PATCH /v1/apps/:id/collections/:name/:key — optimistic-locked update. */ updateAppRow(appId: string, collection: string, key: string, body: { data: unknown; if_match?: number; }): Promise<{ row: AppRow; }>; /** DELETE /v1/apps/:id/collections/:name/:key — soft-delete. */ deleteAppRow(appId: string, collection: string, key: string, opts?: { ifMatch?: number; }): Promise; /** * POST /v1/apps/:id/collections/:name/batch: bulk create/upsert (Wave B). * Returns a per-row result array: a single invalid row is reported by index * without aborting the good rows. A batch over the server's BATCH_MAX_ROWS cap * is a clean 400 (throws HomespunApiError `invalid_request`). DEFAULTS TO * SILENT (suppresses notify + webhooks): pass `{emitEffects:true}` or * `{suppress:[]}` to fire effects. Owner/agent key only for suppression control. */ batchRows(appId: string, collection: string, rows: BatchRowInput[], opts?: BatchWriteOptions): Promise; /** * DELETE /v1/apps/:id/collections/:name/batch: bulk soft-delete (Wave B). * Same per-row result shape + cap as `batchRows`. */ deleteRows(appId: string, collection: string, keys: string[]): Promise; /** * GET /v1/apps/:id/feed — change-feed catch-up; the long-poll fallback * transport for `watch` (spec-cli §2.3/§5). `wait` (0-30s) long-polls when * the caller is already caught up. */ getAppFeed(appId: string, opts: { since: number; limit?: number; wait?: number; }): Promise; /** POST /v1/community/publish: publish an owned app as a pending template. */ publishCommunityTemplate(req: PublishCommunityTemplateRequest): Promise; /** GET /v1/community/pending (operator): list pending submissions. */ listCommunitySubmissions(opts?: { limit?: number; cursor?: string; }): Promise; /** GET /v1/community/submissions/:id (operator): a submission's full content. */ getCommunitySubmission(snapshotId: string): Promise; /** * POST /v1/community/submissions/:id/{approve,reject} (operator): review a * submission. `decision` "reject" requires a `note` (delivered to the * publisher's app feed). */ reviewCommunitySubmission(snapshotId: string, review: { decision: "approve" | "reject"; note?: string; }): Promise; /** * POST /v1/community/submissions/:id/unpublish (publisher): take your own * live listing down. It leaves the public gallery, search, and the direct * snapshot install link; existing installs are untouched, because an install * is a fresh private copy rather than a live reference. Idempotent, and 404 * when the snapshot does not exist OR is not yours (indistinguishable on * purpose). Republish a new version to undo it. */ unpublishCommunityTemplate(snapshotId: string): Promise; /** * GET the install-time config contract for an installable template (its * settings collection + the ordered keyed config/upload steps). Use it to * discover what a template needs and to pre-upload files for `upload` steps * (POST /v1/attachments) before install. */ getCommunityConfigContract(ref: string): Promise; /** * POST to install a community template for the calling agent's owning human. * `config` is `{ [stepKey]: value }` where a `config` step's value is a string * and an `upload` step's value is an attachment id the agent pre-uploaded * (agent-scoped) via `uploadBlob()` / `uploadBlobInline()`; the relay * re-points it to the new app on install. Installs always create; returns the * new app's id, slug, and url. */ installCommunityTemplate(ref: string, config?: Record): Promise; /** * GET /v1/apps/:id/upgrade: is a newer version of this app's source template * available, would it apply cleanly, and what would it newly be allowed to * reach. No side effects; the verdict is computed by the same validation the * apply path re-runs. */ checkTemplateUpgrade(appId: string): Promise; /** * POST /v1/apps/:id/upgrade: apply it. * * `acceptPermissions` is REQUIRED when the preview reports a non-empty * permission diff, and is deliberately not defaulted: an agent must state * that its owner accepted the widening rather than have the absence of a * field satisfy the gate. `expectVersion` refuses if the offer moved since * the preview, so an agent never applies a version it did not describe. * * Refuses outright, with no override, when the new version would strand rows * the app already holds. */ upgradeTemplate(appId: string, opts?: { acceptPermissions?: boolean; expectVersion?: string; }): Promise; /** * POST /v1/apps/:id/upgrade/revert: put back the version the app ran before * its last template upgrade. Refuses when rows written since would have * nowhere to live under the older version. */ revertTemplateUpgrade(appId: string): Promise; /** GET /v1/publisher: the caller's own publisher profile. */ getPublisher(): Promise; /** POST /v1/publisher/claim: set the handle once (permanent after claiming). */ claimPublisherHandle(handle: string): Promise; /** POST /v1/publisher/update: update displayName/bio/url. */ updatePublisher(update: PublisherProfileUpdate): Promise; /** * POST /v1/community/publishers/:handle/trust (operator, marketplace PR 11): * set a publisher's trust level ("new" | "established"), the MVP promotion * path for the review fast-track. Operator-gated server-side; returns the * updated publisher profile. */ setPublisherTrustLevel(handle: string, trustLevel: "new" | "established"): Promise; /** * POST /v1/reviews: review a template the caller installed. Identify the * template by its namespaced `/` OR by explicit handle + slug. */ createReview(req: CreateCommunityReviewRequest): Promise; /** POST /v1/reviews/:id/respond: respond to a review as the publisher. */ respondToReview(reviewId: string, response: string | null): Promise; /** POST /v1/reviews/:id/report: flag a review for operator attention. */ reportReview(reviewId: string, reason: string): Promise; /** POST /v1/reviews/:id/remove (operator): take a review down. */ removeReview(reviewId: string): Promise; /** POST /v1/reviews/:id/unhold (operator): publish a held review. */ unholdReview(reviewId: string): Promise; } /** * PUT raw bytes to a presigned upload URL returned by `presignBlob()`. * Deliberately a bare function, not a `HomespunClient` method: the URL is * ALREADY pre-authorized (a SAS query string on Azure, scoped to this one * key), so sending `Authorization: Bearer ` alongside it would hand * the agent's relay credential to a storage host that never asked for it and * has no use for it. Routing this through `this.call` / `this.fetchImpl` * would attach that header unconditionally, so this stays outside the * authenticated client machinery on purpose. * * Azure Blob's single-shot PUT additionally requires the * `x-ms-blob-type: BlockBlob` header; every presign backend today is Azure * SAS, and a PUT without it 400s with `MissingRequiredHeader`, so it is * always sent. There is no relay error envelope to parse on failure here: * this response is storage's own, so a non-2xx throws a plain `Error` * carrying the status and whatever body storage returned. */ export declare function putPresigned(uploadUrl: string, bytes: Uint8Array | Buffer, mime: string, fetchImpl?: typeof fetch): Promise; /** * One multi-file-deploy asset carrying its bytes inline as base64. The deployed * page references it by a stable, app-relative, same-origin path (e.g. * `frames/000.jpg`). `content_base64` is the standard base64 of the raw bytes; * `mime` is advisory (the relay sniffs the real type from the leading bytes). */ export interface AppAssetInline { path: string; content_base64: string; mime?: string; } /** * One multi-file-deploy asset referencing an ALREADY-uploaded attachment (via * `attachments fetch` or presign + finalize) instead of carrying its bytes. The * referenced attachment must be owned by the deploying agent, app-scoped to THIS * app, and `ready`. Lets an image reach the app with zero base64 in the deploy * body (and, with fetch, zero bytes in the model context at all). */ export interface AppAssetRef { path: string; attachment_id: string; } /** A multi-file-deploy asset: inline bytes OR a by-reference attachment id. */ export type AppAsset = AppAssetInline | AppAssetRef; /** Request body for `POST /v1/apps` — create (deploy) a new App. */ export interface DeployAppRequest { html: string; manifest: unknown; /** Defaults to "private" server-side when omitted. */ visibility?: "private" | "link" | "public"; /** Accepted for visibility public|private (including the omitted default); rejected for link. */ slug?: string; /** * Optional asset bundle shipped alongside the HTML in ONE deploy. Each asset * is validated + stored app-scoped exactly like an attachment and served at * its `path` on the app's own origin, so `` just * works. Rejected atomically if any asset fails validation. */ assets?: AppAsset[]; } export interface DeployAppResponse { app_id: string; slug: string; visibility: string; url: string; version: number; created: true; /** * The tokenized share URL, present ONLY when the app was created as `link`. Its * `#k=` fragment carries the raw share token and is shown exactly ONCE (the * token is hashed at rest and never recoverable). Anyone with this exact URL * can open the app until it is rotated (`rotateShareLink`). */ share_url?: string; /** * Non-fatal deploy advisories, present only when there is at least one. Today * the schedules-without-timezone case: an app that declares `schedules` but has * no timezone set will fire reminders at 08:00 UTC until one is set. Relay these * to the human. */ warnings?: string[]; } /** * Request body for `POST /v1/apps/:id/versions`: redeploy (compat-gated). * * Every content field is optional, and an OMITTED one keeps what is live: the * relay carries the current version's html / manifest / assets forward. Send * only what changes. At least one of the three is required; a body that omits * all three is refused as `invalid_args` rather than minting a no-op version. */ export interface RedeployAppRequest { /** Omit to keep the live document (a manifest-only change needs no HTML). */ html?: string; /** Omit to keep the live manifest (an HTML-only change needs no manifest). */ manifest?: unknown; /** Bypass the compat gate; a removed collection is detached, not deleted. */ force?: boolean; /** * Optional asset bundle for this version. Present, it replaces the previous * version's asset set atomically (the new version carries its own map; old * assets are detached). Omitted, the live asset set is carried forward; `[]` * is the explicit "clear the assets". */ assets?: AppAsset[]; } export interface RedeployAppResponse { app_id: string; version: number; compat: "clean" | "forced"; breaks?: Array<{ path: string; message: string; }>; /** * Non-fatal deploy advisories, present only when there is at least one; see * DeployAppResponse.warnings. On redeploy the schedules-without-timezone * warning fires only when the app STILL has no timezone set. */ warnings?: string[]; } /** * Result of a DRY-RUN deploy (`checkDeploy`): the deploy validation + compat * gate run WITHOUT creating a version or mutating anything. Mirrors what a real * deploy would report: the manifest is validated (a structurally invalid * manifest throws the SAME error a real deploy would), asset shapes are checked, * the schedule-timezone / migration-mode advisories are computed, and (for a * redeploy) the compat gate runs against the current version. * * - `ok` is true when a REAL deploy with the same inputs would succeed: a * clean deploy, or a gated redeploy with `force: true`. It is false when a * real un-forced redeploy would be REJECTED, either for stranding rows * already written or for widening what the app's install screen discloses * (see `compat: "incompatible"` + `breaks`). * - `compat` is present only for a redeploy check: "clean" (nothing gated), * "forced" (gated, but force was set so the real deploy would proceed), or * "incompatible" (gated and force was NOT set, so the real deploy would * return manifest_incompatible_redeploy). * - `breaks` lists what the gate found, present only when there is at least * one. */ export interface DeployCheckResult { ok: boolean; warnings: string[]; compat?: "clean" | "forced" | "incompatible"; breaks?: Array<{ path: string; message: string; }>; } /** * Request for a dry-run deploy check. Omit `app_id` to check a CREATE; pass it * to check a REDEPLOY of an existing app (the compat gate then runs against its * current version, and `force` decides whether a narrowing would be accepted). */ export interface DeployCheckRequest { app_id?: string; /** Required to check a create; omit on a redeploy check to inherit the live document. */ html?: string; /** Required to check a create; omit on a redeploy check to inherit the live manifest. */ manifest?: unknown; force?: boolean; assets?: AppAsset[]; } /** * Result of minting a sibling API key for the calling agent's own identity * (`mintKey`). The raw `api_key` is returned exactly ONCE here and is never * retrievable again (only its hash is stored). The new key has the same * scope/ownership as the caller and can be revoked by the owner via the normal * revoke path. */ export interface KeyMintResult { agent_id: string; /** The raw sibling key, shown ONCE, never returned again. */ api_key: string; key_prefix: string; name: string | null; created_at: string; } /** Lean per-app summary — the shape `listApps` returns. */ export interface AppSummary { id: string; slug: string; visibility: string; status: string; url: string; /** * Whether the app currently has an active share token (a `link` app with a * live, revocable share link). NEVER the token itself: the raw token is * returned only at create/rotate. False for public/private apps. */ has_share_token: boolean; created_at: string; last_activity_at: string; /** * When the app was soft-deleted. PRESENT ONLY on a deleted app, so its * absence is a reliable "this app is live" test. A deleted app keeps all of * its data and is restorable with `restoreApp` until `purges_at`. */ deleted_at?: string; /** * When the app becomes eligible to be purged forever, or `null` meaning * never. Present alongside `deleted_at`, so a null here is unambiguously * "never purged" rather than "not deleted". * * An eligibility boundary, not an exact moment: the relay's sweeper runs on * an interval, so the real purge lands at or after this. Word it as "purges * after", never "purges at". */ purges_at?: string | null; } /** Full app detail — the shape `getApp` returns. */ export interface AppDetail extends AppSummary { manifest: unknown; current_version: number | null; owner_human_id: string; row_count: number; storage_bytes: string; /** * The app's IANA timezone for `schedules` reminders, or null when unset. Set * it with `updateApp(id, { timezone })`; an app that declares schedules with no * timezone fires reminders at 08:00 UTC. */ timezone: string | null; } export interface AppsPage { items: AppSummary[]; next_cursor: string | null; } /** One security finding against a collection's declared permission shape. */ export interface CollectionAdvisory { /** Stable identifier, safe to filter and count on. */ code: string; collection: string; /** * `high` = exploitable by an anonymous caller today, with no precondition * beyond opening the app. `medium` = a real weakness that needs something * else to go wrong first. `low` = a design smell, frequently intentional. */ severity: "high" | "medium" | "low"; message: string; } /** One app's findings, from `GET /v1/apps/advisories`. */ export interface AppAdvisories { app_id: string; slug: string; visibility: "private" | "link" | "public"; status: string; current_version: number; highest_severity: "high" | "medium" | "low"; advisories: CollectionAdvisory[]; } /** `GET /v1/apps/advisories`: the owner security audit across every owned app. */ export interface AppAdvisoriesReport { /** Live apps considered, including those with nothing to report. */ apps_scanned: number; apps_affected: number; /** Counts across the WHOLE audit, unaffected by any `severity` filter. */ counts: { high: number; medium: number; low: number; }; /** True when a cap stopped the walk before the owner's apps ran out. */ truncated: boolean; items: AppAdvisories[]; } /** `GET /v1/apps/:id/document`: an app's authored HTML source (current version). */ export interface AppDocument { /** The authored HTML, exactly as deployed (no served-page prelude injected). */ html: string; /** The current version number these bytes belong to. */ version: number; /** Content hash of `html` (the app's `sourceHash`). */ source_hash: string; } /** One DNS record the domain owner must publish (custom domains). */ export interface AppDomainDnsRecord { type: string; name: string; value: string; purpose: "routing" | "ownership" | "certificate_validation"; } /** The app's custom-domain record - the shape the /domain routes return. */ export interface AppDomain { domain: string; status: "pending" | "active" | "error" | "removed"; dns_records: AppDomainDnsRecord[]; cf_hostname_id: string | null; cf_worker_route_id?: string | null; /** Null on the serving domain; on an alias, the domain it redirects to. */ redirect_to?: string | null; last_error: string | null; last_checked_at: string | null; created_at: string; updated_at: string; /** * Present on the GET response only: the app's other domains, each of which * redirects to the one carried at the top level. */ aliases?: AppDomain[]; } /** * One member row — the shape `listAppMembers` returns and the `member` half * of `addAppMember`'s union returns. Fields are camelCase on the wire (see * the app-membership section above for why). */ export interface AppMember { humanId: string; email: string; role: string; /** * The DECLARED roles attached alongside base member powers. A member may hold * several at once and holds the union of what each grants, plus everything * those roles `includes`. */ customRoles?: string[]; /** * The first of `customRoles`, or null. The single-role shape this API * reported before a member could hold several; read `customRoles`. */ customRole?: string | null; createdAt: string; } /** * One inbound catch-hook row from `listIngestHooks`. `url` is the full secret * ingest URL to hand a sender. Rule-derived fields (collection/mode/wake/ * handshake) are null when the hook's rule has left the manifest (disabledAt * set). `deliveries` are per-status counts of the hook's inbound journal. */ /** One retention axis pair, either bound null when unset. */ export interface RetentionAxes { maxRows: number | null; maxAgeDays: number | null; } /** * A collection's retention picture (issue #956): the EFFECTIVE bounds in force * (per-axis `override ?? authorDefault`), the AUTHOR default declared in the * manifest, the raw owner OVERRIDE, and `wouldPrune`, how many live rows the * effective bounds would prune on the next sweep. */ export interface CollectionRetentionView { effective: RetentionAxes; authorDefault: RetentionAxes; override: RetentionAxes; wouldPrune: number; } export interface IngestHookInfo { name: string; url: string; collection: string | null; mode: string | null; wake: boolean | null; handshake: string | null; disabledAt: string | null; createdAt: string; /** * Opt-in signature verification state (issue #935), shipped dark. `configured` * is true when a signing secret is set; `fingerprint` identifies WHICH one * (plaintext-derived, matches the value in the provider's settings) without * ever exposing it; `previousUntil` is set during a rotation's grace window. */ signingSecret: { configured: boolean; fingerprint: string | null; setAt: string | null; previousUntil: string | null; }; /** Placeholder for the D3 manifest `verify` grammar; always null for now. */ verify: null; deliveries: { accepted: number; failed: number; dropped_duplicate: number; }; } /** * Effective per-verb access from `listAppRoles`: "all" = every row, "own" = * only rows the holder authored (the `:own` / author narrowing), "none" = * nothing. `create` is never "own" (no pre-existing row to be the author of). * The relay computes these by probing its real enforcement functions, so they * report what a holder can ACTUALLY do, including floors like `anyone`. */ export interface AppRoleVerbAccess { read: "all" | "own" | "none"; create: "all" | "none"; update: "all" | "own" | "none"; delete: "all" | "own" | "none"; } /** * One collection's access from `listAppRoles`, reported PER POPULATION: * `member_access` is what a signed-in member holding the role can do (their * principal also carries the member floor), `grant_access` what a grant-link * holder of the same role can do (no member floor, so the two can differ). * `append_only` is context: such a collection refuses update/delete for every * role while still allowing create, and the access tables reflect that. */ export interface AppRoleCollectionAccess { name: string; member_access: AppRoleVerbAccess; grant_access: AppRoleVerbAccess; append_only: boolean; } /** * One declared custom role from `listAppRoles`. `member_count` and * `active_grant_count` are separate numbers, never summed: members are * signed-in Humans, grant-link holders are anonymous per-holder identities. */ export interface AppRoleSummary { name: string; label: string; description: string | null; collections: AppRoleCollectionAccess[]; member_count: number; active_grant_count: number; } /** * `POST /v1/apps/:id/members` response — either the member was attached * immediately (existing Human) or an invite email was sent (no Human yet). */ export type AddAppMemberResult = { member: AppMember; } | { ok: true; invited: string; expires_at: string; }; /** `POST /v1/apps/:id/grants` response. `grant_url` carries the raw link token * in its #g= fragment and is shown ONCE (it is never recoverable afterward). */ export interface MintAppGrantResult { id: string; grant_url: string; role: string; mode: string; max_uses: number | null; expires_at: string; } /** One grant-link row from `listAppGrants` (never carries any token material). */ export interface AppGrantSummary { id: string; role: string; mode: string; max_uses: number | null; use_count: number; claim_count: number; pin_row_key: string | null; pin_where: unknown; label: string | null; active: boolean; expires_at: string; revoked_at: string | null; created_at: string; } /** One collection entry in a service credential's allowlist. */ export interface ServiceCredentialGrant { collection: string; ops: ("read" | "create" | "update" | "delete")[]; /** "own" narrows every row-addressed op to rows this credential wrote last. */ scope?: "own"; } /** `POST /v1/apps/:id/credentials` response. `token` is shown ONCE. */ export interface MintAppCredentialResult { id: string; token: string; token_prefix: string; mode: string; grants: ServiceCredentialGrant[]; members: boolean; label: string | null; expires_at: string | null; /** Present + true iff minting cleared the app's egress waiver (#1328). */ unrestricted_revoked?: true; unrestricted_notice?: string; } /** One service credential row from `listAppCredentials` (never any token). */ export interface AppCredentialSummary { id: string; label: string | null; token_prefix: string; mode: string; grants: ServiceCredentialGrant[]; members: boolean; paused: boolean; active: boolean; expires_at: string | null; paused_at: string | null; revoked_at: string | null; rotated_at: string | null; previous_token_prefix: string | null; previous_expires_at: string | null; created_at: string; last_used_at: string | null; } /** `POST /v1/apps/:id/credentials/:credentialId/rotate` response. */ export interface RotateAppCredentialResult { id: string; token: string; token_prefix: string; /** The instant the SUPERSEDED token stops resolving. Both work until then. */ previous_expires_at: string; } /** One connection row (never any secret, metadata plus a fingerprint). */ export interface ConnectionSummary { id: string; name: string; kind: string; provider: string | null; label: string | null; allowedHost: string | null; headerName: string | null; status: string; authScheme: string | null; instanceBaseUrl: string | null; scopes: string | null; expiresAt: string | null; secretFingerprint: string | null; createdAt: string; } /** One row in an app collection — the shape every row CRUD op returns. */ export interface AppRow { key: string; data: unknown; version: number; author: { kind: string; id: string; }; created_at: string; updated_at: string; deleted_at: string | null; } /** One tombstoned row as the recovery bin reports it. */ export interface DeletedAppRow { key: string; data: unknown; version: number; /** Who removed it. */ author: { kind: string; id: string; }; /** Who created it; null on rows predating the creator columns. */ creator: { kind: string; id: string; } | null; created_at: string; deleted_at: string; /** When this row stops being recoverable. */ recoverable_until: string; /** True once purged: contents already scrubbed, and restore refuses it. */ purged: boolean; } export interface DeletedAppRowsPage { rows: DeletedAppRow[]; next_before: string | null; } export interface AppRowsPage { rows: AppRow[]; next_cursor: string | null; has_more: boolean; } /** A scalar operand for a structured read-query condition (Wave C2). */ export type ListScalar = string | number | boolean; /** * One structured read-query condition (Wave C2). `op` is one of eq / neq / in / * notIn / gt / lt / gte / lte, with the SAME type semantics as a notify `when`: * comparisons are same-type only (a number never matches a string operand), and * dates are compared as ISO-8601 strings. `value` is a single scalar for the * scalar ops and a non-empty scalar array for `in`/`notIn`. */ export interface ListWhereCondition { field: string; op: "eq" | "neq" | "in" | "notIn" | "gt" | "lt" | "gte" | "lte"; value: ListScalar | ListScalar[]; } /** One structured read-query sort key (Wave C2). `dir` defaults to "asc". */ export interface ListSortSpec { field: string; dir?: "asc" | "desc"; } /** * Path for a community-template ref action. A namespaced `/` ref * becomes a TWO-segment path (`/templates///`) so it * survives a proxy/edge that normalizes a percent-encoded slash to `/` before * routing (#921); a snapshot id (no slash) stays single-segment. Mirrors the * server's `parseNamespacedId`: exactly two non-empty slash-separated parts. */ export declare function communityTemplatePath(ref: string, suffix: string): string; /** One row of a batch write (Wave B). `key` absent => create; present => upsert. */ export interface BatchRowInput { key?: string; data: unknown; } /** * The outcome of one row in a batch, addressed by its `index` in the input array * so a caller maps a failure back to the exact row it sent. `ok:true` carries the * written `key`; `ok:false` carries the per-row `error`. */ export interface BatchRowResult { index: number; ok: boolean; key?: string; error?: { code: string; message: string; status: number; details?: unknown; }; } /** The batch write / delete envelope: per-row results plus convenience counts. */ export interface BatchResult { results: BatchRowResult[]; ok_count: number; error_count: number; } /** * Effect-suppression options for a batch (silent migration, Wave B). The batch * endpoint DEFAULTS to silent (suppresses both notify and webhooks). Pass * `emitEffects:true` (or `suppress:[]`) to fire effects, or `suppress` to mute a * specific subset. Honored only for an owner/agent key. */ export interface BatchWriteOptions { suppress?: ("notify" | "webhooks" | "agentTasks")[]; emitEffects?: boolean; /** * Wave C1 natural-key upsert: match (or create) each row on this * declared-unique field's value instead of the row id, making a re-import * idempotent. Mutually exclusive with per-row `key`. */ on?: string; } /** * One entry in an app's change feed — the SAME shape whether it arrives via * `GET /v1/apps/:id/feed` (long-poll) or the `/_hs/ws` live/batch frames * (openAppStream, app-stream.ts) — the CLI's `apps watch` prints this object * unchanged regardless of which transport served it (spec-cli §3.4/§5). */ export interface AppFeedEntry { seq: number; op: string; collection_name: string; row_key: string; row_version?: number | null; data: unknown; author: { kind: string; id: string; }; ts: string; } export interface AppFeedPage { entries: AppFeedEntry[]; cursor: number; truncated: boolean; } /** Request body for `publishCommunityTemplate()`. */ /** * One typed agent-setup step (marketplace PR 9). An ordered list of these tells * an installing agent what to configure after install. Carries only the * publisher's own defaults + hints, never an installer's value; `secret: true` * marks a step whose eventual value is sensitive (its default is masked on the * public detail page). The relay validates + normalizes on publish. */ export interface CommunitySetupStep { kind: "config" | "seed-data" | "connect" | "note" | "upload"; label: string; description?: string; required?: boolean; secret?: boolean; default?: string; choices?: string[]; valueHint?: string; /** * The settings-collection field this step's answer is written into * (install-config programme). Required for an `upload` step, optional for a * `config` step, and not allowed on the other kinds. Field-key identifier * (letters, digits, '_', up to 64 chars). */ key?: string; /** * The manifest `ingest` rule this step wires up (D7). Allowed ONLY on a * `connect` step, optional there. Publish rejects a name the manifest does * not declare. After install, the installer pastes that rule's freshly * provisioned hook URL into the external service. */ ingestRule?: string; } /** * One keyed config/upload step in a template's install-time config contract * (install-config programme, PR 4). Field names are the wire shape (snake_case * `value_hint`). An `upload` step's answer is an attachment id string. */ export interface CommunityConfigStep { key: string; kind: "config" | "upload"; label: string; description?: string; required: boolean; secret: boolean; choices?: string[]; default?: string; value_hint?: string; } /** * One `connect` step in a template's contract that wires up an INBOUND hook: * after install, the app's own freshly provisioned hook URL for `ingest_rule` * has to be pasted into the external service the step describes. Read the URLs * from `GET /v1/apps/:app_id/ingest-hooks` once the install returns. */ export interface CommunityConnectStep { label: string; description?: string; ingest_rule: string; /** Collection the hook writes into, or null when the rule went missing. */ collection: string | null; mode: "append" | "upsert" | null; } /** The install-time config contract for an installable community template. */ export interface CommunityConfigContract { snapshot_id: string; name: string; slug: string | null; /** The manifest's declared settings collection, or null when none. */ settings_collection: string | null; /** Ordered keyed config/upload steps; empty when the template needs none. */ config_steps: CommunityConfigStep[]; /** Ordered connect steps that name an ingest rule; empty when none. */ connect_steps: CommunityConnectStep[]; } /** Result of an agent-key community install (installs always create). */ export interface CommunityInstallResult { app_id: string; slug: string; url: string; } /** One narrowing the compat gate reports, in the manifest author's words. */ export interface TemplateUpgradeBreak { path: string; message: string; } /** Everything a caller needs to decide whether to apply a template update. */ export interface TemplateUpgradePreview { /** Is a newer live version of this app's template line available at all? */ available: boolean; from_version: string | null; to_version: string | null; changelog: unknown; snapshot_id: string | null; compat: "clean" | "forced" | "incompatible" | null; /** * Breaks that BLOCK the update: it would strand rows the app already holds. * No acceptance clears these. */ breaks: TemplateUpgradeBreak[]; /** * Breaks saying the new version discloses more than the installed one. These * are what `accept_permissions` clears, and the human-readable peer is * `permission_lines`. */ disclosure_breaks: TemplateUpgradeBreak[]; permissions: { external_hosts: string[]; embeds: string[]; capabilities: string[]; cdn: boolean; offline: boolean; webhook_rules: number; is_empty: boolean; }; /** One plain sentence per widening, for showing a person before accepting. */ permission_lines: string[]; blocked: boolean; blocked_reason: "incompatible" | null; revert_available: boolean; } export interface TemplateUpgradeResult { from_version: string | null; to_version: string; snapshot_id: string; /** The version now available to revert to. */ previous_version_id: string; } export interface TemplateRevertResult { version_id: string; template_version: string | null; } export interface PublishCommunityTemplateRequest { /** The owned app to publish. */ appId: string; /** Listing title; defaults server-side to the app's manifest name. */ title?: string; /** Listing blurb; defaults server-side to the manifest description. */ description?: string; /** * Optional long-form description (template-experience PR 5a): richer prose * rendered on the detail page below the short blurb. Plain text, length-capped * server-side; blank lines become escaped paragraph breaks (never raw HTML). */ longDescription?: string; /** Optional category (validated server-side against the fixed enum). */ category?: string; /** Optional curation tags. */ tags?: string[]; /** * Optional per-publisher slug (marketplace PR 2). When set, the template gets * a namespaced id `/` and joins a versioned line. */ slug?: string; /** Optional semver version; defaults server-side to "1.0.0". */ version?: string; /** Optional note recorded in this version's changelog entry. */ changelogNote?: string; /** * Optional ordered typed setup steps (marketplace PR 9). Validated + stored * server-side; read back through the detail page + get_submission. */ setupSteps?: CommunitySetupStep[]; /** Optional remix/fork lineage: the snapshot id this was derived from. */ derivedFromSnapshotId?: string; /** * The example-only attestation (marketplace PR 10). Set true to attest the * captured template content AND its captured seed rows contain no real * personal data: publishing makes both PUBLIC to all platform users. Recorded * (not a hard gate) and surfaced to the operator review payload; omitting it * still publishes but is shown to the reviewer as "not attested". */ attestExampleOnly?: boolean; } export interface PublishCommunityTemplateResponse { snapshot_id: string; review_status: "pending" | "approved" | "rejected"; name: string; seeded_row_count: number; slug: string | null; version: string; /** How many typed setup steps were stored (marketplace PR 9). */ setup_step_count: number; /** * The example-only attestation as recorded (marketplace PR 10): true/false as * attested, or null when the publish carried no attestation. */ attest_example_only: boolean | null; /** * Fast-track outcome (marketplace PR 11). `expedited`: the publisher is * `established` and the submission still landed `pending`, flagged to sort * first in the operator review queue. `auto_approved`: the publisher is * `established` AND the relay enables true auto-approve, so it was approved * immediately at publish (review_status === "approved"). Both false for a * `new` publisher (ordinary full review). */ expedited: boolean; auto_approved: boolean; } /** A pending-submission summary in the operator review queue. */ export interface CommunitySubmissionSummary { snapshot_id: string; name: string; description: string | null; /** * Publisher-provided long-form description (template-experience PR 5a), * surfaced in the review payload so the operator sees the full prose that * renders on the detail page. Null when the publish carried none. */ long_description: string | null; category: string | null; tags: string[]; review_status: "pending" | "approved" | "rejected"; publisher_human_id: string | null; publisher_name: string | null; source_app_id: string | null; seeded_row_count: number; published_at: string; /** Namespaced identity + version (marketplace PR 2); null when unset. */ slug: string | null; version: string | null; /** * Publish-time PII attestation (marketplace PR 10): true/false as attested by * the publisher, or null when the publish carried no attestation. Surfaced in * the review queue so a missing attestation is visible to the operator. */ attest_example_only: boolean | null; /** * Fast-track flag (marketplace PR 11): true when this pending submission was * expedited (published by an `established` publisher, sorted first in the * queue). False for a `new` publisher and pre-PR-11 rows. */ expedited: boolean; /** The publisher's trust level (marketplace PR 11): "new" | "established". */ publisher_trust_level: string; } export interface CommunitySubmissionsPage { items: CommunitySubmissionSummary[]; next_cursor: string | null; } /** One collection's captured-seed footprint (marketplace PR 10). */ export interface CommunitySeedCollectionSummary { collection: string; row_count: number; bytes: number; } /** A per-collection digest of a submission's captured seed rows (PR 10). */ export interface CommunitySeedRowSummary { collections: CommunitySeedCollectionSummary[]; total_rows: number; total_bytes: number; } /** A submission's FULL content, for operator review. */ export interface CommunitySubmissionDetail extends CommunitySubmissionSummary { html: string; manifest: unknown; seed_rows: unknown; /** * A digest of seed_rows (marketplace PR 10): per-collection row counts + byte * sizes plus totals, so the operator sees how much (and which) captured * starter data would become public without parsing the raw rows. */ seed_summary: CommunitySeedRowSummary; version_id: string | null; reviewed_at: string | null; review_note: string | null; superseded_at: string | null; /** Namespaced identity + fork/license fields (marketplace PR 2). */ changelog: unknown; license: string | null; license_terms: string | null; derived_from_snapshot_id: string | null; /** * Typed agent-setup steps (marketplace PR 9): the machine-readable structure * an agent reads. Full structure incl. secret-flagged steps (a step stores * only the publisher's default/hint, never an installer value). `null` when * the template declares no steps. */ setup_steps: CommunitySetupStep[] | null; /** * Every external destination the manifest declares (D6 disclosure): outbound * webhook targets, the app's fetch allowance and its framing allowance, * deduplicated and HOSTS ONLY, so a reviewer sees where the app can send * data without reading the raw manifest. Empty when it talks to nothing * outside Homespun. */ external_destinations: CommunityExternalDestination[]; } /** One disclosed external destination on a submission (D6). */ export interface CommunityExternalDestination { /** `webhook` = the relay POSTs row data there; `fetch` / `embed` = the app's * own page may call or frame it. */ kind: "webhook" | "embed" | "fetch"; /** Destination host, or null when the manifest alone cannot name it. */ host: string | null; /** Settings field an install-time webhook target is read from. */ fromSetting?: string; /** Stored connection a relative webhook target resolves against. */ viaConnection?: string; } /** A publisher's own profile, as returned by the `/v1/publisher` routes. */ export interface PublisherProfile { handle: string; display_name: string | null; bio: string | null; url: string | null; /** True once the handle has been claimed (and is therefore permanent). */ claimed: boolean; claimed_at: string | null; created_at: string; first_published_at: string | null; /** * Templates of this publisher that are LIVE right now: approved, not * superseded by a newer version, and not withdrawn. A template republished * several times counts once, and withdrawing one lowers the count. */ approved_template_count: number; /** * Ratings across ALL of this publisher's template lines, including ones since * withdrawn - a rating is attached to the `/` line rather than * to a single version, so it is not shed by taking a template down. Divide * `rating_sum` by `rating_count` for the average; `rating_count` is 0 when * nobody has rated anything yet. */ rating_count: number; rating_sum: number; trust_level: string; } /** * The mutable profile fields for `updatePublisher()`. A field left `undefined` * is untouched; `null` clears it. */ export interface PublisherProfileUpdate { displayName?: string | null; bio?: string | null; url?: string | null; } /** Request body for `createReview()`. Give `template` OR (`handle` + `slug`). */ export interface CreateCommunityReviewRequest { /** Namespaced template id, "/". */ template?: string; /** Publisher handle (with `slug`, as an alternative to `template`). */ handle?: string; /** Per-publisher slug (with `handle`). */ slug?: string; /** Star rating, integer 1..5. */ stars: number; /** Optional written review body. */ body?: string; } /** A review as returned by the `/v1/reviews` routes. */ export interface CommunityReview { id: string; template_publisher_human_id: string; template_slug: string | null; snapshot_id: string; install_id: string | null; reviewer_human_id: string; stars: number; body: string | null; /** "visible" | "held" | "removed". A body with a link/email lands "held". */ status: string; held_reason: string | null; publisher_response: string | null; publisher_responded_at: string | null; created_at: string; } /** Result of `reportReview()`. */ export interface CommunityReviewReportResult { report_id: string; review_id: string; } /** Per-attachment metadata as returned by `POST /v1/attachments` and friends. */ export interface AttachmentRef { attachment_id: string; scope: "agent" | "app"; mime: string; size: number; sha256: string; url?: string; width?: number | null; height?: number | null; filename?: string | null; status?: string; app_id?: string | null; created_at?: string; confirmed_at?: string | null; deleted_at?: string | null; } export interface UploadBlobOptions { scope?: "agent" | "app"; appId?: string; /** Declared Content-Type. Defaults to `application/octet-stream`. The * relay sniffs leading bytes and may reject with `mime_mismatch`. */ mime?: string; /** Optional display name (the relay records it for UX; never a path component). */ filename?: string; } export interface PresignBlobOptions { mime: string; size: number; sha256: string; scope?: "agent" | "app"; appId?: string; filename?: string; } export interface AttachmentTokenMintResponse { token_id: string; token: string; token_prefix: string; url: string; expires_at: string; once: boolean; } /** Options for `listBlobs()` — opaque cursor + page-size knob. */ export interface ListBlobsOptions { /** Opaque pagination cursor from a prior `next_cursor`. */ cursor?: string; /** Page size; relay clamps to 1..100. Defaults to the relay default (50). */ limit?: number; } /** One row in the response from `listBlobTokens()`. */ export interface AttachmentTokenAuditEntry { token_id: string; token_prefix: string; expires_at: string; once: boolean; created_at: string; last_used_at: string | null; use_count: number; /** Non-null when the token has been revoked. Expired-but-unrevoked rows * carry `revoked_at: null` and an `expires_at` in the past — both are * useful for audit. */ revoked_at: string | null; } /** Shape returned by `listBlobTokens()`. */ export interface AttachmentTokenListResponse { attachment_id: string; items: AttachmentTokenAuditEntry[]; }