From 9895445bc21849044b80651e03921d1c945e4ecc Mon Sep 17 00:00:00 2001 From: Tim Disney Date: Fri, 26 Jun 2026 16:07:32 -0700 Subject: [PATCH] xrpc services --- README.md | 2 +- demo/README.md | 98 +++++++++++++++++++ demo/fixtures.mjs | 55 +++++++++++ demo/index.html | 100 +++++++++++++++++++ demo/route.mjs | 112 ++++++++++++++++++++++ demo/serve.mjs | 44 +++++++++ demo/share.html | 91 ++++++++++++++++++ demo/share.mjs | 116 +++++++++++++++++++++++ docs/agent-guide.md | 65 ++++++++++--- docs/capability-spec.md | 49 ++++++++-- docs/consumers.md | 32 +++++-- docs/overview.md | 10 +- docs/producers.md | 16 ++-- lexicons/README.md | 2 +- lexicons/dev/at-intent/capability.json | 8 +- resolver/README.md | 2 +- resolver/cli.mjs | 4 +- resolver/fixtures/demo.mjs | 4 +- resolver/resolver.mjs | 5 +- tools/examples/all.capabilities.json | 4 +- tools/examples/subscribe.capability.json | 4 +- tools/lib/capability.mjs | 33 +++++-- tools/write-capability-interactive.mjs | 6 +- 23 files changed, 796 insertions(+), 66 deletions(-) create mode 100644 demo/README.md create mode 100644 demo/fixtures.mjs create mode 100644 demo/index.html create mode 100644 demo/route.mjs create mode 100644 demo/serve.mjs create mode 100644 demo/share.html create mode 100644 demo/share.mjs diff --git a/README.md b/README.md index 953eb50..97b3005 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ publish │ dev.at-intent.usage ──"I use Sill"──────── ``` Every capability is a **`(verb, subject, handler)`** triple plus a **`delivery`** -method (`passive` / `pds` / `service`) that says how the action actually reaches +method (`passive` / `repo` / `service`) that says how the action actually reaches the app. The same record doubles as an **agent tool definition** — discovered from the user's data at runtime, scoped by their existing grants. diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..cab2cfb --- /dev/null +++ b/demo/README.md @@ -0,0 +1,98 @@ +# Share bookmarklet — a browser consumer demo + +A working **consumer** of AT Intents: a bookmarklet that shares the current web +page through whatever atproto apps are already in your repo. No app list is +hardcoded — the share targets are discovered from the user's repo footprint, the +same loop the [resolver](../resolver/) runs on the CLI. + +It's the canonical consumer story from [docs/consumers.md](../docs/consumers.md), +made tangible: + +``` +current page (url + title) ──► bare `uri` subject + │ + ├─ 1 RESOLVE scan repo for dev.at-intent.usage ─┐ (read-only, + ├─ 2 DISCOVER resolve each app → its capabilities │ no auth) + ├─ 3 MATCH keep (share, uri) handlers ─┘ + └─ 4 ACT route the picked handler by `delivery` + repo → write a record to your repo + service → call the app's XRPC endpoint +``` + +## Run it + +```bash +node demo/serve.mjs # → http://localhost:8787/demo/ +``` + +Browser ES modules can't load over `file://`, so this tiny zero-dep server hosts +the repo root (the demo imports `../resolver/resolver.mjs`). Then: + +1. Open `http://localhost:8787/demo/`. +2. Drag **Share with…** to your bookmarks bar (or copy the source). +3. Click it on any page — or hit **Try the share sheet** to skip straight to it. + +## What you'll see + +The share sheet lists the demo user's apps that can share a URL and shows how +each one is routed: + +| Handler | `delivery` | What the demo shows | +|---|---|---| +| **Skyreader Linkblog** | `repo` | the `com.atproto.repo.createRecord` write + the `include:` scope it needs | +| **Chirp** | `service` | the `POST /xrpc/...` call, with the page URL as the `subject` param | + +Pick one and it renders the exact request a real consumer would send. **Nothing +is written or sent** — the demo stops at the request so you can see the +`delivery` split that keeps "looks like it worked" and "actually worked" the same +thing. + +## How it maps to the code + +| File | Role | +|---|---| +| `index.html` | builds + installs the bookmarklet (against the serving origin) | +| `share.html` / `share.mjs` | the share sheet: runs steps 1–3, renders the picker | +| `route.mjs` | step 4 — builds the concrete `repo` / `service` / `passive` request (inert) | +| `fixtures.mjs` | offline data: the resolver demo (Skyreader + Sill) plus a `service` share (Chirp) | +| `serve.mjs` | zero-dep static server | + +The discovery + matching is all upstream code: `resolveActionGraph` and +`matchActions` from [`../resolver/resolver.mjs`](../resolver/resolver.mjs), +unchanged. This demo only adds the page-capture front end and the inert `act` +renderer. + +## Going live + +To run against a real repo instead of fixtures, drop the `fixtures` option: + +```js +const graph = await resolveActionGraph("alice.bsky.social"); // real repo scan +``` + +and make the `act` step real: + +- **`repo`** — `createRecord` through the user's OAuth session, requesting the + aggregated `include:` scopes. +- **`service`** — the endpoint belongs to the *handler's* app, not the user's PDS, + so a PDS write scope doesn't apply. Two paths: + - **service-auth JWT** (what the demo's inert output shows): mint a short-lived + token via the user's PDS session — `com.atproto.server.getServiceAuth?aud=&lxm=` + (the handler DID comes from discovery, the NSID from the endpoint path) — then + send it as `Authorization: Bearer`. The PDS signs the token; the client only + requests it, so this works **fully in the browser**: a SPA is a supported + atproto OAuth public client (PKCE + DPoP). The bookmarklet snippet can't be the + OAuth client (it runs on a foreign origin), but the `share.html` page it opens — + on your origin — can hold the session and mint the token. + - **browser navigation** for `verb: open` (GET): just navigate to the URL and let + the user's existing session at the handler authenticate. Fully client-side. + +The thing that may force a backend is **not** session-holding but **CORS on the +handler's endpoint**: a cross-origin call needs the third-party service to send +`Access-Control-Allow-Origin` for your origin, which you don't control — a +server-side fetch (no CORS) or a proxy sidesteps it. Also unverified: whether the +proposal-0011 OAuth scope a browser session holds permits `getServiceAuth` for an +arbitrary `aud`/`lxm`. And per the trust model, **verify the handler's authority +before acting** — the usage record that surfaced it is self-asserted. See +[docs/consumers.md](../docs/consumers.md) and +[docs/capability-spec.md](../docs/capability-spec.md#service-wire-contract-xrpc). diff --git a/demo/fixtures.mjs b/demo/fixtures.mjs new file mode 100644 index 0000000..8129a67 --- /dev/null +++ b/demo/fixtures.mjs @@ -0,0 +1,55 @@ +// Demo fixtures for the share bookmarklet — the browser consumer. +// +// Reuses the canonical resolver demo (Skyreader + Sill) so the bookmarklet runs +// the SAME action graph the CLI does, then adds one extra share handler delivered +// by `service` so the picker contrasts the two routes a share can take: +// +// Skyreader Linkblog share uri delivery: repo (write a record to your repo) +// Chirp share uri delivery: service (call the app's endpoint) +// +// Nothing here touches the network: the resolver reads these fixtures instead of +// scanning a real repo, so the whole loop runs offline. + +import base, { DEMO_ACTOR } from "../resolver/fixtures/demo.mjs"; + +export { DEMO_ACTOR }; + +const CHIRP = "did:web:chirp.example"; + +const chirpCaps = [ + { + $type: "dev.at-intent.capability", + name: "Chirp", + icon: "https://chirp.example/icon.png", + description: + "Post a link to your Chirp microblog with an optional comment. Chirp's canonical store is its own backend, so the share is delivered by calling its endpoint — a PDS write alone would not reach it.", + verb: "share", + subject: [{ kind: "uri" }], + input: [ + { name: "comment", kind: "string", description: "Optional text to post alongside the link." }, + ], + delivery: "service", + endpoint: "https://chirp.example/xrpc/app.chirp.post.create", + createdAt: "2026-06-26T00:00:00Z", + }, +]; + +// Same shape the resolver expects: { usage: {did: [...]}, capabilities: {did: [...]} }. +// Add Chirp to the demo user's footprint and register its capabilities. +export default { + usage: { + [DEMO_ACTOR]: [ + ...base.usage[DEMO_ACTOR], + { + $type: "dev.at-intent.usage", + app: CHIRP, + lastSeenAt: "2026-06-25T18:00:00Z", + createdAt: "2026-04-01T00:00:00Z", + }, + ], + }, + capabilities: { + ...base.capabilities, + [CHIRP]: chirpCaps, + }, +}; diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..1bbe0e3 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,100 @@ + + + + + +Share bookmarklet — AT Intents demo + + + +
+

