From ee9a4176dc72446a5cdebd61b069217a6df47f2d Mon Sep 17 00:00:00 2001 From: Tim Disney Date: Sun, 28 Jun 2026 11:07:38 -0700 Subject: [PATCH] productionize demo --- demo/.gitignore | 1 + demo/README.md | 231 ++++++---- demo/client-metadata.json | 12 + demo/fixtures.mjs | 56 --- demo/index.html | 14 +- demo/package-lock.json | 887 ++++++++++++++++++++++++++++++++++++++ demo/package.json | 20 + demo/route.mjs | 133 ------ demo/serve.mjs | 44 +- demo/share.html | 105 +++-- demo/share.mjs | 111 ----- demo/src/auth.mjs | 107 +++++ demo/src/execute.mjs | 126 ++++++ demo/src/scopes.mjs | 56 +++ demo/src/share.mjs | 376 ++++++++++++++++ resolver/resolver.mjs | 1 + 16 files changed, 1842 insertions(+), 438 deletions(-) create mode 100644 demo/.gitignore create mode 100644 demo/client-metadata.json delete mode 100644 demo/fixtures.mjs create mode 100644 demo/package-lock.json create mode 100644 demo/package.json delete mode 100644 demo/route.mjs delete mode 100644 demo/share.mjs create mode 100644 demo/src/auth.mjs create mode 100644 demo/src/execute.mjs create mode 100644 demo/src/scopes.mjs create mode 100644 demo/src/share.mjs diff --git a/demo/.gitignore b/demo/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/demo/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/demo/README.md b/demo/README.md index 0edde59..069e01c 100644 --- a/demo/README.md +++ b/demo/README.md @@ -1,113 +1,180 @@ -# Share bookmarklet — a browser consumer demo +# Share sheet — a real browser consumer -A working **consumer** of AT Intents: a bookmarklet that shares the current web +A working **consumer** of AT Intents: a browser app 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: +hardcoded — the handlers are discovered from your repo footprint (the same loop +the [resolver](../resolver/) runs on the CLI), and the actions run **for real** +against a live PDS over OAuth. ``` -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 +your handle + │ + ├─ 1 DISCOVER scan repo for dev.at-intent.usage → resolve each app's (read-only, + │ dev.at-intent.capability records → one action graph no auth) + │ + ├─ 1b MATCH keep actions whose subject accepts this page (a bare `uri`); (resolver.mjs + │ the rest are shown but greyed, with the subject they'd need subjectMatches) + │ + ├─ 2 CONSENT compute the minimal granular OAuth scopes the APPLICABLE (scopes.mjs) + │ handlers declared, and request exactly them — nothing more + │ + └─ 3 ACT run any action against the page, by `delivery`: (execute.mjs) + repo → com.atproto.repo.createRecord into your repo + service → XRPC proxied through your PDS (atproto-proxy) + open → navigate; the handler's own session authenticates ``` +The order matters: because we request **granular** scopes (`repo:…` / `rpc:…`, +not blanket write access), we must know *which* collections and methods to ask +for — so discovery runs first, read-only, then consent requests precisely that +set. That's AT Intents' whole pitch made concrete: ask for what the repo says is +needed, and nothing else. + +Subject constraints are part of that: a capability declares which subjects it +accepts (a `uri`, a `did` account, an `at-uri` of some collection…). The share +target is a bare web URL, so an action like "follow an account or publication" +can't apply — `subjectMatches` filters it out of the runnable set, drops its scope +from the request, and the UI lists it under "Doesn't apply to this page" with the +subject it would need. Same matcher the [resolver](../resolver/) and CLI use. + ## Run it ```bash -node demo/serve.mjs # → http://localhost:8787/demo/ +cd demo +npm install +npm run dev # builds the bundle, then serves → http://127.0.0.1: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: +Use **`127.0.0.1`, not `localhost`** — atproto's loopback OAuth client requires a +`127.0.0.1` redirect URI. `npm run dev` runs esbuild (`src/*.mjs` → `dist/share.js`) +then the zero-dep static server. While iterating, `npm run watch` rebuilds on save. -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. +Then: -## What you'll see +1. Open `http://127.0.0.1:8787/demo/` and drag **Share with…** to your bookmarks + bar (or hit **Try the share sheet**). +2. In the sheet, enter your handle and **Discover apps** — read-only, no login. +3. Review the grouped verbs and the exact scopes, then **Connect** to sign in at + your PDS and grant them. +4. Back in the sheet, run any action — it executes live against the target page. -The share sheet lists the demo user's apps that can share a URL and shows how -each one is routed: +## Auth: loopback dev vs hosted -| 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 proxied through your PDS (`atproto-proxy` header), with the page URL as the `subject` param | +`src/auth.mjs` picks a client from the hostname: -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. +- **loopback** (`127.0.0.1`): the special `http://localhost?redirect_uri=…&scope=…` + client — no hosted metadata needed. The granted scope is dynamic, so it's stashed + and the same `client_id` is rebuilt when the redirect returns. +- **hosted**: a real [`client-metadata.json`](./client-metadata.json) served next + to the page; `client_id` is that file's URL. To deploy, replace `YOUR_HOST`, + host the file at its own `client_id` URL, and keep its `scope` a **superset** of + what any sign-in requests (it ships with `repo:*` plus the demo handler's `rpc:` + auds). + +PKCE, DPoP, token refresh, and session storage are handled by +[`@atproto/oauth-client-browser`](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser): +on the next visit `initAuth()` silently restores a live session and goes straight +to the connected view. We additionally remember the user's `{did, handle}` so a +returning visitor whose session has lapsed is greeted by handle, pre-filled, and +one click from reconnecting (cleared on explicit sign-out). ## 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 | +| `share.html` | the sheet shell + styles; loads the built `dist/share.js` | +| `src/share.mjs` | the flow: discover → consent → execute, grouped by verb | +| `src/auth.mjs` | atproto OAuth (loopback + hosted), session → `@atproto/api` Agent | +| `src/scopes.mjs` | action graph → minimal granular `repo:` / `rpc:` scopes | +| `src/execute.mjs` | the real `act`: `createRecord`, PDS-proxied XRPC, navigation | +| `client-metadata.json` | hosted OAuth client template | +| `serve.mjs` | zero-dep static server (binds `127.0.0.1`) | + +Discovery is upstream code: `resolveActionGraph` from +[`../resolver/resolver.mjs`](../resolver/resolver.mjs), unchanged except that it +now carries `serviceId` through to each action (needed for the proxy `aud`). + +## Implementing service calls against `@atproto/api` + +The [service wire contract](../docs/capability-spec.md#service-wire-contract-xrpc) +is unambiguous: `subject` + scalar inputs go in the **query string**, only a `blob` +goes in the **body**. But `@atproto/api`'s generic `agent.call(nsid, params, data, opts)` +adds a library-specific wrinkle that the spec (rightly) doesn't mention — and that +is easy to get wrong: + +- **`call()` needs the method in its local lexicon registry first.** It looks the + NSID up *before* making any request — to choose GET vs POST and to know which + query params are valid. A handler's `app.foo.*` method isn't bundled, so an + unregistered NSID throws `Lexicon not found` **with zero network traffic** (no + request shows up in devtools). +- **Undeclared query params are rejected.** `call()` only appends params that are + declared in the method's `parameters` block, and throws `Invalid query parameter` + on any that aren't. Passing `{ subject }` as the `params` arg is not enough. + +So before calling, register a minimal stub lexicon declaring the contract's params +(`subject` + each scalar input), then pass them as `params` and leave `data`/body +empty (`execute.mjs` → `ensureProcedure`): + +```js +agent.lex.add({ + lexicon: 1, + id: lxm, + defs: { main: { type: "procedure", parameters: { type: "params", + properties: { subject: { type: "string" }, /* + each scalar input */ } } } }, +}); +await agent.call(lxm, { subject: page.url, ...scalarInputs }, undefined, { + headers: { "atproto-proxy": `${handlerDid}#${serviceId}` }, +}); +``` -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. +Output has no `schema`, so any response the handler returns is accepted. Putting +`subject` in `data` instead of `params` is what produces a handler-side +`Missing required param: subject`. -## Going live +## Scope mapping -To run against a real repo instead of fixtures, drop the `fixtures` option: +`scopesForGraph` unions one rule per action (`atproto` is always included): -```js -const graph = await resolveActionGraph("alice.bsky.social"); // real repo scan +| delivery | scope requested | +|---|---| +| `repo` | the capability's own `repo:` (or `repo:?action=create`) | +| `service` (non-`open`) | `rpc:?aud=#` | +| `open` / `passive` | none (navigation / already-readable record) | + +For the demo footprint (`photos.disnetdev.com` → Skyreader) that's: + +``` +atproto +repo:site.standard.graph.subscription +rpc:app.skyreader.linkblog.share?aud=did:plc:ra4jsemddo2ii4pn5jaf6x4v#skyreader_api +rpc:app.skyreader.feed.subscribe?aud=did:plc:ra4jsemddo2ii4pn5jaf6x4v#skyreader_api +rpc:app.skyreader.feed.save?aud=did:plc:ra4jsemddo2ii4pn5jaf6x4v#skyreader_api ``` -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 proxying** (what the demo's inert output shows): call your *own* PDS - at the same `/xrpc/` path with an - `atproto-proxy: #` header (the DID comes from discovery, - the `serviceId` fragment from the capability record). Your PDS resolves the - handler's DID, mints the inter-service JWT, and forwards the call. This works - **fully in the browser** with the session a SPA already holds (a supported - atproto OAuth public client — PKCE + DPoP), and crucially it sidesteps CORS: - you only ever fetch your own PDS, never the third-party handler. 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. - - **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. - -Proxying is what dissolves the **CORS** problem a direct cross-origin call to the -handler would hit (the third-party service would have to send -`Access-Control-Allow-Origin` for your origin, which you don't control). It -requires the handler to publish a matching service entry (`serviceId`) in its DID -document. - -**Unverified — test against a live PDS before relying on this path:** -- **Will a real PDS proxy to an arbitrary third-party handler at all?** Service - proxying was designed around canonical network services (AppView, labeler, - feed-gen). The reference PDS forwards on any DID-doc service entry with no - allowlist, so an `app.chirp.*` handler *should* work — but whether a production - PDS (e.g. Bluesky's) will mint a user-signed JWT toward an arbitrary app DID is - the load-bearing assumption here, and it needs a live test, not just a spec read. -- **Does the OAuth session's scope permit it?** Whether the proposal-0011 scope a - browser session holds allows proxied calls to an arbitrary handler/`lxm`. +## Verified vs. load-bearing assumptions + +Verified against the live `bsky.social` PDS: read-only discovery in the browser +(CORS to `plc.directory` + the PDS is fine), scope computation, and the OAuth +redirect — the authorization server **accepts the loopback client and all granular +scopes** (the pushed-authorization request succeeds and the consent screen renders). + +Still needs a real end-to-end run (they depend on a completed login, which can't be +automated): + +- **Will a production PDS proxy to an arbitrary third-party handler?** Service + proxying was designed around canonical services (AppView, labeler, feed-gen). + The reference PDS forwards on any DID-doc service entry with no allowlist, but + whether Bluesky's PDS will mint a user-signed JWT toward an arbitrary app DID is + the load-bearing assumption — `execute.mjs` makes the call; a live test confirms it. +- **Does the granted `rpc:` scope actually permit the proxied call?** We request it; + the PDS enforces it. +- **Record shape for `repo` writes.** We don't have the handler's lexicon, so we + synthesize a minimal record and write with `validate: false`. A first-party + consumer would build the exact shape. - **Trust:** proxying makes *your* PDS sign a JWT under your identity toward a DID - that surfaced from a **self-asserted** usage record — so per the trust model, - **verify the handler's authority before acting**. + that surfaced from a **self-asserted** usage record — verify the handler's + authority before acting. -See -[docs/consumers.md](../docs/consumers.md) and +See [docs/consumers.md](../docs/consumers.md) and [docs/capability-spec.md](../docs/capability-spec.md#service-wire-contract-xrpc). diff --git a/demo/client-metadata.json b/demo/client-metadata.json new file mode 100644 index 0000000..156dc24 --- /dev/null +++ b/demo/client-metadata.json @@ -0,0 +1,12 @@ +{ + "client_id": "https://YOUR_HOST/demo/client-metadata.json", + "client_name": "AT Intents share demo", + "client_uri": "https://YOUR_HOST/demo/", + "redirect_uris": ["https://YOUR_HOST/demo/share.html"], + "scope": "atproto repo:* rpc:app.skyreader.linkblog.share?aud=did:plc:ra4jsemddo2ii4pn5jaf6x4v#skyreader_api rpc:app.skyreader.feed.subscribe?aud=did:plc:ra4jsemddo2ii4pn5jaf6x4v#skyreader_api rpc:app.skyreader.feed.save?aud=did:plc:ra4jsemddo2ii4pn5jaf6x4v#skyreader_api", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "application_type": "web", + "dpop_bound_access_tokens": true +} diff --git a/demo/fixtures.mjs b/demo/fixtures.mjs deleted file mode 100644 index 5044cab..0000000 --- a/demo/fixtures.mjs +++ /dev/null @@ -1,56 +0,0 @@ -// 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", - serviceId: "chirp_api", // service-endpoint fragment in Chirp's DID doc, for atproto-proxy - 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 index 1bbe0e3..9d69548 100644 --- a/demo/index.html +++ b/demo/index.html @@ -59,17 +59,19 @@

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. +
  9. Discover (no auth): scan your repo for dev.at-intent.usage + records and resolve each app's dev.at-intent.capability records into one action graph.
  10. +
  11. Consent: compute the minimal granular OAuth scopes those handlers + declared they need, and grant exactly them when you sign in to your PDS.
  12. +
  13. Act: run any action for real, routed by delivery + (repo writes a record; service calls an endpoint proxied through your PDS).

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. + Resolves a real repo over the network, then executes against a live PDS over OAuth, + requesting only the scopes the discovered handlers need. See docs/consumers.md.
diff --git a/demo/package-lock.json b/demo/package-lock.json new file mode 100644 index 0000000..1eabae7 --- /dev/null +++ b/demo/package-lock.json @@ -0,0 +1,887 @@ +{ + "name": "at-intents-demo", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "at-intents-demo", + "version": "0.0.0", + "dependencies": { + "@atproto/api": "^0.20.22", + "@atproto/oauth-client-browser": "^0.4.3" + }, + "devDependencies": { + "esbuild": "^0.28.1" + } + }, + "node_modules/@atproto-labs/did-resolver": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.3.3.tgz", + "integrity": "sha512-AOzZVvBCaMlmRlYDNfAfIVLIvvsHV97z3yajQkjKngt+G+HDMxsKj+RBPmqpSYz6EzI3g+XH+rGIRjXsBEj6cg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "^0.3.2", + "@atproto-labs/pipe": "^0.2.2", + "@atproto-labs/simple-store": "^0.4.2", + "@atproto-labs/simple-store-memory": "^0.2.2", + "@atproto/did": "^0.5.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/fetch": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.3.2.tgz", + "integrity": "sha512-NWNQ+MLNEqXpxsfM9mG14eVo0n5z8oWU7yjz/kCROAw5HG+i8a44tQtic90qXw3rBVxFBrrgE4kMftA7jeOiCw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/pipe": "^0.2.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/handle-resolver": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.4.3.tgz", + "integrity": "sha512-GHaqceAzOs4p/XP77yaPIbZlrdD0PPDCw4DS4RyxvvMZFbvnjPsHQOigCRYGdxsf3WgyqSxBlIzTl0jO7j9Q+A==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "^0.4.2", + "@atproto-labs/simple-store-memory": "^0.2.2", + "@atproto/did": "^0.5.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/identity-resolver": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.4.2.tgz", + "integrity": "sha512-OKrTzb/2DWu7w4wahutEG4Y5fL5UQlosZ1y+dldYsYwEk+PnY3CHX9RCuE36nREQ2dICNWAdaKM3z6b0ZlB2QA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.3", + "@atproto-labs/handle-resolver": "^0.4.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/pipe": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.2.2.tgz", + "integrity": "sha512-4xCUqB96I7WWGRlJvkJeOi8fpkrbB5pwW471hpJefqgb8Ep0UHP1solF3QOK+fms42cTfwkZCx3ExfGk6xeOuA==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/simple-store": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.4.2.tgz", + "integrity": "sha512-kWF6GCJBWeG+nwakUInainJi2pb4lukQ4ffkpEpLaavZ7ogKSrbsvA7ZFJWGjVsJNVxcamtXeLEl1NpFufs/4g==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/simple-store-memory": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.2.2.tgz", + "integrity": "sha512-uhEO3j9I09ksdJxYK0B9hl6X5ZUZ+poUBBq6qpulOjbIlzFPhRG/i23ZnzHhQaeT17LQBeP8xvSPk1QzDsblMA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "^0.4.2", + "lru-cache": "^10.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/api": { + "version": "0.20.22", + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.20.22.tgz", + "integrity": "sha512-TdT9ktYc0FMmMZ8HjkMz13QcSCcYiesCGN81mFZd5owQASHVM9GBtAAmzpkrPyZhUkiIjgh/nUHk8xWVu+4dVA==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.5.2", + "@atproto/lexicon": "^0.7.3", + "@atproto/syntax": "^0.6.3", + "@atproto/xrpc": "^0.8.2", + "await-lock": "^3.0.0", + "multiformats": "^13.0.0", + "tlds": "^1.234.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/common-web": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.5.2.tgz", + "integrity": "sha512-oO0JEvM7MM7iXMngq6V51IJlzBZFhFJoDjnGnQT75EO94/8Q5hnM/LP+a8KyWjcBcpcSZwQ0bukNv9phZONdGA==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.1.3", + "@atproto/lex-json": "^0.1.2", + "@atproto/syntax": "^0.6.3", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/did": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.5.2.tgz", + "integrity": "sha512-ilVQ1Nrio6EhL61mt7zPgM0Vnb1N2mOisVP32bUBa+PflGI0wRneflacWyVyyN78RdFJDcPQBmv0dfSNgOcbQA==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/jwk": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.7.2.tgz", + "integrity": "sha512-CnLjMXgbUEstC2gsSE0Nc4hEgA7oS7HJAFRnI4gBNzAckHJqLqvAqj2whhM9VlcnY/dUZSnAskZ11pgBU/go+w==", + "license": "MIT", + "dependencies": { + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/jwk-jose": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.2.2.tgz", + "integrity": "sha512-iM+iEpn+hBHSDeeONcsx/fBdh2i6AnwtcmpbWwEdxYkLMtZvQ54hLJsuCpPt4gMe1ctGAOb8XmKIaQLrRE3oyQ==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "^0.7.2", + "jose": "^5.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/jwk-webcrypto": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.3.2.tgz", + "integrity": "sha512-4F4bVOUUfGGkFlG+7BIrctoD5wh6wyNH66PsKmFGhDsoOcjwEGjTq1+MJ24/YqjW2r9qqsxTJZ02Hv4zVA3suQ==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "^0.7.2", + "@atproto/jwk-jose": "^0.2.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/lex-data": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.1.3.tgz", + "integrity": "sha512-ysqMYW6cIKce52/+EIbTa+I4pLqZQSBP9aGImN88vAQtd1oZBKPmut6dQk00xSQ8PW9REJRuIHNSFAF4HD+fsw==", + "license": "MIT", + "dependencies": { + "multiformats": "^13.0.0", + "tslib": "^2.8.1", + "uint8arrays": "^5.0.0", + "unicode-segmenter": "^0.14.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/lex-json": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.1.2.tgz", + "integrity": "sha512-58nIjoWX0c8T5fcoPPmIaShxKZgfPMhNmrprtR7GuN4PzyMwm59A3CLlKAzzR+vjI3IidEMU+enpLuwUT8L+Nw==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/lexicon": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.7.3.tgz", + "integrity": "sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.5.2", + "@atproto/syntax": "^0.6.3", + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.7.5.tgz", + "integrity": "sha512-B9VLopSCxu8z05+3DwMmhlMR+jIh49FQ/jJN8ZUCFSInRpX/6TcnnwhJWQoYxMaQ0orcul9dCucxTZhtp/W/uA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.3", + "@atproto-labs/fetch": "^0.3.2", + "@atproto-labs/handle-resolver": "^0.4.3", + "@atproto-labs/identity-resolver": "^0.4.2", + "@atproto-labs/simple-store": "^0.4.2", + "@atproto-labs/simple-store-memory": "^0.2.2", + "@atproto/did": "^0.5.2", + "@atproto/jwk": "^0.7.2", + "@atproto/oauth-types": "^0.7.3", + "@atproto/xrpc": "^0.8.2", + "core-js": "^3", + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.4.3.tgz", + "integrity": "sha512-0BRy108X9dWPxpNv0RS4mNDjtaRsRTeg13xEFFDPHRAHz3++TBnqPzOkxO4w6f9OwDdG6spzt9OMKkl9l+Ou5g==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.3", + "@atproto-labs/handle-resolver": "^0.4.3", + "@atproto-labs/simple-store": "^0.4.2", + "@atproto/did": "^0.5.2", + "@atproto/jwk": "^0.7.2", + "@atproto/jwk-webcrypto": "^0.3.2", + "@atproto/oauth-client": "^0.7.5", + "@atproto/oauth-types": "^0.7.3", + "core-js": "^3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-types": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.7.3.tgz", + "integrity": "sha512-ndtwZQDawNLG6UBeJ31oXbtcITFbR+h48vAvyLs1FyZcXX6tEHniuG2Sd+4sUsrzHAMS/PGAEjUpTijObQIXzw==", + "license": "MIT", + "dependencies": { + "@atproto/did": "^0.5.2", + "@atproto/jwk": "^0.7.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/syntax": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.6.3.tgz", + "integrity": "sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==", + "license": "MIT", + "dependencies": { + "iso-datestring-validator": "^2.2.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/xrpc": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.8.2.tgz", + "integrity": "sha512-geLqJwazZuCGnae68KZppciS8DujhGvYtpc/aAj16XpHkUCf5Ds64H1IPrV0uv7SFvd/IAuMZAXZS4GIpfp1iw==", + "license": "MIT", + "dependencies": { + "@atproto/lexicon": "^0.7.3", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/await-lock": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-3.0.0.tgz", + "integrity": "sha512-eO6fLiSnrJrMdjWMNK8zbVRXPs2TKJg78iKZd9wDpN3na5tcoV6EoeiOlMgk2QaAQ1gIrK1YuMsJHXWqz89tSA==", + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/iso-datestring-validator": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", + "license": "MIT" + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/uint8arrays": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.1.tgz", + "integrity": "sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^13.0.0" + } + }, + "node_modules/unicode-segmenter": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..23849c8 --- /dev/null +++ b/demo/package.json @@ -0,0 +1,20 @@ +{ + "name": "at-intents-demo", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "AT Intents share-sheet demo — a real browser consumer: OAuth login, discover the action graph, request granular scopes, execute live.", + "scripts": { + "build": "esbuild src/share.mjs --bundle --format=esm --outfile=dist/share.js --sourcemap", + "watch": "esbuild src/share.mjs --bundle --format=esm --outfile=dist/share.js --sourcemap --watch", + "serve": "node serve.mjs", + "dev": "npm run build && node serve.mjs" + }, + "dependencies": { + "@atproto/api": "^0.20.22", + "@atproto/oauth-client-browser": "^0.4.3" + }, + "devDependencies": { + "esbuild": "^0.28.1" + } +} diff --git a/demo/route.mjs b/demo/route.mjs deleted file mode 100644 index dd7edc5..0000000 --- a/demo/route.mjs +++ /dev/null @@ -1,133 +0,0 @@ -// 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 browser-native answer is *service proxying* - // (https://atproto.com/specs/xrpc): the consumer calls its OWN PDS at the same - // /xrpc/ path and adds an `atproto-proxy` header naming the handler. The - // PDS resolves the handler's DID, mints the inter-service JWT itself, and - // forwards the call. Two wins over a direct call: (1) you only ever talk to your - // own PDS, so there's no third-party CORS to control; (2) no separate - // getServiceAuth round-trip — the PDS does the token minting. - // 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)"; - // The proxy target is DID + a service-endpoint fragment from the handler's DID - // document. The capability record carries it as `serviceId`; if absent we show a - // placeholder so the missing piece stays visible rather than silently wrong. - const serviceId = action.serviceId || ""; - const isOpen = action.verb === "open"; - - if (isOpen) { - // GET/open is a navigation, not a background XRPC call: send the user's browser - // to the handler URL and let their existing session there authenticate. - return { - route: "service", - summary: `Open ${action.name} (${method}).`, - scope: null, - auth: { - kind: "browser", - note: "GET/open: navigate the browser to the URL — the user's existing session at the handler (cookies) authenticates. No proxy, no token to mint.", - }, - request: { method, endpoint: url.toString() }, - }; - } - - // Proxy the call through the user's own PDS at the same /xrpc/ path. - const proxied = `https:///xrpc/${lxm}${url.search}`; - return { - route: "service", - summary: `Call ${action.name}'s XRPC method (${method}), proxied through your PDS.`, - scope: null, // the handler's own auth, not a PDS write scope - auth: { - kind: "service-proxy", - note: "Service proxying: call your own PDS and name the handler in `atproto-proxy`. Your PDS resolves the DID, mints the inter-service JWT, and forwards. No third-party CORS, no shared secret, no account linking.", - }, - request: { - method, - endpoint: proxied, - headers: { - "atproto-proxy": `${aud}#${serviceId}`, - // A browser OAuth session is a DPoP-bound public client (PKCE + DPoP), so the - // access token rides as `DPoP`, not `Bearer`, alongside a per-request proof. - Authorization: "DPoP ", - DPoP: "", - }, - }, - }; -} - -// 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 index 7c9849f..837dbde 100644 --- a/demo/serve.mjs +++ b/demo/serve.mjs @@ -1,14 +1,16 @@ #!/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. +// Tiny zero-dep static server for the share demo. Serves the whole repo root so +// the built bundle (demo/dist/share.js) and demo/client-metadata.json are reachable. // -// node demo/serve.mjs # http://localhost:8787/demo/ +// Open the demo at http://127.0.0.1:8787/demo/ — the loopback IP (not "localhost") +// matters: atproto's loopback OAuth client requires a 127.0.0.1 redirect_uri. +// Build the bundle first: `npm run build` (or `npm run dev` to build + serve). +// +// npm run dev # build, then http://127.0.0.1:8787/demo/ // PORT=9000 node demo/serve.mjs import { createServer } from "node:http"; -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { extname, join, normalize } from "node:path"; @@ -23,22 +25,38 @@ const TYPES = { ".css": "text/css; charset=utf-8", }; +const redirect = (res, location) => { res.writeHead(302, { location }).end(); }; + 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"; + const url = new URL(req.url, "http://localhost"); + const path = decodeURIComponent(url.pathname); + // Send the bare root to the demo with a REAL redirect (not an internal rewrite): + // the demo's HTML uses relative imports (share.html, ./dist/share.js), and the + // bookmarklet builds its target from location.href — both only resolve correctly + // when the browser's URL actually carries the /demo/ base path. + if (path === "/") { redirect(res, "/demo/"); return; } + // 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" }); + + let target = file; + const info = await stat(file).catch(() => null); + if (info?.isDirectory()) { + // /demo -> /demo/ so relative links resolve against the directory, not its parent + if (!path.endsWith("/")) { redirect(res, path + "/" + url.search); return; } + target = join(file, "index.html"); + } + + const body = await readFile(target); + res.writeHead(200, { "content-type": TYPES[extname(target)] || "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`); +server.listen(PORT, "127.0.0.1", () => { + console.log(`\n AT Intents share demo → http://127.0.0.1:${PORT}/demo/\n`); }); diff --git a/demo/share.html b/demo/share.html index 6244807..42850f2 100644 --- a/demo/share.html +++ b/demo/share.html @@ -8,11 +8,14 @@ :root { --ink: #16181c; --dim: #5b6370; --line: #e6e8eb; --bg: #fff; --accent: #2b6fff; --repo: #2b6fff; --service: #c2820a; --passive: #1f9d57; + --ok: #1f9d57; --err: #d23f4a; + /* type scale — ~1.27 ratio between steps for clear hierarchy */ + --t-label: 12px; --t-body: 15px; --t-strong: 19px; --t-head: 24px; } * { box-sizing: border-box; } body { - margin: 0; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; - color: var(--ink); background: #f4f5f7; padding: 20px; font-size: 15px; + margin: 0; font: var(--t-body)/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + color: var(--ink); background: #f4f5f7; padding: 20px; } .sheet { max-width: 480px; margin: 0 auto; background: var(--bg); @@ -20,52 +23,81 @@ box-shadow: 0 8px 30px rgba(0,0,0,.08); } header { padding: 16px 18px; border-bottom: 1px solid var(--line); } - header h1 { margin: 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--dim); } - header .who { color: var(--dim); font-size: 12px; margin-top: 2px; } + header h1 { margin: 0; font-size: var(--t-strong); font-weight: 700; letter-spacing: -.01em; } + header .who { color: var(--dim); font-size: var(--t-label); margin-top: 3px; } + header .who #conn { color: var(--ink); } .target { padding: 14px 18px; background: #fafbfc; border-bottom: 1px solid var(--line); } .target .t { font-weight: 600; overflow-wrap: anywhere; } - .target .u { color: var(--dim); font-size: 12px; overflow-wrap: anywhere; } - #handlers { padding: 8px 18px 4px; } - .handler { padding: 14px 0; border-bottom: 1px solid var(--line); } + .target .u { color: var(--dim); font-size: var(--t-label); overflow-wrap: anywhere; } + + #body { padding: 16px 18px 4px; } + .lede { margin: 0 0 14px; } + .muted { color: var(--dim); } + .small { font-size: var(--t-label); } + .error { color: var(--err); } + + h2.sec { font-size: var(--t-label); text-transform: uppercase; letter-spacing: .05em; + color: var(--dim); font-weight: 600; margin: 20px 0 8px; } + + .verb-group { margin: 6px 0 14px; } + .excluded { margin-top: 10px; opacity: .6; } + .verb { + font-size: var(--t-label); text-transform: uppercase; letter-spacing: .06em; + color: var(--accent); font-weight: 700; margin: 14px 0 4px; + } + + .handler { padding: 12px 0; border-bottom: 1px solid var(--line); } .handler:last-child { border-bottom: 0; } + .handler.preview { padding: 8px 0; } .handler-head { display: flex; align-items: center; gap: 8px; } - .handler-head strong { font-size: 19px; } + .handler-head strong { font-size: var(--t-body); font-weight: 600; } .delivery { - font-size: 11px; padding: 1px 7px; border-radius: 99px; text-transform: uppercase; - letter-spacing: .03em; color: #fff; + font-size: 10px; padding: 2px 7px; border-radius: 99px; text-transform: uppercase; + letter-spacing: .04em; color: #fff; font-weight: 600; } .delivery[data-route="repo"] { background: var(--repo); } .delivery[data-route="service"] { background: var(--service); } .delivery[data-route="passive"] { background: var(--passive); } - .desc { color: var(--dim); margin: 6px 0 10px; font-size: 12px; } + .desc { color: var(--dim); margin: 5px 0 8px; font-size: var(--t-label); } + .field { display: flex; flex-direction: column; gap: 3px; margin: 8px 0; } - .field span { font-size: 12px; color: var(--dim); } + .field span { font-size: var(--t-label); color: var(--dim); } .field input { - padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; font: inherit; + padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; font: inherit; } - .go { - margin-top: 6px; padding: 8px 14px; border: 0; border-radius: 8px; - background: var(--accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer; + .field input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); } + + button { font: inherit; cursor: pointer; } + .primary { + margin-top: 8px; padding: 9px 15px; border: 0; border-radius: 8px; + background: var(--accent); color: #fff; font-weight: 600; } - .go:hover { filter: brightness(.95); } - .empty { color: var(--dim); padding: 8px 0 16px; } - #action { - margin: 4px 18px 18px; padding: 14px; background: #0f1115; color: #e8eaed; - border-radius: 10px; font-size: 12px; + .primary:hover { filter: brightness(.95); } + .primary:disabled { opacity: .55; cursor: default; } + .ghost { + margin: 18px 0 8px; padding: 7px 13px; border: 1px solid var(--line); + border-radius: 8px; background: #fff; color: var(--dim); } - #action .act-summary { margin: 0 0 8px; } - .route-pill { - display: inline-block; padding: 1px 8px; border-radius: 99px; font-size: 11px; - background: var(--c); color: #fff; text-transform: uppercase; letter-spacing: .03em; + .ghost:hover { background: #f4f5f7; } + + ul.scopes { list-style: none; padding: 0; margin: 4px 0 10px; } + ul.scopes li { margin: 4px 0; } + ul.scopes code, code { + background: #eef0f3; padding: 2px 6px; border-radius: 5px; + font-size: var(--t-label); overflow-wrap: anywhere; } - #action .scope { margin: 8px 0; color: #c9cdd6; } - #action code { background: #23262d; padding: 1px 5px; border-radius: 5px; } - .wire { + + .result { margin: 10px 0 4px; padding: 11px 12px; border-radius: 10px; + background: #0f1115; color: #e8eaed; font-size: var(--t-label); } + .result.ok { box-shadow: inset 3px 0 0 var(--ok); } + .result.error { box-shadow: inset 3px 0 0 var(--err); } + .result .summary { margin: 0 0 6px; } + .result .wire { background: #15181e; padding: 10px; border-radius: 8px; overflow-x: auto; - margin: 8px 0; line-height: 1.4; color: #aee1c0; + margin: 6px 0 0; line-height: 1.4; color: #aee1c0; white-space: pre-wrap; word-break: break-all; } - #action .note { color: #8d94a3; font-size: 12px; margin: 8px 0 0; } - footer { text-align: center; padding: 14px; color: var(--dim); font-size: 12px; } + + footer { text-align: center; padding: 14px; color: var(--dim); font-size: var(--t-label); } footer a { color: var(--accent); } @@ -73,19 +105,18 @@

Share with…

-
apps in your's repo that can share a URL
+
AT Intents · …
…
…
-
- +
- Discovered from the repo footprint — no app list hardcoded. - ← bookmarklet + Discovered live from your repo footprint — no app list hardcoded. + ← about
- + diff --git a/demo/share.mjs b/demo/share.mjs deleted file mode 100644 index 4c6b77a..0000000 --- a/demo/share.mjs +++ /dev/null @@ -1,111 +0,0 @@ -// 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 (proxy via your PDS, or defer to the browser). - if (built.auth) { - parts.push(el("p", { className: "scope" }, "auth: " + built.auth.note)); - } - if (built.request) { - const r = built.request; - const lines = [`${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/demo/src/auth.mjs b/demo/src/auth.mjs new file mode 100644 index 0000000..6d98d28 --- /dev/null +++ b/demo/src/auth.mjs @@ -0,0 +1,107 @@ +// atproto OAuth for the browser, wrapped behind a tiny interface the UI uses: +// +// initAuth() -> process any ?code callback / restore a session +// beginSignIn(handle, []) -> redirect to the user's PDS to grant the scopes +// signOut() -> revoke + forget +// +// Two deployment modes, picked from the hostname: +// • loopback dev (localhost / 127.0.0.1): the special `http://localhost` client, +// whose redirect_uri + scope ride as query params on the client_id. No hosted +// metadata needed. The granted scope is dynamic, so we stash it and rebuild the +// same client_id when the redirect returns (the AS pins client_id consistency). +// • hosted: a real client-metadata.json served next to this page; client_id is +// that file's URL. The per-sign-in scope must be a subset of its `scope`. +// +// PKCE, DPoP, token refresh and session storage (IndexedDB) are all handled by +// @atproto/oauth-client-browser. See atproto.com/specs/oauth. + +import { BrowserOAuthClient } from "@atproto/oauth-client-browser"; +import { Agent } from "@atproto/api"; + +const HANDLE_RESOLVER = "https://bsky.social"; +const SCOPE_KEY = "atint.scope"; // last-granted scope, for loopback client_id rebuild +const ACCOUNT_KEY = "atint.account"; // remembered {did, handle} for a returning user + +let _client = null; +let _session = null; + +// The library already restores a live session across reloads (it stores the sub + +// refresh token). We additionally remember the human identity so we can greet a +// returning user — and pre-fill their handle when the session itself has lapsed. +export function rememberedAccount() { + try { + return JSON.parse(localStorage.getItem(ACCOUNT_KEY) || "null"); + } catch { + return null; + } +} + +// Merge-persist whatever we know so far (handle as soon as it's typed, did once +// discovery/login resolves it). Callable before any network round-trip. +export function rememberAccount(account) { + localStorage.setItem(ACCOUNT_KEY, JSON.stringify({ ...rememberedAccount(), ...account })); +} + +const isLoopback = () => ["localhost", "127.0.0.1", "[::1]"].includes(location.hostname); + +// Come back to this same page; init() consumes the ?code there. +const redirectUri = () => new URL("share.html", location.href).href; + +function makeClient(scope) { + // load() turns a client_id into metadata: a `http://localhost?...` loopback id is + // expanded locally; an https id is fetched as the hosted client-metadata.json. + const clientId = isLoopback() + ? "http://localhost" + + `?redirect_uri=${encodeURIComponent(redirectUri())}` + + `&scope=${encodeURIComponent(scope)}` + : new URL("client-metadata.json", location.href).href; + return BrowserOAuthClient.load({ clientId, handleResolver: HANDLE_RESOLVER }); +} + +async function resolveHandle(agent, did) { + try { + const res = await agent.com.atproto.repo.describeRepo({ repo: did }); + return res.data.handle || did; + } catch { + return did; // handle is cosmetic; fall back to the DID + } +} + +async function sessionInfo(session) { + const agent = new Agent(session); + const did = session.sub; + const handle = await resolveHandle(agent, did); + rememberAccount({ did, handle }); + return { agent, did, handle }; +} + +// Run once on load. Returns the active session info, or null if signed out. +export async function initAuth() { + const scope = localStorage.getItem(SCOPE_KEY) || "atproto"; + _client = await makeClient(scope); + const res = await _client.init(); // handles ?code callbacks AND restores stored sessions + if (res?.session) { + _session = res.session; + return sessionInfo(_session); + } + return null; +} + +// Redirects the browser to the authorization server — does not return. +export async function beginSignIn(handle, scopes) { + const scope = scopes.join(" "); + localStorage.setItem(SCOPE_KEY, scope); // so the post-redirect init() rebuilds the same client_id + const client = await makeClient(scope); + await client.signIn(handle, { scope }); +} + +export async function signOut() { + try { + await _client?.revoke?.(_session?.sub); + } catch { + /* best effort */ + } + localStorage.removeItem(SCOPE_KEY); + localStorage.removeItem(ACCOUNT_KEY); + _session = null; +} diff --git a/demo/src/execute.mjs b/demo/src/execute.mjs new file mode 100644 index 0000000..0ab8eb1 --- /dev/null +++ b/demo/src/execute.mjs @@ -0,0 +1,126 @@ +// The ACT step, for real. Given an authenticated Agent (from the OAuth session), +// a matched action, the target page, and the user's inputs, actually perform the +// action and return a structured result for the UI to render. +// +// repo -> com.atproto.repo.createRecord into the user's own repo +// service -> proxied XRPC through the user's PDS (atproto-proxy header) +// open -> navigate the browser to the handler (its own session authenticates) +// passive -> nothing to send; the handler already reads the repo +// +// Mirrors the inert preview in route.mjs — but fires the request instead of +// returning it. The scope for each was already granted at sign-in (see scopes.mjs). + +import { lxmFromEndpoint, audFor } from "./scopes.mjs"; + +const SCALAR_KINDS = new Set(["string", "uri", "at-uri", "did", "integer", "boolean"]); + +// Keep only declared, non-empty, scalar inputs — keyed by their declared name. +function cleanInputs(action, inputs) { + const out = {}; + for (const f of action.input || []) { + const v = inputs[f.name]; + if (v == null || v === "") continue; + if (SCALAR_KINDS.has(f.kind)) out[f.name] = v; + } + return out; +} + +// delivery: repo — synthesize a minimal record and write it to the user's repo. +// We don't have the handler's lexicon, so `validate: false` lets the write through; +// a real first-party consumer would build the exact record shape. +async function execRepo(agent, action, page, inputs) { + const collection = action.produces?.[0]; + if (!collection) { + return { ok: false, route: "repo", summary: "Capability declares no produces[] record type to write." }; + } + const record = { + $type: collection, + subject: page.url, + ...(page.title ? { title: page.title } : {}), + ...cleanInputs(action, inputs), + createdAt: new Date().toISOString(), + }; + const res = await agent.com.atproto.repo.createRecord({ + repo: agent.assertDid, + collection, + record, + validate: false, + }); + return { + ok: true, + route: "repo", + summary: `Wrote a ${collection} record into your repo.`, + detail: { uri: res.data.uri, cid: res.data.cid }, + }; +} + +// delivery: service, verb=open — a navigation, not a background call. Send the +// browser to the handler; the user's existing session there authenticates. +function execOpen(action, page) { + const url = new URL(action.endpoint); + url.searchParams.set("subject", page.url); + window.open(url.toString(), "_blank", "noopener"); + return { ok: true, route: "open", summary: `Opened ${action.name}.`, detail: { url: url.toString() } }; +} + +const XRPC_PARAM_TYPE = (kind) => + kind === "integer" ? "integer" : kind === "boolean" ? "boolean" : "string"; + +// The Agent's generic call() looks the method up in its local lexicon registry +// BEFORE any request — to know the HTTP method AND which keys are valid query +// params (it throws on any param not declared here). We don't have the handler's +// lexicon, so register a minimal procedure declaring the wire contract's params: +// the reserved `subject` plus each scalar input, all in the query string. Output +// has no schema, so any response the handler returns is accepted. +function ensureProcedure(agent, nsid, action) { + try { + agent.lex.remove(nsid); // re-register so the param set always matches this action + } catch { + /* not registered yet */ + } + const properties = { subject: { type: "string" } }; + for (const f of action.input || []) { + if (SCALAR_KINDS.has(f.kind)) properties[f.name] = { type: XRPC_PARAM_TYPE(f.kind) }; + } + agent.lex.add({ + lexicon: 1, + id: nsid, + defs: { main: { type: "procedure", parameters: { type: "params", properties } } }, + }); +} + +// delivery: service — proxy the XRPC call through the user's own PDS. The PDS +// resolves the handler DID, mints the inter-service JWT, and forwards. We only +// ever talk to our own PDS, so there is no third-party CORS to arrange. Per the +// wire contract, `subject` and scalar inputs ride in the query string. +async function execService(agent, action, page, inputs) { + if (action.verb === "open") return execOpen(action, page); + + const lxm = lxmFromEndpoint(action.endpoint); + if (!lxm) return { ok: false, route: "service", summary: `Could not parse a method NSID from ${action.endpoint}.` }; + + ensureProcedure(agent, lxm, action); + const params = { subject: page.url, ...cleanInputs(action, inputs) }; + const res = await agent.call(lxm, params, undefined, { + headers: { "atproto-proxy": audFor(action) }, + }); + return { + ok: true, + route: "service", + summary: `Called ${lxm} via your PDS proxy.`, + detail: res.data ?? null, + }; +} + +export async function executeAction(agent, action, page, inputs = {}) { + switch (action.delivery) { + case "repo": + return execRepo(agent, action, page, inputs); + case "service": + return execService(agent, action, page, inputs); + case "passive": + return { ok: true, route: "passive", summary: `${action.name} already reads this from your repo — nothing to send.` }; + default: + return { ok: false, route: action.delivery || "unknown", summary: `Unknown delivery "${action.delivery}".` }; + } +} diff --git a/demo/src/scopes.mjs b/demo/src/scopes.mjs new file mode 100644 index 0000000..ce5e9fd --- /dev/null +++ b/demo/src/scopes.mjs @@ -0,0 +1,56 @@ +// Map the discovered action graph -> the minimal set of granular atproto OAuth +// scopes that enable every action. This is the crux of AT Intents as a CONSUMER: +// we don't ask for blanket write access, we ask for exactly what the handlers in +// the user's repo declared they need, and nothing more. +// +// repo delivery -> the capability's own `repo:` scope +// (falls back to repo:?action=create) +// service delivery -> rpc:?aud=# +// open / passive -> nothing (browser navigation / already-readable record) +// +// `atproto` is always required: it establishes the session + identity but grants +// no repo/rpc access on its own. See atproto.com/specs/oauth (scope grammar). + +export const BASE_SCOPE = "atproto"; + +export function lxmFromEndpoint(endpoint) { + try { + return new URL(endpoint).pathname.split("/xrpc/")[1] || null; + } catch { + return null; + } +} + +// The `aud` of an rpc scope / proxy header: the handler's DID plus the +// service-endpoint fragment from its DID document (carried as `serviceId`). +export function audFor(action) { + return action.serviceId ? `${action.handler}#${action.serviceId}` : action.handler; +} + +// The granular scope(s) a single action needs. Returns [] when none are required. +export function scopesForAction(a) { + if (a.delivery === "repo") { + // Capability authors publish the exact scope; honor it verbatim. + if (a.scope) return [a.scope]; + const collection = a.produces?.[0]; + return collection ? [`repo:${collection}?action=create`] : []; + } + if (a.delivery === "service" && a.verb !== "open") { + const lxm = lxmFromEndpoint(a.endpoint); + if (!lxm) return []; + return [`rpc:${lxm}?aud=${audFor(a)}`]; + } + return []; // open = navigation, passive = already in repo +} + +// Union of scopes across a set of actions, `atproto` first. Pass the actions that +// actually apply to the target so we never request a scope we can't use. +export function scopesForActions(actions) { + const set = new Set(); + for (const a of actions) for (const s of scopesForAction(a)) set.add(s); + return [BASE_SCOPE, ...set]; +} + +export function scopesForGraph(graph) { + return scopesForActions(graph.actions); +} diff --git a/demo/src/share.mjs b/demo/src/share.mjs new file mode 100644 index 0000000..5659ff4 --- /dev/null +++ b/demo/src/share.mjs @@ -0,0 +1,376 @@ +// The share sheet, productionized: a real atproto CONSUMER in the browser. +// +// 1. DISCOVER (no auth) enter a handle -> resolveActionGraph scans the repo +// footprint and lists every app + every verb it offers. +// 2. CONSENT compute the minimal granular scopes those handlers need +// and request exactly them via OAuth (scopes.mjs). +// 3. ACT (authed) run any action for real against the target page — +// repo write or PDS-proxied service call (execute.mjs). +// +// No app list is hardcoded; the handlers come entirely from the user's repo. + +import { resolveActionGraph, subjectMatches } from "../../resolver/resolver.mjs"; +import { initAuth, beginSignIn, signOut, rememberedAccount, rememberAccount } from "./auth.mjs"; +import { scopesForActions } from "./scopes.mjs"; +import { executeAction } from "./execute.mjs"; + +const $ = (sel) => document.querySelector(sel); +const el = (tag, props = {}, ...kids) => { + const n = Object.assign(document.createElement(tag), props); + for (const k of kids) if (k != null) n.append(k); + return n; +}; + +const PAGE_KEY = "atint.page"; // survive the OAuth redirect round-trip + +// Verbs render in this order; anything else falls to the end, alpha. +const VERB_ORDER = ["share", "save", "subscribe", "open", "follow"]; +const verbRank = (v) => { + const i = VERB_ORDER.indexOf(v); + return i === -1 ? VERB_ORDER.length : i; +}; + +// --- target page (survives the auth redirect via sessionStorage) -------------- + +function getPage() { + const p = new URLSearchParams(location.search); + const fromQuery = p.get("url"); + if (fromQuery) { + const page = { url: fromQuery, title: p.get("title") || fromQuery }; + sessionStorage.setItem(PAGE_KEY, JSON.stringify(page)); + return page; + } + try { + const saved = JSON.parse(sessionStorage.getItem(PAGE_KEY) || "null"); + if (saved?.url) return saved; + } catch { + /* ignore */ + } + return { url: "https://example.com/an-article", title: "An example article" }; +} + +function renderTarget(page) { + $("#target-title").textContent = page.title; + $("#target-url").textContent = page.url; +} + +// --- small shared bits -------------------------------------------------------- + +// Build a querySelector-safe id: handler DIDs contain ":" which is valid in an +// HTML id but parses as a pseudo-class in a "#..." selector. Strip to [A-Za-z0-9_-]. +function inputId(action, name) { + return `in-${action.handler}-${action.verb}-${name}`.replace(/[^A-Za-z0-9_-]/g, "_"); +} + +function fieldInputs(action) { + return (action.input || []).map((f) => + el( + "label", + { className: "field" }, + el("span", {}, f.name + (f.required ? " *" : "")), + el("input", { + id: inputId(action, f.name), + type: "text", + placeholder: f.description || f.name, + }), + ), + ); +} + +function readInputs(action) { + const inputs = {}; + for (const f of action.input || []) { + const v = $(`#${inputId(action, f.name)}`)?.value; + if (v) inputs[f.name] = v; + } + return inputs; +} + +// The shared page is always a bare web URL, so an action only applies if one of +// its declared subject specs accepts a `uri`. This is the same matcher the resolver +// and CLI use (subjectMatches) — it's what keeps a "follow an account/publication" +// capability from being offered (and scoped) for a random page URL. +const PAGE_SUBJECT = { kind: "uri" }; +const appliesToPage = (a) => (a.subject || []).some((s) => subjectMatches(s, PAGE_SUBJECT)); + +// Human-readable summary of what subject(s) a capability does accept — shown so a +// filtered-out action explains itself rather than silently vanishing. +function describeSubject(specs = []) { + if (!specs.length) return "a specific subject"; + const one = (s) => { + if (s.kind === "uri") return "a web URL"; + if (s.kind === "at-uri") return s.of ? `a ${s.of} record` : "an at-uri record"; + if (s.kind === "did") return s.as && s.as !== "record" ? `an ${s.as} (DID)` : "a DID"; + if (s.kind === "blob") return "a file"; + return `a ${s.kind}`; + }; + return specs.map(one).join(" or "); +} + +// The muted "doesn't fit this page" section, each entry naming the subject it needs. +function excludedBlock(others) { + if (!others.length) return null; + return el( + "div", + { className: "excluded" }, + el("h2", { className: "sec" }, "Doesn't apply to this page"), + ...others.map((a) => + el( + "div", + { className: "handler preview" }, + el( + "div", + { className: "handler-head" }, + el("strong", {}, a.name || a.handler), + el("span", { className: "delivery", "data-route": a.delivery }, a.delivery), + ), + el("p", { className: "desc" }, `Needs ${describeSubject(a.subject)}, not a page URL.`), + ), + ), + ); +} + +// Group a flat action list into [{verb, actions}] in display order. +function groupByVerb(actions) { + const byVerb = new Map(); + for (const a of actions) { + if (!byVerb.has(a.verb)) byVerb.set(a.verb, []); + byVerb.get(a.verb).push(a); + } + return [...byVerb.entries()] + .sort(([a], [b]) => verbRank(a) - verbRank(b) || a.localeCompare(b)) + .map(([verb, list]) => ({ verb, actions: list })); +} + +// --- DISCONNECTED: discover + consent ---------------------------------------- + +function renderDisconnected(page) { + const remembered = rememberedAccount(); + $("#conn").textContent = remembered ? `signed out — ${remembered.handle}` : "not connected"; + const root = $("#body"); + + const handleInput = el("input", { + id: "handle", + type: "text", + placeholder: "you.bsky.social", + autocomplete: "username", + value: remembered?.handle || "", + }); + const status = el("p", { className: "muted", id: "discover-status" }); + const preview = el("div", { id: "preview" }); + + const discover = async () => { + const handle = handleInput.value.trim().replace(/^@/, ""); + if (!handle) return; + rememberAccount({ handle }); // persist immediately, before any network or login + status.textContent = `Scanning ${handle}'s repo…`; + preview.replaceChildren(); + let graph; + try { + graph = await resolveActionGraph(handle); + } catch (err) { + status.textContent = `Could not resolve ${handle}: ${err?.message || err}`; + return; + } + rememberAccount({ did: graph.did }); // enrich with the DID now that we have it + if (!graph.actions.length) { + status.textContent = `No AT Intents handlers found in ${handle}'s repo.`; + return; + } + status.textContent = ""; + renderConsent(graph, handle, preview, page); + }; + + handleInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") discover(); + }); + + const lede = remembered + ? `Welcome back, ${remembered.handle}. Discover what your apps can do with this page, then reconnect.` + : "Connect your atproto account to discover what the apps in your repo can do with this page — then grant exactly the permissions they need."; + + root.replaceChildren( + el("p", { className: "lede" }, lede), + el("label", { className: "field" }, el("span", {}, "your handle"), handleInput), + el("button", { className: "primary", onclick: discover }, remembered ? `Continue as ${remembered.handle}` : "Discover apps"), + status, + preview, + ); + + // Returning user: we already know who they are — surface their apps immediately. + if (remembered) discover(); +} + +// Show the discovered action graph (read-only) + the precise scopes we'll request. +// Only actions whose subject fits this page are offered and scoped; the rest are +// listed, de-emphasized, with the subject they'd need. +function renderConsent(graph, handle, mount, page) { + const applicable = graph.actions.filter(appliesToPage); + const others = graph.actions.filter((a) => !appliesToPage(a)); + const scopes = scopesForActions(applicable); + + const verbBlocks = groupByVerb(applicable).map((g) => + el( + "div", + { className: "verb-group" }, + el("h3", { className: "verb" }, g.verb), + ...g.actions.map((a) => + el( + "div", + { className: "handler preview" }, + el( + "div", + { className: "handler-head" }, + el("strong", {}, a.name || a.handler), + el("span", { className: "delivery", "data-route": a.delivery }, a.delivery), + ), + el("p", { className: "desc" }, a.description || ""), + ), + ), + ), + ); + + const header = applicable.length + ? `${applicable.length} action(s) for this page` + : "None of your apps can act on a page URL"; + + const grantBtn = applicable.length + ? el( + "button", + { + className: "primary", + onclick: () => beginSignIn(handle, scopes).catch((e) => { + $("#discover-status").textContent = "Sign-in failed: " + (e?.message || e); + }), + }, + `Connect ${handle} & grant ${scopes.length} scopes`, + ) + : null; + + mount.replaceChildren( + ...[ + el("h2", { className: "sec" }, header), + ...verbBlocks, + excludedBlock(others), + applicable.length && el("h2", { className: "sec" }, "Permissions you'll grant"), + applicable.length && + el("ul", { className: "scopes" }, ...scopes.map((s) => el("li", {}, el("code", {}, s)))), + grantBtn, + grantBtn && + el("p", { className: "muted small" }, "You'll be sent to your PDS to approve. Nothing runs until you come back and click an action."), + ].filter(Boolean), + ); +} + +// --- CONNECTED: execute for real --------------------------------------------- + +async function renderConnected(auth, page) { + $("#conn").textContent = `connected — ${auth.handle || auth.did}`; + const root = $("#body"); + root.replaceChildren(el("p", { className: "muted" }, "Resolving your apps…")); + + let graph; + try { + graph = await resolveActionGraph(auth.did); + } catch (err) { + root.replaceChildren(el("p", { className: "error" }, "Failed to resolve your repo: " + (err?.message || err))); + return; + } + + const signOutBtn = el( + "button", + { className: "ghost", onclick: async () => { await signOut(); location.reload(); } }, + "Sign out", + ); + + if (!graph.actions.length) { + root.replaceChildren( + el("p", { className: "lede" }, "You're connected, but no AT Intents handlers are in your repo yet."), + signOutBtn, + ); + return; + } + + const applicable = graph.actions.filter(appliesToPage); + const others = graph.actions.filter((a) => !appliesToPage(a)); + + const blocks = groupByVerb(applicable).map((g) => + el( + "div", + { className: "verb-group" }, + el("h3", { className: "verb" }, g.verb), + ...g.actions.map((a) => actionCard(a, auth, page)), + ), + ); + + const lede = applicable.length + ? "Pick an action — it runs for real against the page above, using the scopes you granted." + : "None of your apps can act on a page URL. They handle other subject types:"; + + root.replaceChildren( + ...[el("p", { className: "lede" }, lede), ...blocks, excludedBlock(others), signOutBtn].filter(Boolean), + ); +} + +function actionCard(action, auth, page) { + const result = el("div", { className: "result", hidden: true }); + + const run = async (btn) => { + btn.disabled = true; + result.hidden = false; + result.className = "result"; + result.textContent = "Running…"; + try { + const r = await executeAction(auth.agent, action, page, readInputs(action)); + result.className = "result " + (r.ok ? "ok" : "error"); + const parts = [el("p", { className: "summary" }, r.summary)]; + if (r.detail) parts.push(el("pre", { className: "wire" }, JSON.stringify(r.detail, null, 2))); + result.replaceChildren(...parts); + } catch (err) { + result.className = "result error"; + result.replaceChildren( + el("p", { className: "summary" }, "Failed: " + (err?.message || err)), + el("p", { className: "muted small" }, "The handler's service rejected or could not be reached. The proxied call itself was made through your PDS."), + ); + } finally { + btn.disabled = false; + } + }; + + return el( + "div", + { className: "handler" }, + el( + "div", + { className: "handler-head" }, + el("strong", {}, action.name || action.handler), + el("span", { className: "delivery", "data-route": action.delivery }, action.delivery), + ), + el("p", { className: "desc" }, action.description || ""), + ...fieldInputs(action), + el("button", { className: "primary", onclick: (e) => run(e.currentTarget) }, "Run"), + result, + ); +} + +// --- boot --------------------------------------------------------------------- + +async function main() { + const page = getPage(); + renderTarget(page); + + let auth = null; + try { + auth = await initAuth(); // completes any OAuth callback, restores a session + } catch (err) { + $("#body").replaceChildren(el("p", { className: "error" }, "Auth init failed: " + (err?.message || err))); + return; + } + + if (auth) renderConnected(auth, page); + else renderDisconnected(page); +} + +main().catch((err) => { + $("#body").replaceChildren(el("p", { className: "error" }, "Failed: " + (err?.message || err))); +}); diff --git a/resolver/resolver.mjs b/resolver/resolver.mjs index 1844b29..bf2ce90 100644 --- a/resolver/resolver.mjs +++ b/resolver/resolver.mjs @@ -150,6 +150,7 @@ export async function resolveActionGraph(actor, opts = {}) { name: cap.name, delivery: cap.delivery, endpoint: cap.endpoint, + serviceId: cap.serviceId, produces: cap.produces || [], scope: cap.scope, description: cap.description, -- 2.51.2