Share bookmarklet

+

A tiny browser bookmark that shares the current page through whatever + atproto apps are already in your repo. Discovered with AT Intents, no app list baked in.

+ +
+

1 · Install

+

Drag this to your bookmarks bar:

+

Share with…

+

Can't drag? Make a new bookmark and paste the URL from + show bookmarklet source.

+ +
+ +
+

2 · Use it

+

On any web page, click the bookmark. It captures the page's URL and + title and opens the AT Intents share sheet:

+
    +
  1. Resolve: scan your repo for dev.at-intent.usage records.
  2. +
  3. Discover: resolve each app to its dev.at-intent.capability records.
  4. +
  5. Match: keep handlers whose (verb, subject) is share on a bare uri.
  6. +
  7. Act: route the chosen handler by delivery (repo writes a record; service calls an endpoint).
  8. +
+

No real page handy?

+
+ +
+ Runs against offline demo fixtures (the same Skyreader/Sill data the resolver CLI uses, + plus a service-delivered share). Nothing is written or sent. + See docs/consumers.md. +
+
+ + + + diff --git a/demo/route.mjs b/demo/route.mjs new file mode 100644 index 0000000..1dbe67a --- /dev/null +++ b/demo/route.mjs @@ -0,0 +1,112 @@ +// The ACT step, isolated and inert. Given a matched share action, the page being +// shared, and the user's input, build the concrete request a real consumer would +// send — but return it as a plain object instead of firing it, so the demo can +// show exactly what each `delivery` route does without auth or a network. +// +// Mirrors the contract in docs/consumers.md ("Routing by delivery"). + +const SCALAR_KINDS = new Set(["string", "uri", "at-uri", "did", "integer", "boolean"]); + +// delivery: repo — write the capability's `produces` record(s) into the user's repo. +// We synthesize a minimal record value from the page + inputs; a real handler's +// lexicon would pin the exact shape, but the point is the createRecord call and +// the scope it needs. +function buildRepoWrite(action, page, inputs) { + const collection = action.produces?.[0] || "(unspecified record type)"; + const value = { + $type: collection, + url: page.url, + ...(page.title ? { title: page.title } : {}), + ...inputs, // note/comment/title etc., keyed by input name + createdAt: "", + }; + return { + route: "repo", + summary: `Write a ${collection} record into your repo.`, + scope: action.scope || null, + request: { + method: "POST", + // The consumer's own PDS, via the session it already holds. + endpoint: "https:///xrpc/com.atproto.repo.createRecord", + body: { repo: "", collection, record: value }, + }, + }; +} + +// delivery: service — call the endpoint as XRPC. verb=open -> GET, else POST. +// Subject rides under the reserved param `subject`; inputs ride under their names; +// scalars go in the query string. Preserve any static params already on endpoint. +function buildServiceCall(action, page, inputs) { + const method = action.verb === "open" ? "GET" : "POST"; + const url = new URL(action.endpoint); + // never clobber the endpoint's own query params + url.searchParams.set("subject", page.url); + for (const field of action.input || []) { + const v = inputs[field.name]; + if (v == null || v === "") continue; + if (SCALAR_KINDS.has(field.kind)) url.searchParams.set(field.name, String(v)); + // blob inputs would go in the body; the demo's share inputs are all scalars + } + + // The endpoint belongs to the HANDLER's app, not the user's PDS, so a PDS write + // scope is useless here. The atproto-native answer is service auth: mint a + // short-lived inter-service JWT scoped to exactly this call, then bearer it. + // aud = the handler DID (from discovery) lxm = the method NSID (from the path) + const aud = action.handler; // discovery already gave us the app's DID + const lxm = new URL(action.endpoint).pathname.split("/xrpc/")[1] || "(method)"; + const isOpen = action.verb === "open"; + const auth = isOpen + ? { + kind: "browser", + note: "GET/open: navigate the browser to the URL — the user's existing session at the handler (cookies) authenticates. No token to mint.", + } + : { + kind: "service-jwt", + note: "Mint a service-auth token via the user's PDS session, then bearer it to the handler. No shared secret, no account linking.", + mint: { + method: "GET", + endpoint: `https:///xrpc/com.atproto.server.getServiceAuth?aud=${aud}&lxm=${lxm}`, + }, + header: "Authorization: Bearer ", + }; + + return { + route: "service", + summary: `Call ${action.name}'s XRPC endpoint (${method}).`, + scope: null, // the handler's own auth, not a PDS write scope + auth, + request: { + method, + endpoint: url.toString(), + ...(auth.kind === "service-jwt" ? { headers: { Authorization: "Bearer " } } : {}), + }, + }; +} + +// delivery: passive — handler already reads a primitive in the repo; nothing to do. +function buildPassive(action) { + return { + route: "passive", + summary: `${action.name} already reads this from your repo — nothing to send.`, + scope: null, + request: null, + }; +} + +export function buildAction(action, page, inputs = {}) { + switch (action.delivery) { + case "repo": + return buildRepoWrite(action, page, inputs); + case "service": + return buildServiceCall(action, page, inputs); + case "passive": + return buildPassive(action); + default: + return { + route: action.delivery || "unknown", + summary: `Unknown delivery "${action.delivery}" — a v1 consumer should ignore this.`, + scope: null, + request: null, + }; + } +} diff --git a/demo/serve.mjs b/demo/serve.mjs new file mode 100644 index 0000000..7c9849f --- /dev/null +++ b/demo/serve.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// Tiny zero-dep static server for the share bookmarklet demo. Browser ES modules +// won't load over file://, so serve the repo over http and open the demo at +// http://localhost:8787/demo/. Serves the whole repo root so the demo's imports +// of ../resolver/* resolve. +// +// node demo/serve.mjs # http://localhost:8787/demo/ +// PORT=9000 node demo/serve.mjs + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { extname, join, normalize } from "node:path"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); // repo root +const PORT = Number(process.env.PORT) || 8787; + +const TYPES = { + ".html": "text/html; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".css": "text/css; charset=utf-8", +}; + +const server = createServer(async (req, res) => { + try { + let path = decodeURIComponent(new URL(req.url, "http://localhost").pathname); + if (path === "/") path = "/demo/"; + if (path.endsWith("/")) path += "index.html"; + // contain to ROOT — no path traversal + const file = normalize(join(ROOT, path)); + if (!file.startsWith(ROOT)) { res.writeHead(403).end("forbidden"); return; } + const body = await readFile(file); + res.writeHead(200, { "content-type": TYPES[extname(file)] || "application/octet-stream" }); + res.end(body); + } catch { + res.writeHead(404, { "content-type": "text/plain" }).end("not found"); + } +}); + +server.listen(PORT, () => { + console.log(`\n AT Intents share demo → http://localhost:${PORT}/demo/\n`); +}); diff --git a/demo/share.html b/demo/share.html new file mode 100644 index 0000000..6244807 --- /dev/null +++ b/demo/share.html @@ -0,0 +1,91 @@ + + + + + +Share with — AT Intents + + + +
+
+

Share with…

+
apps in your's repo that can share a URL
+
+
+
…
+
…
+
+
+ +
+
+ Discovered from the repo footprint — no app list hardcoded. + ← bookmarklet +
+ + + diff --git a/demo/share.mjs b/demo/share.mjs new file mode 100644 index 0000000..a5be150 --- /dev/null +++ b/demo/share.mjs @@ -0,0 +1,116 @@ +// The share sheet: a CONSUMER that runs the AT Intents discovery loop in the +// browser and routes a "share this page" by delivery. No app list is hardcoded — +// the handlers below come entirely from the demo user's repo footprint. +// +// page (url+title) -> bare `uri` subject +// resolveActionGraph(user) scan usage -> resolve capabilities +// matchActions(graph, share + uri) which of MY apps can share a URL? +// buildAction(handler, page, inputs) route by delivery (repo | service | passive) + +import { resolveActionGraph, matchActions } from "../resolver/resolver.mjs"; +import demo, { DEMO_ACTOR } from "./fixtures.mjs"; +import { buildAction } from "./route.mjs"; + +const $ = (sel) => document.querySelector(sel); +const el = (tag, props = {}, ...kids) => { + const n = Object.assign(document.createElement(tag), props); + for (const k of kids) n.append(k); + return n; +}; + +function getPage() { + const p = new URLSearchParams(location.search); + return { + url: p.get("url") || "https://example.com/an-article", + title: p.get("title") || "An example article", + }; +} + +function renderTarget(page) { + $("#target-title").textContent = page.title; + $("#target-url").textContent = page.url; +} + +// Show the concrete request a real consumer would send for the chosen handler. +function renderAction(action, page) { + const inputs = {}; + for (const field of action.input || []) { + const v = $(`#in-${field.name}`)?.value; + if (v) inputs[field.name] = v; + } + const built = buildAction(action, page, inputs); + + const routeColor = { repo: "var(--repo)", service: "var(--service)", passive: "var(--passive)" }[built.route] || "var(--ink)"; + const parts = [ + el("p", { className: "act-summary" }, + el("span", { className: "route-pill", style: `--c:${routeColor}` }, `delivery: ${built.route}`), + " " + built.summary), + ]; + if (built.scope) { + parts.push(el("p", { className: "scope" }, "OAuth scope requested: ", el("code", {}, built.scope))); + } + // service auth: show how the call is authenticated (mint a token, or defer to the browser). + if (built.auth) { + parts.push(el("p", { className: "scope" }, "auth: " + built.auth.note)); + if (built.auth.mint) { + parts.push(el("pre", { className: "wire" }, + `# 1. mint a service-auth token (via your PDS session)\n${built.auth.mint.method} ${built.auth.mint.endpoint}\n -> { token: "" }`)); + } + } + if (built.request) { + const r = built.request; + const prefix = built.auth?.mint ? "# 2. call the handler\n" : ""; + const lines = [`${prefix}${r.method} ${r.endpoint}`]; + if (r.headers) for (const [k, v] of Object.entries(r.headers)) lines.push(`${k}: ${v}`); + if (r.body) lines.push("", JSON.stringify(r.body, null, 2)); + parts.push(el("pre", { className: "wire" }, lines.join("\n"))); + } + parts.push(el("p", { className: "note" }, + "Nothing was sent — this is the demo showing what the bookmarklet would do. " + + "A real consumer fires this request (with your auth) here.")); + + const box = $("#action"); + box.replaceChildren(...parts); + box.hidden = false; +} + +function renderHandlers(handlers, page) { + const list = $("#handlers"); + list.replaceChildren(); + if (!handlers.length) { + list.append(el("p", { className: "empty" }, + "None of your apps can share a URL. (Try the action graph to see what they can do.)")); + return; + } + for (const h of handlers) { + const inputs = (h.input || []).map((f) => + el("label", { className: "field" }, + el("span", {}, f.name + (f.required ? " *" : "")), + el("input", { id: `in-${f.name}`, type: "text", placeholder: f.description || f.name }))); + + const card = el("div", { className: "handler" }, + el("div", { className: "handler-head" }, + el("strong", {}, h.name || h.handler), + el("span", { className: "delivery", "data-route": h.delivery }, h.delivery)), + el("p", { className: "desc" }, h.description || ""), + ...inputs, + el("button", { className: "go", onclick: () => renderAction(h, page) }, "Share here")); + list.append(card); + } +} + +async function main() { + const page = getPage(); + renderTarget(page); + + // Steps 1–3 of the loop — read-only, no auth. Fixtures stand in for the repo scan. + const graph = await resolveActionGraph(DEMO_ACTOR, { fixtures: demo }); + const handlers = matchActions(graph, { verb: "share", subject: { kind: "uri" } }); + + $("#user").textContent = "demo user"; + renderHandlers(handlers, page); +} + +main().catch((err) => { + $("#handlers").replaceChildren(el("p", { className: "empty" }, "Failed: " + (err?.message || err))); +}); diff --git a/docs/agent-guide.md b/docs/agent-guide.md index c633a6d..9017c28 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -26,10 +26,11 @@ record in the app's own repo. closest, or coin one and flag it to the developer). - **subject** — what it acts on, a *union* of shapes: `uri` (a bare web/feed URL), `at-uri` (a record, optionally `of` a lexicon type), `did` (an account). -- **delivery** — `passive` (you read a record already in the user's repo), `pds` +- **delivery** — `passive` (you read a record already in the user's repo), `repo` (the consumer writes a record into the user's repo that you read), or `service` - (the consumer must call your HTTP endpoint; a PDS write won't reach you). **This - is the field most likely to be wrong — get it right.** + (the consumer must call your XRPC endpoint; a PDS write won't reach you). **This + is the field most likely to be wrong — get it right.** A `service` endpoint is + an **XRPC method** (`https://host/xrpc/`); see [service shape](#service-must-be-xrpc-shaped). ## Your procedure @@ -67,6 +68,10 @@ Gather evidence; don't guess. High-signal places to look: - A non-PDS canonical store (a database — D1, Postgres, KV; an external API) that user actions write to. **If the action's source of truth is a backend, not the user's PDS, it's `delivery: service`.** +- **Note whether the route is already XRPC-shaped** (`/xrpc/`, params in the + query string, `{ error, message }` errors). If the action's endpoint is a + plain REST/RPC route (`POST /api/save`, a GraphQL field, a `?action=` handler), + flag it — see [below](#service-must-be-xrpc-shaped). **The actions themselves (for `verb` / `subject`):** - UI: buttons/menus labeled save, subscribe/follow, share/post, highlight/annotate, @@ -83,14 +88,14 @@ table: |---|---|---| | What's the action? | `verb` | save / subscribe / share / annotate / open — closest match. | | What can you act on? | `subject[]` | A raw URL → `{kind: uri}`. A record you accept → `{kind: at-uri, of: }`. An account → `{kind: did, as: account}`. List every accepted shape. | -| Where does the result live? | `delivery` | In the **user's PDS** as a record you write/read → `pds`. In **your backend** → `service`. You only **read** an existing repo record → `passive`. | -| (pds) what record? | `produces` | The NSID(s) from the createRecord calls. | -| (pds) what permission? | `scope` | `include:` — ask the developer if unknown; leave a clear `TODO`. | -| (service) what URL? | `endpoint` | The XRPC/HTTP route that performs it. | +| Where does the result live? | `delivery` | In the **user's PDS** as a record you write/read → `repo`. In **your backend** → `service`. You only **read** an existing repo record → `passive`. | +| (repo) what record? | `produces` | The NSID(s) from the createRecord calls. | +| (repo) what permission? | `scope` | `include:` — ask the developer if unknown; leave a clear `TODO`. | +| (service) what URL? | `endpoint` | The XRPC method URL (`https://host/xrpc/`) that performs it. If the real route isn't XRPC-shaped, see [below](#service-must-be-xrpc-shaped). | | Extra inputs? | `input[]` | A note, title, target collection — name + kind. | **The `delivery` decision is the one that silently breaks if wrong.** A "save" -whose data lands in your own database is `service`, *not* `pds` — a PDS write +whose data lands in your own database is `service`, *not* `repo` — a PDS write would never reach your backend, so a consumer would think it saved when it didn't. When the evidence is ambiguous, write your best guess and call it out in the summary for the developer to confirm. @@ -99,6 +104,34 @@ Write `description` for a planner *and* a human: what it does, when to use it, what it won't do (≤300 graphemes). This text drives both UI pickers and agent tool-calling, so it matters. +#### Service must be XRPC-shaped + +A `delivery: service` `endpoint` is an **XRPC method** — an absolute URL whose +path is `/xrpc/`, with the subject and inputs carried as XRPC parameters +(scalars in the query string, blobs in the request body) and errors returned as +`{ error, message }` JSON. The `verb` picks the HTTP method: `open` → GET query, +every other verb → POST procedure. (Full rules: +[service wire contract](./capability-spec.md#service-wire-contract-xrpc).) + +**If the app's real endpoint isn't XRPC-shaped — don't quietly paper over it.** +A `POST /api/save`, a GraphQL mutation, a `?action=save` handler, or any plain +REST/RPC route can't be invoked by a consumer through this contract. When you +find one backing a `service` capability: + +1. **Write the capability with the XRPC endpoint it *should* have** + (`https://host/xrpc/`) and mark it `TODO` — don't invent a + route that doesn't exist yet and present it as real. +2. **Suggest the app expose an XRPC method** for that action — either a thin + `/xrpc/` wrapper in front of the existing handler, or by moving the route + onto the app's XRPC server. Spell out the small delta (path → `/xrpc/`, + read params from the query string, return `{ error, message }` on failure). +3. **Call it out in the Step 6 summary** as a required change before the `service` + capability will actually work, not just a style nit. + +Getting this right is what keeps a `service` capability honest: a non-XRPC route +publishes fine but no consumer can route to it, so it "looks like it worked" while +being unreachable — the same silent-failure trap as a mislabeled `repo`. + ### Step 4 — write the record file(s) Create one JSON file in the repo holding the capability record(s) — a single @@ -120,13 +153,13 @@ ignored, so use it for review notes. "subject": [ { "kind": "" } ], - "delivery": "", + "delivery": "", "_delivery_note": "Keep ONLY the fields for the delivery you chose:", "_passive": "you read an existing repo record — no produces/scope/endpoint", - "_pds": "consumer writes a record you read — set produces + scope", - "produces": [""], - "scope": "include: TODO confirm — pds only", + "_repo": "consumer writes a record you read — set produces + scope", + "produces": [""], + "scope": "include: TODO confirm — repo only", "_service": "consumer calls your backend — set endpoint instead", "endpoint": " TODO confirm — service only" } @@ -163,7 +196,10 @@ End with a short report: 1. **Capabilities drafted** — one line each: `verb` + `subject` + `delivery`. 2. **Assumptions & TODOs** — every value you guessed, especially `delivery`, - `scope`, and `endpoint`, with where the evidence was thin. + `scope`, and `endpoint`, with where the evidence was thin. **Flag any `service` + action whose real route isn't XRPC-shaped** and recommend exposing an + `/xrpc/` method (see [Service must be XRPC-shaped](#service-must-be-xrpc-shaped)) — + call it a blocker, since the capability can't be routed to until then. 3. **Usage records** — remind the developer that discovery also requires writing a `dev.at-intent.usage` record into each *user's* repo when they use the app (see [producers.md](./producers.md)); that's app code, not a one-time publish. @@ -174,6 +210,9 @@ End with a short report: - **Don't invent NSIDs, endpoints, scopes, or a DID.** If the repo doesn't show it, leave a `TODO` and ask. A wrong `produces`/`endpoint` is worse than a blank one. +- **A `service` endpoint must be XRPC (`/xrpc/`).** If the app's real route + isn't, recommend exposing one rather than pointing `endpoint` at a non-XRPC URL + (see [Service must be XRPC-shaped](#service-must-be-xrpc-shaped)). - **A capability may only describe its own app's records/endpoints** — never another app's collections. (This is also a security rule: descriptors are untrusted input to consumer agents.) diff --git a/docs/capability-spec.md b/docs/capability-spec.md index d530618..2d267ff 100644 --- a/docs/capability-spec.md +++ b/docs/capability-spec.md @@ -25,13 +25,13 @@ JSON has no comments. | `name` | ✅ | string ≤64 graphemes | Display name of the handler (e.g. `Skyreader`). | | `verb` | ✅ | string | Known: `share` `save` `subscribe` `annotate` `open`. Open set — unknown verbs validate with a warning; consumers that don't know a verb skip it. | | `subject` | ✅ | array (≥1) of [subject specs](#subject-spec) | A union: matches if **any** entry fits. | -| `delivery` | ✅ | `passive` \| `pds` \| `service` | How the action reaches you. See [delivery rules](#delivery-rules). | +| `delivery` | ✅ | `passive` \| `repo` \| `service` | How the action reaches you. See [delivery rules](#delivery-rules). | | `description` | — | string ≤300 graphemes | Strongly recommended. Written for **both** a human picker and an agent planner: what it does, when to use it, what it won't do. | | `icon` | — | uri | Handler icon; http(s) URL. | | `input` | — | array of [input fields](#input-field) | Named typed inputs beyond the subject. | -| `produces` | — | array of NSIDs | Record type(s) you write. Required-ish for `pds`; empty for `passive`/`service`. | -| `endpoint` | — | uri | **Required for `delivery: service`** — the URL/XRPC that performs the action. | -| `scope` | — | string | **For `delivery: pds`** — an `include:` permission set the consumer must request. | +| `produces` | — | array of NSIDs | Record type(s) you write. Required-ish for `repo`; empty for `passive`/`service`. | +| `endpoint` | — | uri | **Required for `delivery: service`** — the XRPC method URL (`https://host/xrpc/`) that performs the action. See [service wire contract](#service-wire-contract-xrpc). | +| `scope` | — | string | **For `delivery: repo`** — an `include:` permission set the consumer must request. | | `$type` | auto | NSID | Set to `dev.at-intent.capability` by the writer; you may include it. | | `createdAt` | auto | datetime | Added by the writer if omitted. | @@ -71,9 +71,12 @@ JSON has no comments. The validator enforces these cross-field rules (the JSON Schema alone can't): -- **`service`** — `endpoint` is **required** (error if missing). `produces` and - `scope` are unexpected (warning). -- **`pds`** — `produces` and `scope` are expected (warning if missing). `endpoint` +- **`service`** — `endpoint` is **required** (error if missing) and must be an + absolute http(s) URL; a path other than `/xrpc/` warns. `produces` and + `scope` are unexpected (warning). A `verb: open` capability maps to an XRPC GET + query and so **cannot** carry a `blob` subject or input (error). See + [service wire contract](#service-wire-contract-xrpc) for how the call is made. +- **`repo`** — `produces` and `scope` are expected (warning if missing). `endpoint` is ignored (warning). - **`passive`** — `produces`, `scope`, and `endpoint` are all unexpected (warning); a passive capability reads what's already there and writes nothing. @@ -81,6 +84,36 @@ The validator enforces these cross-field rules (the JSON Schema alone can't): Errors block a write. Warnings don't — pass `--force` to write despite them, or `--dry-run` to inspect first. +## Service wire contract (XRPC) + +A `delivery: service` capability is an **XRPC method call**. The capability record +*is* the param schema — v1 does **not** require a separate XRPC method lexicon; a +consumer encodes the matched subject and inputs directly from this record. + +- **Endpoint.** `endpoint` is an absolute `https://` URL whose path is + `/xrpc/`. It may carry static query params as handler config — the + consumer preserves them and merges its own params in, never clobbering. +- **HTTP method, derived from `verb`.** `open` → **GET** (an XRPC *query*: + cacheable, must not mutate). Every other verb — `save`, `subscribe`, + `annotate`, `share`, and any unknown verb — → **POST** (an XRPC *procedure*). + Unknown verbs default to POST, the non-cacheable / may-mutate choice. +- **Encoding subject + inputs.** The matched subject value is sent under the + reserved param name `subject`; each input under its declared `name`. + - Scalar kinds (`string` `uri` `at-uri` `did` `integer` `boolean`) are XRPC + `params` → the **query string**. Booleans serialize as bare `true`/`false` + (unquoted); arrays repeat the key. + - `blob` cannot be a query param → it travels in the **request body**. A GET + (`open`) capability therefore cannot accept a `blob` subject or input. +- **Auth.** The endpoint's own OAuth — the same OAuth path you walk for `repo` — or + an atproto inter-service JWT. Not a PDS write scope. +- **Errors.** XRPC convention: a non-2xx response with + `Content-Type: application/json` and a body of + `{ "error": "", "message": "" }`. + +So "what about endpoints that need query params?" dissolves: in XRPC the params +*are* the query string. A handler that wants `?url=…&cat=…` just declares those +as `subject` / `input`, and the consumer puts them there. + ## Full example ```json @@ -98,7 +131,7 @@ Errors block a write. Warnings don't — pass `--force` to write despite them, o { "name": "category", "kind": "string", "description": "Optional folder to file the subscription under." } ], "produces": ["app.skyreader.feed.subscription"], - "delivery": "pds", + "delivery": "repo", "scope": "include:app.skyreader.subscribe" } ``` diff --git a/docs/consumers.md b/docs/consumers.md index 4484f26..65a79ce 100644 --- a/docs/consumers.md +++ b/docs/consumers.md @@ -13,8 +13,8 @@ New to the model? Read the [overview](./overview.md) first. 1. RESOLVE Scan the user's repo for dev.at-intent.usage records → app DIDs. 2. DISCOVER Resolve each app DID → its dev.at-intent.capability records. 3. MATCH Filter to (verb, subject) you care about → candidate handlers. -4. AUTHORIZE (pds only) Aggregate include: scopes; request their union. -5. ACT Route by delivery: passive | pds | service. +4. AUTHORIZE (repo only) Aggregate include: scopes; request their union. +5. ACT Route by delivery: passive | repo | service. ``` Steps 1–3 are **read-only and need no auth**. The [`resolver/`](../resolver/) in @@ -66,20 +66,38 @@ Once the user picks a handler, route by its `delivery`: | `delivery` | What you do | Auth needed | |---|---|---| | `passive` | Nothing — the handler already reads the primitive in the repo. Just show it as available. | none | -| `pds` | Write the capability's `produces` record(s) into the user's repo. | the capability's `scope` | -| `service` | `POST` to the capability's `endpoint`. | the endpoint's own auth | +| `repo` | Write the capability's `produces` record(s) into the user's repo. | the capability's `scope` | +| `service` | Call the `endpoint` as an XRPC method — the `verb` picks GET/POST, subject+input become params. See below. | the endpoint's own auth (OAuth) | -> **Don't assume `pds` for everything.** A `service` capability will not work if +> **Don't assume `repo` for everything.** A `service` capability will not work if > you only write to the PDS; you must call its endpoint. A `passive` capability > needs no write at all. Honoring `delivery` is what keeps "looks like it worked" > and "actually worked" the same thing. +### Calling a `service` endpoint + +`endpoint` is an XRPC method URL (`https://host/xrpc/`). You don't need a +method lexicon — the capability record is the param schema. To make the call: + +- **Method from `verb`.** `open` → **GET** (XRPC query). Every other verb (and any + unknown verb) → **POST** (XRPC procedure). +- **Encode subject + input as params.** Put the matched subject under the reserved + name `subject` and each input under its `name`. Scalar kinds (`string` `uri` + `at-uri` `did` `integer` `boolean`) go in the **query string** (booleans as bare + `true`/`false`, arrays as repeated keys); a `blob` goes in the **request body** + (so a GET/`open` capability can't take one). +- **Preserve the endpoint's own query string.** If `endpoint` already has params + (`?source=…`), keep them and merge yours in — never clobber. +- **Auth** is the endpoint's own OAuth (the path you already walk for `repo`), not a + PDS write scope. **Errors** come back as XRPC `{ error, message }` JSON on a + non-2xx. + ## OAuth scope aggregation `passive` and the entire discover/display path need **no new scope** — ship that first as a complete, zero-scope feature. -For `pds` capabilities: +For `repo` capabilities: 1. Collect the `include:` scope of each handler the user might invoke (`requiredScopes(actions)`). @@ -122,7 +140,7 @@ duties unique to agents: - [ ] Scan usage records; dedupe by `app`; drop stale by `lastSeenAt` + TTL. - [ ] Resolve + cache capabilities by app DID. - [ ] Match on `(verb, subject)` — don't hardcode an app list. -- [ ] Route by `delivery` (passive / pds / service) — handle all three. +- [ ] Route by `delivery` (passive / repo / service) — handle all three. - [ ] Aggregate `include:` scopes; request the union; incremental re-auth only on first encounter; never `repo:*`. - [ ] (Agents) untrusted-input hardening + verify authority before acting. diff --git a/docs/overview.md b/docs/overview.md index cf2dfdf..cd28eee 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -44,14 +44,14 @@ between a capability that works and one that silently doesn't. | `delivery` | Meaning | `produces` | Cost to consumer | |---|---|---|---| | `passive` | Handler **reads a primitive already in the repo**; consumer writes nothing. | empty | zero | -| `pds` | Consumer **writes the produced records** to the user's repo; handler reads them. | the record(s) | a write + scope | +| `repo` | Consumer **writes the produced records** to the user's repo; handler reads them. | the record(s) | a write + scope | | `service` | Consumer **calls `endpoint`**; a PDS write alone won't reach the handler. | empty | an API call | Why it matters: a "Save to X" capability can *look* free and silently not work, because a record written to the user's PDS never reaches an app whose canonical store is its own backend. `delivery: service` makes that case explicit instead of a silent failure. `scope` (an `include:` permission set) is only needed for -`delivery: pds`. +`delivery: repo`. ## Two records, two homes @@ -76,8 +76,8 @@ A consumer assembles a user's **action graph** without any hardcoded app list: 2. DISCOVER Resolve each app DID → its dev.at-intent.capability records (cache by DID). 3. MATCH Now you have a typed action set: {(verb, subject) → handler, delivery, scope}. Answer "which of my apps can this ?" -4. AUTHORIZE (pds only) Aggregate the include: scopes of chosen handlers; request their union. -5. ACT passive → nothing; pds → write the produced record(s); service → call the endpoint. +4. AUTHORIZE (repo only) Aggregate the include: scopes of chosen handlers; request their union. +5. ACT passive → nothing; repo → write the produced record(s); service → call the endpoint. ``` Steps 1–3 are read-only and need no auth. The [resolver](../resolver/) implements @@ -107,7 +107,7 @@ split resolves this: - `passive` and discovery/display need **no new scope** — a complete feature with zero scope changes. -- `pds` capabilities each advertise an `include:` **permission set** +- `repo` capabilities each advertise an `include:` **permission set** ([atproto proposal 0011](https://github.com/bluesky-social/proposals/blob/main/0011-auth-scopes/README.md)). The consumer aggregates the sets of discovered apps and triggers incremental re-auth **only** on first encounter of a new app that needs scope it doesn't diff --git a/docs/producers.md b/docs/producers.md index c5c18c7..9a3c862 100644 --- a/docs/producers.md +++ b/docs/producers.md @@ -38,10 +38,10 @@ asking *where the canonical data lives*: | If… | `delivery` | You must… | |---|---|---| | your app just **reads** a primitive the user already has in their repo | `passive` | nothing else — no `produces`, no `scope` | -| your app's data lives **in the user's PDS** as a record you read | `pds` | list `produces` (the NSID you write) and a `scope` | -| your app's canonical store is **your own backend** (a PDS write won't reach you) | `service` | give an `endpoint` | +| your app's data lives **in the user's PDS** as a record you read | `repo` | list `produces` (the NSID you write) and a `scope` | +| your app's canonical store is **your own backend** (a PDS write won't reach you) | `service` | give an XRPC `endpoint` (`https://host/xrpc/`) | -> The classic mistake: marking a backend-canonical save as `pds` or `passive`. A +> The classic mistake: marking a backend-canonical save as `repo` or `passive`. A > record written to the user's repo never reaches your backend, so the capability > looks free and silently fails. Use `service`. @@ -62,7 +62,7 @@ are filled in for you. Full field reference: [capability-spec.md](./capability-s { "kind": "did", "as": "account" } ], "produces": ["app.myreader.subscription"], - "delivery": "pds", + "delivery": "repo", "scope": "include:app.myreader.subscribe" } ``` @@ -145,9 +145,11 @@ node write-usage.mjs --app did:web:myreader.app \ - [ ] Every user-facing action mapped to a `(verb, subject)` pair. - [ ] `delivery` chosen by where the canonical data lives (no backend-canonical - surface marked `pds`/`passive`). -- [ ] `pds` capabilities declare `produces` **and** a `scope`. -- [ ] `service` capabilities declare an `endpoint`. + surface marked `repo`/`passive`). +- [ ] `repo` capabilities declare `produces` **and** a `scope`. +- [ ] `service` capabilities declare an XRPC `endpoint` (`/xrpc/`); the + `verb` picks GET (`open`) vs POST, and subject+input ride as XRPC params + (see [service wire contract](./capability-spec.md#service-wire-contract-xrpc)). - [ ] `description` written for a planner (what / when / won't). - [ ] Capability records written to your app's repo (verify with the [resolver](../resolver/): `node resolver/cli.mjs `). diff --git a/lexicons/README.md b/lexicons/README.md index dbbce7b..32f075d 100644 --- a/lexicons/README.md +++ b/lexicons/README.md @@ -31,5 +31,5 @@ afterthought — it decides whether a capability is actionable: | `delivery` | Meaning | `produces` | scope | |---|---|---|---| | `passive` | Handler reads a primitive already in the repo; consumer writes nothing. | empty | none | -| `pds` | Consumer writes the produced record(s); handler reads them. | the record(s) | `include:` | +| `repo` | Consumer writes the produced record(s); handler reads them. | the record(s) | `include:` | | `service` | Consumer must call `endpoint`; a PDS write won't reach the handler. | empty | none | diff --git a/lexicons/dev/at-intent/capability.json b/lexicons/dev/at-intent/capability.json index 624edf8..ade6f93 100644 --- a/lexicons/dev/at-intent/capability.json +++ b/lexicons/dev/at-intent/capability.json @@ -52,17 +52,17 @@ }, "delivery": { "type": "string", - "knownValues": ["passive", "pds", "service"], - "description": "How the action reaches the handler. 'passive': the handler reads a primitive ALREADY in the user's repo; the consumer performs no extra write (zero-cost). 'pds': the consumer writes the 'produces' records into the user's repo and the handler reads them (enrichment). 'service': the consumer must call 'endpoint'; a PDS write alone would NOT reach the handler (e.g. an app whose canonical store is its own backend)." + "knownValues": ["passive", "repo", "service"], + "description": "How the action reaches the handler. 'passive': the handler reads a primitive ALREADY in the user's repo; the consumer performs no extra write (zero-cost). 'repo': the consumer writes the 'produces' records into the user's repo and the handler reads them (enrichment). 'service': the consumer must call 'endpoint'; a PDS write alone would NOT reach the handler (e.g. an app whose canonical store is its own backend)." }, "endpoint": { "type": "string", "format": "uri", - "description": "Required for delivery=service: the HTTP/XRPC endpoint that performs the action." + "description": "Required for delivery=service: the XRPC method URL that performs the action — an absolute https URL whose path is /xrpc/. The verb selects the HTTP method (open=GET query, every other verb=POST procedure); the capability's subject and input are encoded as XRPC parameters (scalars in the query string, blobs in the request body). May carry static query params as handler config. The record itself is the param schema; v1 does not require a separate XRPC method lexicon." }, "scope": { "type": "string", - "description": "For capabilities that write (delivery=pds): the OAuth permission set the handler requires, as an 'include:' scope string per atproto auth-scopes (proposal 0011). The consumer aggregates these across discovered apps and requests their union. Omit for delivery=passive." + "description": "For capabilities that write (delivery=repo): the OAuth permission set the handler requires, as an 'include:' scope string per atproto auth-scopes (proposal 0011). The consumer aggregates these across discovered apps and requests their union. Omit for delivery=passive." }, "createdAt": {"type": "string", "format": "datetime"} } diff --git a/resolver/README.md b/resolver/README.md index 47ed234..1975ec4 100644 --- a/resolver/README.md +++ b/resolver/README.md @@ -57,6 +57,6 @@ const scopes = requiredScopes(handlers); - **Browser live mode can hit CORS** — `listRecords` against arbitrary PDSes may be blocked from a web origin; a tiny read proxy fixes it. The Node path and demo mode have no such limit. -- **Routing is read-only here** — `pds`/`service` capabilities show what they +- **Routing is read-only here** — `repo`/`service` capabilities show what they *would* do; performing the write/call is the consumer's job (see [consumers.md](../docs/consumers.md)). diff --git a/resolver/cli.mjs b/resolver/cli.mjs index 1f95730..50ee41b 100755 --- a/resolver/cli.mjs +++ b/resolver/cli.mjs @@ -40,7 +40,7 @@ const C = { const deliveryNote = { passive: C.green("passive") + C.dim(" — reads a primitive already in your repo (zero-cost)"), - pds: C.cyan("pds") + C.dim(" — would write a record to your repo (needs OAuth scope)"), + repo: C.cyan("repo") + C.dim(" — would write a record to your repo (needs OAuth scope)"), service: C.yellow("service") + C.dim(" — would call the app's endpoint"), }; @@ -106,7 +106,7 @@ async function main() { } if (a.json) { console.log(JSON.stringify(graph, null, 2)); return; } - if (a.scopes) { console.log(requiredScopes(graph.actions).join(" ") || C.dim("(no pds-delivery scopes)")); return; } + if (a.scopes) { console.log(requiredScopes(graph.actions).join(" ") || C.dim("(no repo-delivery scopes)")); return; } if (a.feed || a.verb || a.subjectType) { const query = { verb: a.verb, includeStale: a.includeStale }; diff --git a/resolver/fixtures/demo.mjs b/resolver/fixtures/demo.mjs index 80e0fe6..5817780 100644 --- a/resolver/fixtures/demo.mjs +++ b/resolver/fixtures/demo.mjs @@ -25,7 +25,7 @@ const readerCaps = [ { name: "tags", kind: "string" }, ], produces: ["app.skyreader.feed.subscription"], - delivery: "pds", + delivery: "repo", scope: "include:app.skyreader.subscribe", createdAt: "2026-06-26T00:00:00Z", }, @@ -41,7 +41,7 @@ const readerCaps = [ { name: "note", kind: "string" }, ], produces: ["site.standard.document"], - delivery: "pds", + delivery: "repo", scope: "include:app.skyreader.linkblog", createdAt: "2026-06-26T00:00:00Z", }, diff --git a/resolver/resolver.mjs b/resolver/resolver.mjs index e4138d2..1844b29 100644 --- a/resolver/resolver.mjs +++ b/resolver/resolver.mjs @@ -145,6 +145,7 @@ export async function resolveActionGraph(actor, opts = {}) { actions.push({ verb: cap.verb, subject: cap.subject || [], + input: cap.input || [], handler: h.app, name: cap.name, delivery: cap.delivery, @@ -179,11 +180,11 @@ export function matchActions(graph, { verb, subject, includeStale = false } = {} // --- scope aggregation: what a consumer must request to enable these ---------- -// Union of include: permission sets across pds-delivery actions. +// Union of include: permission sets across repo-delivery actions. export function requiredScopes(actions) { const set = new Set(); for (const a of actions) { - if (a.delivery === "pds" && a.scope) set.add(a.scope); + if (a.delivery === "repo" && a.scope) set.add(a.scope); } return [...set]; } diff --git a/tools/examples/all.capabilities.json b/tools/examples/all.capabilities.json index c30aa26..cdf90d9 100644 --- a/tools/examples/all.capabilities.json +++ b/tools/examples/all.capabilities.json @@ -12,7 +12,7 @@ { "kind": "did", "as": "account" } ], "produces": ["app.skyreader.feed.subscription"], - "delivery": "pds", + "delivery": "repo", "scope": "include:app.skyreader.subscribe" }, { @@ -26,7 +26,7 @@ { "name": "note", "kind": "string" } ], "produces": ["site.standard.document"], - "delivery": "pds", + "delivery": "repo", "scope": "include:app.skyreader.linkblog" }, { diff --git a/tools/examples/subscribe.capability.json b/tools/examples/subscribe.capability.json index fb74e63..a83d509 100644 --- a/tools/examples/subscribe.capability.json +++ b/tools/examples/subscribe.capability.json @@ -1,5 +1,5 @@ { - "_comment": "A `subscribe` capability with delivery=pds. The consumer writes a `produces` record into the user's repo; the app reads it. Exercises a union subject: a bare feed URL (no atproto type), a publication AT-URI, and an account DID — matches if ANY entry fits. The _comment field is ignored by the writer.", + "_comment": "A `subscribe` capability with delivery=repo. The consumer writes a `produces` record into the user's repo; the app reads it. Exercises a union subject: a bare feed URL (no atproto type), a publication AT-URI, and an account DID — matches if ANY entry fits. The _comment field is ignored by the writer.", "name": "Skyreader", "icon": "https://skyreader.app/icon.png", "description": "Subscribe to a feed, a standard.site publication, or an account so its new items appear in your Skyreader reading list. Use for following a source over time; not for saving a single article (that's `save`).", @@ -14,6 +14,6 @@ { "name": "tags", "kind": "string" } ], "produces": ["app.skyreader.feed.subscription"], - "delivery": "pds", + "delivery": "repo", "scope": "include:app.skyreader.subscribe" } diff --git a/tools/lib/capability.mjs b/tools/lib/capability.mjs index 62933f5..0e6e29a 100644 --- a/tools/lib/capability.mjs +++ b/tools/lib/capability.mjs @@ -11,7 +11,7 @@ const VERBS = ["share", "save", "subscribe", "annotate", "open"]; const SUBJECT_KINDS = ["uri", "at-uri", "did", "string", "blob"]; const SUBJECT_AS = ["record", "collection", "account"]; const INPUT_KINDS = ["string", "uri", "at-uri", "did", "integer", "boolean", "blob"]; -const DELIVERY = ["passive", "pds", "service"]; +const DELIVERY = ["passive", "repo", "service"]; const RECORD_FIELDS = [ "name", "icon", "description", "verb", "subject", @@ -68,13 +68,34 @@ export function validateCapability(cap) { if (!DELIVERY.includes(cap.delivery)) { errors.push(`delivery: required, must be one of ${DELIVERY.join(", ")}`); } else if (cap.delivery === "service") { - if (!cap.endpoint) errors.push("delivery=service requires an endpoint (the URL that performs the action)"); + if (!cap.endpoint) { + errors.push("delivery=service requires an endpoint (the XRPC method URL that performs the action)"); + } else { + // endpoint must be an XRPC method URL: https://host/xrpc/ + let url = null; + try { url = new URL(cap.endpoint); } catch { url = null; } + if (!url || !/^https?:$/.test(url.protocol)) { + errors.push("delivery=service endpoint must be an absolute http(s) URL"); + } else if (!/^\/xrpc\/[^/]+$/.test(url.pathname)) { + warnings.push("delivery=service endpoint should be an XRPC method URL: https://host/xrpc/"); + } + } if (cap.produces?.length) warnings.push("delivery=service usually produces nothing on the PDS; the handler ingests out-of-band"); if (cap.scope) warnings.push("delivery=service does not need a PDS scope"); - } else if (cap.delivery === "pds") { - if (!cap.produces?.length) warnings.push("delivery=pds normally lists the record type(s) it writes in `produces`"); - if (!cap.scope) warnings.push("delivery=pds normally declares a `scope` (include: permission set) so consumers can request it"); - if (cap.endpoint) warnings.push("delivery=pds ignores `endpoint`"); + // verb=open maps to an XRPC GET query, which can't carry a blob body. + if (cap.verb === "open") { + const blobSubject = Array.isArray(cap.subject) && cap.subject.some((s) => s && s.kind === "blob"); + const blobInput = Array.isArray(cap.input) && cap.input.some((f) => f && f.kind === "blob"); + if (blobSubject || blobInput) + errors.push("verb=open maps to an XRPC GET query and cannot carry a blob; use a non-open (POST) verb for blob subject/input"); + } + // `subject` is the reserved param name carrying the matched subject value. + if (Array.isArray(cap.input) && cap.input.some((f) => f && f.name === "subject")) + warnings.push('input named "subject" collides with the reserved subject param for delivery=service; rename it'); + } else if (cap.delivery === "repo") { + if (!cap.produces?.length) warnings.push("delivery=repo normally lists the record type(s) it writes in `produces`"); + if (!cap.scope) warnings.push("delivery=repo normally declares a `scope` (include: permission set) so consumers can request it"); + if (cap.endpoint) warnings.push("delivery=repo ignores `endpoint`"); } else if (cap.delivery === "passive") { if (cap.produces?.length) warnings.push("delivery=passive writes nothing; drop `produces`"); if (cap.scope) warnings.push("delivery=passive needs no scope; drop it (zero-cost discovery)"); diff --git a/tools/write-capability-interactive.mjs b/tools/write-capability-interactive.mjs index 7401896..2028134 100755 --- a/tools/write-capability-interactive.mjs +++ b/tools/write-capability-interactive.mjs @@ -94,14 +94,14 @@ async function main() { console.log(C.dim("\nDelivery — how the action actually reaches your app:")); console.log(C.dim(" passive your app READS a record already in the user's repo (zero-cost)")); - console.log(C.dim(" pds the consumer WRITES record(s) to the user's repo; your app reads them")); + console.log(C.dim(" repo the consumer WRITES record(s) to the user's repo; your app reads them")); console.log(C.dim(" service the consumer must CALL your endpoint; a PDS write won't reach you")); - cap.delivery = await ask("delivery (passive/pds/service):", "pds"); + cap.delivery = await ask("delivery (passive/repo/service):", "repo"); if (cap.delivery === "service") { cap.endpoint = await ask(" endpoint URL (the API/XRPC that performs the action):"); } - if (cap.delivery === "pds") { + if (cap.delivery === "repo") { const produces = await ask(" produces — record NSID(s) you write, comma-separated:"); if (produces) cap.produces = produces.split(",").map((s) => s.trim()).filter(Boolean); cap.scope = await ask(" scope (include: permission set):"); -- 2.51.2