oficina / slides Public

feat: drive API with agent principals

The surface an agent drives a deck from (spec §7), and the tokens that
reach it.

Agents are their own principals: a person mints one from /agents, the
token is shown once and stored as a SHA-256, and every write it makes
records "agent:<id>" rather than the operator's. The deck list badges
that with a robot mark and the principal, so a deck a program has been
editing says so on the row.

/api/v1 is mounted on the ServeMux ahead of the chi router, outside
csrf.Protect. csrf.SameOrigin refuses a POST carrying no
Sec-Fetch-Site, no Origin and no Referer, which is exactly what a
non-browser client sends -- and the API's gate is a bearer header a
browser never attaches on its own, so nothing is given up. api.go,
app.go and docs/api.md each say so where somebody will meet it.

Endpoints: list, create, get (ETag on the seq, honouring
If-None-Match), ops, the rendered section of one slide, and present.
The ops endpoint mints the ids the core cannot draw itself and hands
them back keyed by the operation's index, so an agent can address what
it just made; a refused batch is a 422 naming the operation, with
nothing applied.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev d8ec71ea80d26380d94ef9f7aeafd4662110d605 parent 3c96685
14 files changed, +2690 −13
  • docs/api.md +417 −0
  • internal/slides/agents.go +540 −0
  • internal/slides/api.go +770 −0
  • internal/slides/app.go +23 −0
  • internal/slides/decks.go +77 −12
  • internal/slides/locales/en.toml +40 −0
  • internal/slides/migrations/0005_create_agents.sql +6 −0
  • internal/slides/migrations/schema.sql +6 −0
  • internal/slides/models.go +43 −0
  • internal/slides/render.go +1 −1
  • internal/slides/templates/account.html +5 −0
  • internal/slides/templates/agents.html +80 −0
  • internal/slidestest/agents_test.go +187 −0
  • internal/slidestest/api_test.go +495 −0

CI passed — run details

Intent

Human

<task-notification> <task-id>ad4a715cb27c81aca</task-id> <tool-use-id>toolu_012yHFmjJm2wgU6yGNBZuRQr</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/ad4a715cb27c81aca.output</output-file> <status>completed</status> <summary>Agent "Implement Task 8: docs and gate" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>**Status:** done. **Commit:** `3c96685` — `docs: AGENTS, README, PRODUCT and the one CI gate` (14 files; report file left uncommitted, as is the other agents' in-flight work). **Gate:** green, exit 0. Run against a clean `git archive HEAD` export, because two other agents have uncommitted work here (`internal/slidestest/api_test.go` was gofmt-unclean mid-write). Tail: `go vet` / `go test` all ok, `rastrillo migration check: models and migrations agree`, `gate: ok`. **Concerns:** - The new gate went red first run on two pre-existing faults, both fixed in this commit and both outside the task's touch list: `core/src/{layout,ops}.rs` + `core/tests/ops.rs` had never seen rustfmt, and the committed `slides_core.wasm` was already stale before that reformat (verified by stashing and rebuilding). - `docs/api.md` does not exist yet (task 7). Three documents link to it; a different filename breaks all three. - `slides.viewer.empty.body` in `locales/en.toml` tells the reader to "add one from the editor" — there is no editor in slice 1. Left alone (outside touch list); the catalog's owner should reword it. - The runner `-repo oficina/slides` allowlist edit is recorded from the brief, not verified — the unit file is unreadable from here. - Gate order differs from the brief: wasm build runs before `cargo test`, because `no_host_imports.rs` reads the artifact off disk and otherwise skips. **Report:** `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-07-slice-1-live/task-8-report.md`</result> <usage><subagent_tokens>151632</subagent_tokens><tool_uses>61</tool_uses><duration_ms>556765</duration_ms></usage> </task-notification>

Human

<task-notification> <task-id>af5465a3f410d7daa</task-id> <tool-use-id>toolu_01UXVkhcmNwQ6vANmEZn7oAx</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/af5465a3f410d7daa.output</output-file> <status>completed</status> <summary>Agent "Review Task 8 (docs + gate)" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Good — all of these are the concurrent agent's in-progress work (matches the brief's "ignore" list), not touched by my review. My build only wrote under `core/target/`, as instructed. Now compiling the final review. ### Spec Compliance (Missing/Extra/Misunderstood; ⚠️ unverifiable) - No missing deliverables: `AGENTS.md` and `README.md` rewritten, `PRODUCT.md` created with the `&lt;!-- impeccable:product-schema 1 --&gt;` header and ten sections, `.amadan/ci.d/10-gate` created (the four scaffold steps deleted), `.amadan/ci` now execs it, `.amadan/README.md` added with the runner-allowlist note. `CLAUDE.md` correctly left untouched. - Gate step order deviates from the brief's literal listing (`cargo test`, `cargo clippy`, `build-wasm.sh`, `cmp`) — the implementation runs fmt → clippy → wasm build → cmp → test → `make ci`. This is justified and verified true: `core/tests/no_host_imports.rs` reads the wasm artifact off disk and only fails hard under `SLIDES_REQUIRE_WASM=1`, so testing before building would race the artifact's presence. Every step the brief named is present; only the ordering changed, for a documented reason. Not a compliance defect. - Two out-of-scope files were touched (`core/src/layout.rs`, `core/src/ops.rs`, `core/tests/ops.rs` reformatted; `internal/slides/core/testdata/slides_core.wasm` rebuilt) to make the new gate pass. Verified both were pre-existing faults (rustfmt drift, stale wasm) rather than newly introduced ones — the rustfmt diff is whitespace-only (confirmed by reading every hunk) and `cargo fmt --check` now passes clean; the rebuilt wasm is now byte-identical to a fresh `scripts/build-wasm.sh` build (confirmed via `cmp`). Reasonable scope creep, honestly reported. - ⚠️ `docs/api.md`, linked from all three documents, does not exist yet (task 7, concurrent) — correctly flagged by the implementer as a forward reference that could break if task 7 lands under a different filename. Not verifiable from this task alone. ### Strengths - Every named-risk check passed: rustfmt hunks are pure reformatting, the wasm rebuild is reproducible byte-for-byte off the worktree (confirms the `--remap-path-prefix` claim in `.amadan/README.md`), `.amadan/ci` execs the gate and both files are executable. - Docs are unusually well-grounded: seams (`actorFor`, `visibleDecks`, `deckFor`/`ownedDeckFor`, `openDeck`, `applyOps`/`editDeck`, `render.Slide`, `present.Hub`), env vars, routes, `clippy.toml`, `rust-toolchain.toml`, and multiple locale-catalog string quotes all matched the code exactly on spot-check. - Spec citations checked against section numbers (§0.1, §0.2, §1 rulings 2/3/5/7/8/9/11, §6.2, §6.3, §7.1, §7.2, §8) are all accurate to the actual spec text. - `[inferred — confirm]` markers used correctly in `PRODUCT.md` for the two claims that are readings rather than citations (team size, the "slide is not themed chrome" reading). - The implementer independently caught and flagged a real bug outside its touch list (`slides.viewer.empty.body` mentions "the editor," which doesn't exist in slice 1) rather than silently fixing or ignoring it. ### Issues #### Critical None. #### Important None. #### Minor - `PRODUCT.md` states `internal/slides/locales/en.toml` has "121 keys today" (Capabilities and Constraints section). Actual count is 119 (`grep -cE '^[A-Za-z0-9_.-]+ *=' internal/slides/locales/en.toml`). Small, easily-fixed factual drift — the catalog will keep moving under concurrent work, so this may already be stale by design, but as read it's off by 2. ### Assessment **Task quality:** Approved **Reasoning:** All deliverables in the brief are present and correct in substance. The three named highest-risk items (rustfmt-only diff, wasm byte-identity, gate wiring) all check out under direct verification, not just the implementer's say-so. A broad spot-check of factual claims across AGENTS.md/README.md/PRODUCT.md against the actual code (routes, env vars, seam functions, toolchain pin, clippy config, catalog strings) turned up only one minor numeric inaccuracy (locale key count off by 2), which doesn't affect any behavioral or architectural claim. The two out-of-scope file changes (rustfmt, wasm rebuild) were necessary, verified pre-existing, and honestly disclosed rather than buried. No fixes are required before merge; the locale-count nit can be picked up whenever the catalog is next touched.</result> <usage><subagent_tokens>84315</subagent_tokens><tool_uses>38</tool_uses><duration_ms>134435</duration_ms></usage> </task-notification>

Attributed to this commit: 98.4 g CO₂e (12204595 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on worktree-slice-1, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..eea2e9d
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,417 @@
+# The Slides drive API
+
+This is the surface a program uses to build a deck, look at what it
+made, and put it on a projector. A person's browser never touches it —
+the screens are a separate thing, and neither one accepts the other's
+credential.
+
+Everything below assumes the instance is at `https://slides.example`.
+Replace it with your own.
+
+## Getting a token
+
+A token belongs to an **agent**, and an agent belongs to a person — its
+operator. Sign in, open **Agents** from the account menu, give the
+agent a name, and press Create. The token is shown once, on that
+screen, and never again: only its fingerprint is stored, so there is
+nothing to show you a second time. Lose it and you revoke the agent and
+make another.
+
+A name is 2 to 32 characters of lowercase letters, digits and hyphens
+— `claude`, `codex`, `release-bot`. It has to be different from every
+other agent you have that is still live.
+
+The token looks like `sl_` followed by forty letters and digits. Send
+it on every request:
+
+```
+Authorization: Bearer sl_...
+```
+
+Two rules worth knowing before you build anything on this:
+
+- **The API never reads a session cookie**, and the screens never read
+ a bearer. A request to `/api/v1/...` with a perfectly good session
+ and no token is a 401.
+- **An agent sees exactly what its operator sees.** It can read and
+ write every deck that person can, and nothing else. It is not an
+ administrator and it cannot mint another token.
+
+Every change an agent makes is recorded against the agent, not the
+person: the actor is `agent:<id>`, and the deck list shows it as
+🤖 `claude~you@example.com`. Revoking the agent stops the token
+immediately; the history it already wrote keeps its name.
+
+## Shapes
+
+Requests with a body send JSON and say so:
+`Content-Type: application/json`. Anything else is a 415. A body over
+1 MiB is a 413.
+
+Every answer with a body is JSON. Every error is:
+
+```json
+{"error": "invalid", "message": "op 1: no slide \"abc\"", "op": 1}
+```
+
+`error` is a fixed English identifier — branch on it. `message` and
+`op` are only on some of them.
+
+| Status | `error` | What happened |
+|--------|--------------------------|---------------|
+| 401 | `unauthorized` | No token, an unknown token, or a revoked one. A token that was never real and one that was revoked answer identically. |
+| 404 | `not_found` | No such deck, slide or path — including a deck the operator cannot see. A deck that is not yours does not exist. |
+| 405 | `method_not_allowed` | Right path, wrong verb. |
+| 409 | `conflict` | Somebody else wrote to the deck while this batch was landing, twice. Re-read the deck and decide what your batch now means. |
+| 413 | `too_large` | The body is over 1 MiB. |
+| 415 | `unsupported_media_type` | The body is not `application/json`. |
+| 422 | `invalid` | The request was read and refused. `message` says why; `op` is the index of the operation at fault, when the failure was about one. |
+| 500 | `internal` | Our fault. The server log has a matching line. |
+
+---
+
+## Endpoints
+
+### `GET /api/v1/decks` — every deck you can open
+
+```sh
+curl -H "Authorization: Bearer $SLIDES_TOKEN" \
+ https://slides.example/api/v1/decks
+```
+
+```json
+{"decks": [
+ {"sqid": "k7m2xq9dwb", "title": "Q3 review", "slides": 12,
+ "rung": "private", "updated_at": "2026-09-07T09:41:02Z", "seq": 34}
+]}
+```
+
+Most recently edited first. `sqid` is the deck's public name and what
+every other endpoint takes. `rung` is who can open it: `private`,
+`granted` or `team`. `seq` is the deck's version — it moves on every
+write and on nothing else.
+
+### `POST /api/v1/decks` — make one
+
+```sh
+curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"title": "Q3 review", "theme": "ink"}' \
+ https://slides.example/api/v1/decks
+```
+
+```json
+{"sqid": "k7m2xq9dwb"}
+```
+
+201. `title` is required; `theme` is one of `paper`, `ink`, `sunset`,
+`forest`, `slate`, `candy`, and defaults to `paper`. The deck is owned
+by your operator and starts on the `private` rung, exactly as one made
+from the screen does — and like one made from the screen, it opens
+with a title slide already on it.
+
+### `GET /api/v1/decks/{sqid}` — the whole document
+
+```sh
+curl -H "Authorization: Bearer $SLIDES_TOKEN" \
+ https://slides.example/api/v1/decks/k7m2xq9dwb
+```
+
+```json
+{"sqid": "k7m2xq9dwb", "seq": 34,
+ "meta": {"title": "Q3 review", "theme": "ink", "accent": null,
+ "fonts": null, "aspect": "16:9"},
+ "slides": [{"id": "hq3ktna7vc2m", "layout": "flow", "template": "title",
+ "background": {"kind": "theme", "value": ""},
+ "phone": "inherit", "transition": "none",
+ "notes": [], "blocks": [...]}],
+ "assets": {}}
+```
+
+The answer carries `ETag: "34"` — the seq, quoted. Send it back and an
+unchanged deck is a 304 with no body, which costs the server nothing:
+
+```sh
+curl -H "Authorization: Bearer $SLIDES_TOKEN" \
+ -H 'If-None-Match: "34"' \
+ https://slides.example/api/v1/decks/k7m2xq9dwb
+```
+
+Poll this rather than re-fetching. It is how you tell whether anybody —
+a person in the editor, another agent — has moved the deck since you
+last looked.
+
+### `POST /api/v1/decks/{sqid}/ops` — change it
+
+Everything that changes a deck goes through here, as a batch applied in
+order inside one transaction.
+
+```sh
+curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"ops": [
+ {"op": "add_slide", "layout": "flow", "template": "section"},
+ {"op": "set_meta", "title": "Q3 review, final"}
+ ]}' \
+ https://slides.example/api/v1/decks/k7m2xq9dwb/ops
+```
+
+```json
+{"seq": 35, "ids": {"0": "hq3ktna7vc2m"}}
+```
+
+**You do not have to invent ids.** `add_slide`, `add_block` and
+`duplicate_slide` each need one, and if you leave it out the server
+mints it and hands it back keyed by the operation's index — `"0"` is
+the first operation you sent. Supply your own and it is used as given.
+For `duplicate_slide` the returned id is the new slide's; the copied
+blocks get ids too, and you read them back off the deck.
+
+**A refused batch changes nothing.** Not the good operations in front
+of the bad one, not the seq — the whole batch is checked before the
+first byte is written:
+
+```json
+{"error": "invalid", "op": 1, "message": "op 1: no slide \"nosuchslide\""}
+```
+
+If two writers land at once you may get a `409 conflict`. The batch
+applied nothing. Re-read the deck and send it again if it still makes
+sense.
+
+### `GET /api/v1/decks/{sqid}/slides/{id}.html` — look at a slide
+
+```sh
+curl -H "Authorization: Bearer $SLIDES_TOKEN" \
+ 'https://slides.example/api/v1/decks/k7m2xq9dwb/slides/hq3ktna7vc2m.html?vp=phone'
+```
+
+One `<section>` of HTML — the same markup the viewer renders — and
+nothing around it. `vp=phone` renders the phone layout; leave it off
+for the desktop one. This is how you check what you built without a
+browser. (An SVG thumbnail lands beside it later.)
+
+### `POST /api/v1/decks/{sqid}/present` — start a talk
+
+```sh
+curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \
+ https://slides.example/api/v1/decks/k7m2xq9dwb/present
+```
+
+```json
+{"session": "p4wq7nzk2h", "code": "K7MQ2X",
+ "present_url": "https://slides.example/p/p4wq7nzk2h",
+ "notes_url": "https://slides.example/p/p4wq7nzk2h/notes?t=...",
+ "remote_url": "https://slides.example/r/K7MQ2X?t=..."}
+```
+
+Call it again on the same deck and you get the same session back, so a
+projector already showing `present_url` keeps showing the same talk.
+What you do get fresh each time is the remote token, which means the
+previous `notes_url` and `remote_url` stop working.
+
+Treat the three URLs differently. `present_url` is safe to put on a
+screen in front of a room — it only watches. `notes_url` and
+`remote_url` carry the token that *drives* the deck, so they are
+secrets: send them to the presenter, not to the audience.
+
+---
+
+## The operations
+
+Every operation is an object with an `op` key. Ids are twelve lowercase
+base32 characters and are minted by the server when you omit them. The
+vocabularies:
+
+- **layout**: `flow`, `canvas`
+- **template** (flow slides): `title`, `title-body`, `two-column`,
+ `section`, `image-full`, `quote`, `blank`
+- **region** (blocks on a flow slide): `heading`, `body`, `aside`, `media`
+- **block type**: `heading`, `text`, `list`, `quote`, `image`,
+ `columns`, `code`, `shape`, `table`, `divider`, `spacer`
+- **background kind**: `theme`, `color`, `image`
+- **transition**: `none`, `fade` — **slide phone**: `inherit`, `reflow`
+- **size**: `s`, `m`, `l`
+
+`after` says where something goes: absent appends, `null` puts it
+first, an id puts it after that one. A `box` is `{x, y, w, h}` as
+fractions of the 16:9 stage. Text arrives as Markdown and is converted
+server-side.
+
+Each example below is one batch, so you can paste any of them straight
+into the `ops` body above. `$D` is the deck sqid, `$S` a slide id and
+`$B` a block id.
+
+### Deck
+
+**`set_meta`** — the deck's title, theme, accent and font pair. Send
+`null` for `accent` or `fonts` to clear one.
+
+```sh
+curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" -H "Content-Type: application/json" \
+ -d '{"ops":[{"op":"set_meta","title":"Q3 review","theme":"ink","accent":"#c0392b",
+ "fonts":{"heading":"Inter","body":"Inter"}}]}' \
+ https://slides.example/api/v1/decks/$D/ops
+```
+
+### Slides
+
+**`add_slide`** — a new slide. `id` optional (minted and returned).
+
+```sh
+-d '{"ops":[{"op":"add_slide","layout":"flow","template":"title-body","after":null}]}'
+```
+
+**`remove_slide`** — delete one, and everything on it.
+
+```sh
+-d '{"ops":[{"op":"remove_slide","id":"'"$S"'"}]}'
+```
+
+**`move_slide`** — reorder. `"after": null` moves it to the front.
+
+```sh
+-d '{"ops":[{"op":"move_slide","id":"'"$S"'","after":null}]}'
+```
+
+**`duplicate_slide`** — copy a slide and everything on it. `new_id` and
+`new_block_ids` are both optional; leave them out and the server mints
+as many block ids as the slide has.
+
+```sh
+-d '{"ops":[{"op":"duplicate_slide","id":"'"$S"'"}]}'
+```
+
+**`set_slide`** — a slide's own settings.
+
+```sh
+-d '{"ops":[{"op":"set_slide","id":"'"$S"'","layout":"flow","template":"quote",
+ "background":{"kind":"color","value":"#101418"},
+ "phone":"reflow","transition":"fade"}]}'
+```
+
+**`set_notes`** — the speaker notes, as Markdown.
+
+```sh
+-d '{"ops":[{"op":"set_notes","id":"'"$S"'","markdown":"Pause here. Ask about hiring."}]}'
+```
+
+### Blocks
+
+**`add_block`** — put something on a slide. `id` optional. On a flow
+slide give a `region`; on a canvas slide give a `box` and optionally
+`z`.
+
+```sh
+-d '{"ops":[{"op":"add_block","slide":"'"$S"'","type":"heading",
+ "region":"heading","props":{"level":1},"markdown":"Revenue"}]}'
+```
+
+```sh
+-d '{"ops":[{"op":"add_block","slide":"'"$S"'","type":"image",
+ "box":{"x":0.1,"y":0.2,"w":0.5,"h":0.4},"z":2,
+ "props":{"asset":"a1"}}]}'
+```
+
+**`remove_block`** — take one off.
+
+```sh
+-d '{"ops":[{"op":"remove_block","slide":"'"$S"'","id":"'"$B"'"}]}'
+```
+
+**`move_block`** — reorder within its slide.
+
+```sh
+-d '{"ops":[{"op":"move_block","slide":"'"$S"'","id":"'"$B"'","after":null}]}'
+```
+
+**`set_block`** — its props, region, box or z. `null` for `region` or
+`box` clears that one.
+
+```sh
+-d '{"ops":[{"op":"set_block","slide":"'"$S"'","id":"'"$B"'",
+ "props":{"level":2},"region":"body","box":null,"z":1}]}'
+```
+
+**`set_text`** — replace the words in a text-bearing block (`heading`,
+`text`, `list`, `quote`, `code`, `shape`).
+
+```sh
+-d '{"ops":[{"op":"set_text","slide":"'"$S"'","id":"'"$B"'",
+ "markdown":"- Up 14%\n- Two new regions"}]}'
+```
+
+**`set_phone`** — a block's phone layer: additions to the desktop
+layout, never a replacement for it.
+
+```sh
+-d '{"ops":[{"op":"set_phone","slide":"'"$S"'","id":"'"$B"'",
+ "phone":{"hidden":false,"order":0,"size":"l"}}]}'
+```
+
+### Assets
+
+**`register_asset`** — name an image the deck can use. The bytes go up
+separately; this records the result.
+
+```sh
+-d '{"ops":[{"op":"register_asset","id":"a1","ref":"store://decks/k7m/roadmap.png",
+ "name":"roadmap.png","width":1600,"height":900,
+ "alt":"Three phases, each with a date"}]}'
+```
+
+**`remove_asset`** — drop the registration.
+
+```sh
+-d '{"ops":[{"op":"remove_asset","id":"a1"}]}'
+```
+
+---
+
+## A worked example
+
+Build a two-slide deck from nothing:
+
+```sh
+export SLIDES_TOKEN=sl_...
+export API=https://slides.example/api/v1
+
+D=$(curl -sX POST -H "Authorization: Bearer $SLIDES_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"title":"Q3 review","theme":"ink"}' $API/decks | jq -r .sqid)
+
+S=$(curl -sX POST -H "Authorization: Bearer $SLIDES_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"ops":[{"op":"add_slide","layout":"flow","template":"title-body"}]}' \
+ $API/decks/$D/ops | jq -r '.ids["0"]')
+
+curl -sX POST -H "Authorization: Bearer $SLIDES_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"ops":[
+ {"op":"add_block","slide":"'"$S"'","type":"heading","region":"heading",
+ "props":{"level":1},"markdown":"Revenue"},
+ {"op":"add_block","slide":"'"$S"'","type":"list","region":"body",
+ "markdown":"- Up 14%\n- Two new regions"},
+ {"op":"set_notes","id":"'"$S"'","markdown":"Pause on the second bullet."}
+ ]}' $API/decks/$D/ops
+
+# Look at what you made.
+curl -s -H "Authorization: Bearer $SLIDES_TOKEN" "$API/decks/$D/slides/$S.html"
+```
+
+---
+
+## A note on CSRF
+
+The screens are protected by an origin check on every state-changing
+request (`Sec-Fetch-Site`, `Origin`, `Referer`). A program has none of
+those to send, and the check refuses a request that carries none of the
+three — so `/api/v1` is mounted outside it and gated by the bearer
+alone.
+
+That is not a hole. A browser never attaches an `Authorization` header
+by itself, so a page on some other origin cannot make a request this
+API will authenticate, whatever it does with forms or credentials.
+Cross-site request forgery is an attack on ambient credentials — a
+cookie the browser sends for you — and there is no ambient credential
+here.
diff --git a/internal/slides/agents.go b/internal/slides/agents.go
new file mode 100644
index 0000000..cccf809
--- /dev/null
+++ b/internal/slides/agents.go
@@ -0,0 +1,540 @@
+// agents.go is the non-human half of §6.2's actors: the token a person
+// mints so a program can drive their decks, the middleware that turns
+// one back into a principal, and the screen that does both.
+//
+// The rule the whole file exists to hold, spec §7.1 and suite ruling 9:
+// an agent is its OWN principal, never a borrowed session. Three
+// consequences, each implemented here rather than only stated:
+//
+// - The token is a bearer credential and only a bearer credential.
+// The API never reads a session cookie and the HTML screens never
+// read a bearer, so neither surface can be reached with the other's
+// credential — and a page in a signed-in person's browser has
+// nothing that would drive the API.
+// - Every write it makes is recorded "agent:<id>", so a deck's
+// history names the agent and, through its operator, the person
+// who is answerable for it.
+// - It can see exactly what its operator can see, and not one deck
+// more: every API read and write runs through visibleDecks
+// evaluated for the operator (api.go).
+//
+// Only the SHA-256 of the token is stored — share.go's bargain, and
+// share.go's reason. So the token is shown once, at the moment of
+// minting, and the screen says so there rather than letting somebody
+// find out by coming back for it.
+package slides
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/form"
+)
+
+// --- the token ---
+
+// agentTokenPrefix marks a Slides agent token on sight. A secret that
+// leaks into a log or a paste is found by whoever is scanning for it,
+// and a prefix is what makes that scan possible; it is also what tells
+// a person which of the six tokens in their password manager this is.
+const agentTokenPrefix = "sl_"
+
+// agentTokenAlphabet is base62: what survives being copied out of a
+// terminal, pasted into a YAML file and typed into an environment
+// variable, with nothing to escape and nothing to trim.
+const agentTokenAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+
+// agentTokenLength is how many of those characters follow the prefix.
+// Forty over sixty-two symbols is about 238 bits, which is not
+// guessable by anything, and short enough to fit on one line.
+const agentTokenLength = 40
+
+// newAgentToken mints one. len(agentTokenAlphabet) is 62, not a power
+// of two, so a plain `b % 62` would be very slightly biased — this
+// rejects the tail of the byte range instead, because a credential is
+// the one place in this app where "astronomically unlikely to matter"
+// is not the standard to hold a random number to.
+func newAgentToken() (string, error) {
+ // 248 is the largest multiple of 62 at or below 256; a byte at or
+ // above it is drawn again.
+ const limit = 248
+ out := make([]byte, 0, agentTokenLength)
+ buf := make([]byte, agentTokenLength)
+ for len(out) < agentTokenLength {
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("slides: generate agent token: %w", err)
+ }
+ for _, b := range buf {
+ if b >= limit {
+ continue
+ }
+ out = append(out, agentTokenAlphabet[int(b)%len(agentTokenAlphabet)])
+ if len(out) == agentTokenLength {
+ break
+ }
+ }
+ }
+ return agentTokenPrefix + string(out), nil
+}
+
+// hashAgentToken is the stored form: SHA-256 hex, unsalted. The token
+// is 238 bits of randomness, so there is no dictionary to defend
+// against and no salt to add; what an unsalted hash buys is that
+// resolving a bearer is one indexed read.
+//
+// Written out again rather than shared with hashShareToken and
+// hashRemoteToken for presentation.go's reason: the three are separate
+// protocol decisions that happen to agree today, and a change to how
+// share links are stored must not silently change what every live
+// agent token means.
+func hashAgentToken(token string) string {
+ sum := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(sum[:])
+}
+
+// --- the principal ---
+
+// agentPrincipal is spec §7.1's `<name>~<operator email>`: what a
+// person reads in an activity list to know which program made a change
+// and on whose authority.
+//
+// The tilde is the separator because neither half can contain one — an
+// agent name is [a-z0-9-] (validAgentName) and an email address's
+// local part cannot hold a bare tilde in any address this app admits —
+// so the string parses back apart unambiguously.
+func agentPrincipal(name, operatorEmail string) string {
+ return name + "~" + operatorEmail
+}
+
+// agentActor is spec §6.2's actor string for an agent, the counterpart
+// of decks.go's actorFor. Every write over the API records one.
+func agentActor(id int64) string { return fmt.Sprintf("agent:%d", id) }
+
+// agentMark is the robot prefix an agent's principal is shown with,
+// amadan's own convention. It is a mark and not a colour: an agent's
+// write is a different kind of fact from a person's, and a tint alone
+// would say so only to people who can see it.
+const agentMark = "🤖 "
+
+// --- the request principal ---
+
+// principal is the resolved bearer: which agent is calling, and the
+// person whose visibility it borrows. Held together because every API
+// handler needs both — the actor to record, and the operator to scope.
+type principal struct {
+ agent Agent
+ operator User
+}
+
+// actor is what this principal's writes are recorded as.
+func (p principal) actor() string { return agentActor(p.agent.ID) }
+
+// principalKey is the context key the bearer middleware stashes a
+// resolved principal under. An unexported type, so nothing outside
+// this package can put one there.
+type principalKey struct{}
+
+// principalFrom answers the principal this request carries, if the
+// bearer gate resolved one. Only /api/v1 is behind that gate, so on
+// every HTML screen this answers false — which is what makes "the HTML
+// routes never read a bearer" a fact about the router rather than a
+// promise each handler has to keep.
+func principalFrom(r *http.Request) (principal, bool) {
+ p, ok := r.Context().Value(principalKey{}).(principal)
+ return p, ok
+}
+
+// bearerActor is the API's whole gate: an `Authorization: Bearer`
+// header, resolved to a live agent and its operator, or a JSON 401.
+//
+// It reads the header and nothing else. No cookie, no fallback to a
+// session, no "signed in OR bearer" — an API that accepted a cookie
+// would be an API every page in the person's browser could drive.
+//
+// The lookup is by hash, so it is one indexed read; the compare that
+// follows is constant-time over two hex strings of fixed length, which
+// the index alone would not be. Belt and braces: SQLite's comparison
+// is not constant-time, so the index narrows and this decides.
+func (a *app) bearerActor(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // presentation.go's reader, which the phone remote already
+ // uses: one place decides what `Authorization: Bearer` means
+ // in this app.
+ token := bearerToken(r)
+ if token == "" {
+ apiFail(w, http.StatusUnauthorized, errUnauthorized)
+ return
+ }
+ hash := hashAgentToken(token)
+
+ var agent Agent
+ err := a.db.G.WithContext(r.Context()).
+ Where("token_hash = ? AND revoked_at IS NULL", hash).Take(&agent).Error
+ switch {
+ case err == nil:
+ case errors.Is(err, gorm.ErrRecordNotFound):
+ // A token that was never real and one that was revoked get
+ // the same answer, share.go's rule: anything else would let
+ // a holder of a guess learn that it had once worked.
+ apiFail(w, http.StatusUnauthorized, errUnauthorized)
+ return
+ default:
+ a.logger.Error("slides: resolve a bearer", "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(agent.TokenHash), []byte(hash)) != 1 {
+ apiFail(w, http.StatusUnauthorized, errUnauthorized)
+ return
+ }
+
+ var operator User
+ if err := a.db.G.WithContext(r.Context()).Take(&operator, agent.OperatorID).Error; err != nil {
+ // An agent whose operator is gone is an agent with nobody
+ // answerable for it, and it can see nothing: 401 rather
+ // than a principal with no visibility, so the answer says
+ // what is wrong.
+ a.logger.Error("slides: an agent's operator is missing",
+ "agent", agent.ID, "operator", agent.OperatorID, "err", err)
+ apiFail(w, http.StatusUnauthorized, errUnauthorized)
+ return
+ }
+
+ ctx := context.WithValue(r.Context(), principalKey{}, principal{agent: agent, operator: operator})
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// --- names ---
+
+// agentNameMin and agentNameMax bound the short half of a principal.
+// Two characters because one is not a name; thirty-two because the
+// principal has to stay readable in a list row beside an address.
+const (
+ agentNameMin = 2
+ agentNameMax = 32
+)
+
+// validAgentName holds the name to what a principal can carry:
+// lowercase letters, digits and hyphens. No dot, no at sign and no
+// tilde, so `<name>~<email>` splits at exactly one place and no name
+// can be made to look like an address.
+func validAgentName(name string) bool {
+ if len(name) < agentNameMin || len(name) > agentNameMax {
+ return false
+ }
+ for _, c := range name {
+ switch {
+ case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+// --- actor labels ---
+
+// actorLabels resolves a set of actor strings to what a person reads:
+// an address for a person, the robot mark plus the principal for an
+// agent.
+//
+// Two queries for any number of actors, because the deck list calls it
+// once with every row's actor rather than once per row.
+//
+// An actor that resolves to nothing — a deleted person, an agent row
+// that is gone — is left out of the map rather than rendered as a raw
+// id. A list row simply says nothing about who last touched it, which
+// is true, where "person:7" would be noise.
+func (a *app) actorLabels(ctx context.Context, actors []string) map[string]string {
+ var personIDs, agentIDs []int64
+ for _, actor := range actors {
+ kind, raw, found := strings.Cut(actor, ":")
+ if !found {
+ continue
+ }
+ id, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil {
+ continue
+ }
+ switch kind {
+ case "person":
+ personIDs = append(personIDs, id)
+ case "agent":
+ agentIDs = append(agentIDs, id)
+ }
+ }
+
+ out := map[string]string{}
+ if len(personIDs) > 0 {
+ var people []User
+ if err := a.db.G.WithContext(ctx).Where("id IN ?", personIDs).Find(&people).Error; err != nil {
+ a.logger.Error("slides: resolve person actors", "err", err)
+ }
+ for _, u := range people {
+ name := u.Name
+ if name == "" {
+ name = u.Email
+ }
+ out[actorFor(u)] = name
+ }
+ }
+ if len(agentIDs) > 0 {
+ var agents []Agent
+ if err := a.db.G.WithContext(ctx).Where("id IN ?", agentIDs).Find(&agents).Error; err != nil {
+ a.logger.Error("slides: resolve agent actors", "err", err)
+ }
+ // One query for the operators the agents name, so this stays
+ // two queries however many agents there are.
+ ids := make([]int64, 0, len(agents))
+ for _, ag := range agents {
+ ids = append(ids, ag.OperatorID)
+ }
+ emails := map[int64]string{}
+ var operators []User
+ if err := a.db.G.WithContext(ctx).Where("id IN ?", ids).Find(&operators).Error; err != nil {
+ a.logger.Error("slides: resolve agent operators", "err", err)
+ }
+ for _, u := range operators {
+ emails[u.ID] = u.Email
+ }
+ for _, ag := range agents {
+ out[agentActor(ag.ID)] = agentMark + agentPrincipal(ag.Name, emails[ag.OperatorID])
+ }
+ }
+ return out
+}
+
+// --- GET /agents ---
+
+type agentRow struct {
+ Principal string
+ Created string
+ StatusLabel string
+ StatusTone string
+ Revoked bool
+ RevokeLabel string
+ RevokeAria string
+ RevokeHref string
+}
+
+type agentsPage struct {
+ shell
+ Title string
+ Lead string
+ WarningTitle string
+ Warning string
+ Action string
+ NameLabel string
+ NameHint string
+ NameValue string
+ CreateLabel string
+ Errors map[string]string
+
+ // NewToken is the token this request minted, shown once and never
+ // again. Empty on every visit that did not just create one.
+ NewToken string
+ NewPrincipal string
+ NewTitle string
+ NewHint string
+ NewLabel string
+ NewUse string
+ CopyHint string
+
+ Agents []agentRow
+ EmptyTitle string
+ EmptyBody string
+}
+
+// agents serves GET /agents: this person's agents, what a token can
+// do, and the one form that mints another.
+func (a *app) agents(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.viewer(w, r)
+ if !ok {
+ return
+ }
+ a.renderAgentsPage(w, r, u, r.URL.Query().Get("new"), "", nil)
+}
+
+func (a *app) renderAgentsPage(w http.ResponseWriter, r *http.Request, u User, newToken, name string, errs map[string]string) {
+ var rows []Agent
+ if err := a.db.G.WithContext(r.Context()).
+ Where("operator_id = ?", u.ID).
+ Order("created_at DESC, id DESC").Find(&rows).Error; err != nil {
+ a.fail(w, r, "list agents", err)
+ return
+ }
+
+ page := agentsPage{
+ shell: shellFor(r, u),
+ Title: rastrillo.T(r, "slides.agents.title"),
+ Lead: rastrillo.T(r, "slides.agents.lead"),
+ WarningTitle: rastrillo.T(r, "slides.agents.warning.title"),
+ Warning: rastrillo.T(r, "slides.agents.warning"),
+ Action: "/agents",
+ NameLabel: rastrillo.T(r, "slides.agents.name.label"),
+ NameHint: rastrillo.T(r, "slides.agents.name.hint"),
+ NameValue: name,
+ CreateLabel: rastrillo.T(r, "slides.agents.create"),
+ Errors: errs,
+ EmptyTitle: rastrillo.T(r, "slides.agents.empty.title"),
+ EmptyBody: rastrillo.T(r, "slides.agents.empty.body"),
+ Agents: make([]agentRow, 0, len(rows)),
+ }
+ if newToken != "" {
+ page.NewToken = newToken
+ page.NewTitle = rastrillo.T(r, "slides.agents.new.title")
+ page.NewHint = rastrillo.T(r, "slides.agents.new.body")
+ page.NewLabel = rastrillo.T(r, "slides.agents.new.label")
+ page.NewUse = rastrillo.Tf(r, "slides.agents.new.use", "origin", a.cfg.Origin)
+ page.CopyHint = rastrillo.T(r, "slides.agents.new.copy")
+ // Which agent the token belongs to: the row that carries this
+ // hash, so the page cannot name one agent and show another's
+ // credential.
+ var minted Agent
+ if err := a.db.G.WithContext(r.Context()).
+ Where("token_hash = ?", hashAgentToken(newToken)).Take(&minted).Error; err == nil {
+ page.NewPrincipal = agentPrincipal(minted.Name, u.Email)
+ }
+ }
+ for _, ag := range rows {
+ principal := agentPrincipal(ag.Name, u.Email)
+ row := agentRow{
+ Principal: principal,
+ Created: rastrillo.Tf(r, "slides.agents.row.created", "when", whenWords(r, ag.CreatedAt)),
+ Revoked: ag.RevokedAt != nil,
+ RevokeLabel: rastrillo.T(r, "slides.agents.revoke"),
+ RevokeAria: rastrillo.Tf(r, "slides.agents.revoke.aria", "principal", principal),
+ RevokeHref: fmt.Sprintf("/agents/%d/revoke", ag.ID),
+ }
+ if row.Revoked {
+ row.StatusLabel = rastrillo.T(r, "slides.agents.revoked")
+ row.StatusTone = "neutral"
+ } else {
+ row.StatusLabel = rastrillo.T(r, "slides.agents.active")
+ row.StatusTone = "positive"
+ }
+ page.Agents = append(page.Agents, row)
+ }
+ render(w, "agents", page)
+}
+
+// --- POST /agents ---
+
+// createAgent mints one agent and its token, and lands back on the
+// page carrying the token so it can be shown once.
+//
+// The token rides in the redirect's query string, share.go's shape and
+// share.go's accounting: it is a real cost — a URL with a live
+// credential in it lands in the browser's history — and it is bounded,
+// because the redirect is same-origin and the page it lands on is the
+// person's own.
+func (a *app) createAgent(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.viewer(w, r)
+ if !ok {
+ return
+ }
+
+ p := form.Parse(r, form.Field{Name: "name", Required: true})
+ name := strings.TrimSpace(p.String("name"))
+ errs := map[string]string{}
+ switch {
+ case !p.OK() || !validAgentName(name):
+ // The framework's own message is English built from the field
+ // name; every visible string in this app is a catalog key, so
+ // the failure is kept and the sentence replaced.
+ errs["Name"] = rastrillo.T(r, "slides.agents.name.invalid")
+ default:
+ var taken int64
+ if err := a.db.G.WithContext(r.Context()).Model(&Agent{}).
+ Where("operator_id = ? AND name = ? AND revoked_at IS NULL", u.ID, name).
+ Count(&taken).Error; err != nil {
+ a.fail(w, r, "check an agent name", err)
+ return
+ }
+ if taken > 0 {
+ errs["Name"] = rastrillo.T(r, "slides.agents.name.taken")
+ }
+ }
+ if len(errs) > 0 {
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ a.renderAgentsPage(w, r, u, "", p.Echo()["name"], errs)
+ return
+ }
+
+ token, err := newAgentToken()
+ if err != nil {
+ a.fail(w, r, "mint an agent token", err)
+ return
+ }
+ agent := Agent{
+ Name: name,
+ OperatorID: u.ID,
+ TokenHash: hashAgentToken(token),
+ CreatedAt: time.Now().UTC(),
+ }
+ if err := a.db.G.WithContext(r.Context()).Create(&agent).Error; err != nil {
+ a.fail(w, r, "create agent", err)
+ return
+ }
+ a.logger.Info("slides: agent minted",
+ "agent", agent.ID, "principal", agentPrincipal(agent.Name, u.Email))
+ http.Redirect(w, r, "/agents?new="+token, http.StatusSeeOther)
+}
+
+// --- POST /agents/{id}/revoke ---
+
+// revokeAgent turns one agent off. Scoped by operator, so another
+// person's agent id is a 404 rather than a revocation nobody was
+// entitled to make; revoking twice is not an error, because the token
+// is dead either way and a double-clicked button should not look like
+// a failure.
+func (a *app) revokeAgent(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.viewer(w, r)
+ if !ok {
+ return
+ }
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ a.notFound(w, r)
+ return
+ }
+
+ // revoked_at IS NULL in the WHERE, so a second POST cannot move the
+ // timestamp: when an agent was turned off is a fact about it.
+ res := a.db.G.WithContext(r.Context()).Model(&Agent{}).
+ Where("id = ? AND operator_id = ? AND revoked_at IS NULL", id, u.ID).
+ Update("revoked_at", time.Now().UTC())
+ if res.Error != nil {
+ a.fail(w, r, "revoke agent", res.Error)
+ return
+ }
+ if res.RowsAffected == 0 {
+ var n int64
+ if err := a.db.G.WithContext(r.Context()).Model(&Agent{}).
+ Where("id = ? AND operator_id = ?", id, u.ID).Count(&n).Error; err != nil {
+ a.fail(w, r, "check an agent", err)
+ return
+ }
+ if n == 0 {
+ a.notFound(w, r)
+ return
+ }
+ } else {
+ a.logger.Info("slides: agent revoked", "agent", id, "actor", actorFor(u))
+ }
+ http.Redirect(w, r, "/agents", http.StatusSeeOther)
+}
diff --git a/internal/slides/api.go b/internal/slides/api.go
new file mode 100644
index 0000000..2963d0b
--- /dev/null
+++ b/internal/slides/api.go
@@ -0,0 +1,770 @@
+// api.go is the drive surface (spec §7): the JSON an agent speaks to
+// build a deck, look at what it made, and put it on a projector. No
+// person's browser ever reaches it.
+//
+// # Why it is mounted outside the chi router
+//
+// csrf.Protect is app-wide (app.go) and it is right to be: it refuses
+// a state-changing request that carries no browser evidence of
+// same-origin submission, and "no evidence" is exactly what a
+// non-browser client sends — no Sec-Fetch-Site, no Origin, no Referer.
+// csrf.SameOrigin's last clause refuses that outright, so every POST
+// an agent made through the chi router would be a 403 with an HTML
+// body.
+//
+// So /api/v1 is mounted on the ServeMux ahead of the chi router, with
+// its own gate. That is not a hole in the CSRF defence: a browser
+// never attaches an `Authorization` header of its own, so a page on
+// another origin cannot make a request this surface will authenticate,
+// whatever it does with credentials or forms. The capability is the
+// header, and CSRF is an attack on ambient credentials.
+//
+// # What it is
+//
+// Every answer with a body is JSON, including every error, and every
+// error is `{"error": "<identifier>"}` — a fixed English word a
+// program branches on, never a translated sentence. Some carry more:
+// a refused batch adds `op` and `message`, which is the core's own
+// account of which operation it refused and why.
+//
+// Visibility is the OPERATOR's visibility, evaluated through
+// decks.go's visibleDecks like every screen. An agent is exactly as
+// powerful as the person who minted its token and never a step more.
+package slides
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "mime"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+
+ "amadan.net/oficina/slides/internal/slides/core"
+ "amadan.net/oficina/slides/internal/slides/deck"
+ slidehtml "amadan.net/oficina/slides/internal/slides/render"
+)
+
+// apiPrefix is where the drive surface lives, and what app.go mounts
+// on the ServeMux. Versioned from the first day it exists: an agent's
+// configuration outlives a deploy, so the day this shape changes there
+// has to be somewhere for the old one to keep answering.
+const apiPrefix = "/api/v1"
+
+// The error identifiers. Fixed English words, not catalog keys: this
+// is a machine surface, and a caller that branches on the error has to
+// see the same string whatever Accept-Language it happened to send.
+// The words a PERSON reads about a failure are the API caller's to
+// write, from these plus the message a 422 carries.
+const (
+ errUnauthorized = "unauthorized"
+ errNotFound = "not_found"
+ errInvalid = "invalid"
+ errConflict = "conflict"
+ errUnsupported = "unsupported_media_type"
+ errTooLarge = "too_large"
+ errMethod = "method_not_allowed"
+ errInternal = "internal"
+)
+
+// maxAPIBody is the largest request body this surface reads. A mebibyte
+// is far more than any batch of operations needs — the biggest thing an
+// op carries is a slide's Markdown — and it is the bound that stops an
+// unbounded read from being the way to take the process down. Asset
+// bytes never come through here (spec §7.2: a presigned upload, then
+// register_asset naming the result).
+const maxAPIBody = 1 << 20
+
+// apiRouter is the whole drive surface, gated by the bearer and
+// nothing else.
+//
+// Full paths rather than a mount, because the ServeMux hands the
+// request over with its path intact: what is written here is what a
+// caller types, which is also what docs/api.md documents.
+func (a *app) apiRouter() http.Handler {
+ r := chi.NewRouter()
+ r.Use(a.bearerActor)
+ r.Get(apiPrefix+"/decks", a.apiListDecks)
+ r.Post(apiPrefix+"/decks", a.apiCreateDeck)
+ r.Get(apiPrefix+"/decks/{sqid}", a.apiGetDeck)
+ r.Post(apiPrefix+"/decks/{sqid}/ops", a.apiOps)
+ // {file} rather than {id}.html: chi can match a static tail inside
+ // a segment, but the extension is part of what this endpoint means
+ // — it is the HTML rendering of a slide, and the .svg thumbnail of
+ // spec §7.2 lands beside it in slice 3 — so the handler reads the
+ // extension itself and there is one place to add the second one.
+ r.Get(apiPrefix+"/decks/{sqid}/slides/{file}", a.apiSlide)
+ r.Post(apiPrefix+"/decks/{sqid}/present", a.apiPresent)
+ // Anything else under the prefix is a 404 in this surface's own
+ // shape, not chi's plain-text one: a caller parsing JSON should
+ // never be handed something else because it mistyped a path.
+ r.NotFound(func(w http.ResponseWriter, r *http.Request) {
+ apiFail(w, http.StatusNotFound, errNotFound)
+ })
+ r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
+ apiFail(w, http.StatusMethodNotAllowed, errMethod)
+ })
+ return r
+}
+
+// --- answering ---
+
+// writeJSON is every successful answer: the header, the status, the
+// body, in that order.
+//
+// Encoded into a buffer before anything touches the wire, render.go's
+// reason: an encoding failure becomes a clean 500 rather than a
+// half-written body with a 200 already on it.
+func (a *app) writeJSON(w http.ResponseWriter, status int, body any) {
+ b, err := json.Marshal(body)
+ if err != nil {
+ a.logger.Error("slides: encode an API answer", "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ w.Write(b)
+}
+
+// apiFail is every error with nothing more to say than which one it
+// is. Hand-encoded rather than through json.Marshal so the error path
+// cannot itself fail to encode.
+func apiFail(w http.ResponseWriter, status int, id string) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ fmt.Fprintf(w, "{\"error\":%q}\n", id)
+}
+
+// apiInvalid is the 422 with an account of what was wrong: the message
+// as the core (or this file) put it, and the index of the operation it
+// belongs to when there is one. op is omitted when the failure is not
+// about a particular operation — a missing title, a theme that is not
+// one of the six.
+func (a *app) apiInvalid(w http.ResponseWriter, message string, op int) {
+ body := map[string]any{"error": errInvalid, "message": message}
+ if op >= 0 {
+ body["op"] = op
+ }
+ a.writeJSON(w, http.StatusUnprocessableEntity, body)
+}
+
+// --- reading ---
+
+// readJSON reads a request body into dst, answering the three
+// refusals a JSON surface owes a caller: the wrong media type, a body
+// too big to be an edit, and bytes that are not the shape the endpoint
+// takes. It reports whether the caller may carry on.
+func (a *app) readJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
+ ct := r.Header.Get("Content-Type")
+ if ct != "" {
+ kind, _, err := mime.ParseMediaType(ct)
+ if err != nil || kind != "application/json" {
+ apiFail(w, http.StatusUnsupportedMediaType, errUnsupported)
+ return false
+ }
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, maxAPIBody)
+ dec := json.NewDecoder(r.Body)
+ if err := dec.Decode(dst); err != nil {
+ var tooBig *http.MaxBytesError
+ if errors.As(err, &tooBig) {
+ apiFail(w, http.StatusRequestEntityTooLarge, errTooLarge)
+ return false
+ }
+ a.apiInvalid(w, "the body is not the JSON this endpoint takes: "+err.Error(), -1)
+ return false
+ }
+ return true
+}
+
+// --- resolving ---
+
+// apiPrincipal is the resolved bearer, or a 500. Past bearerActor
+// there is always one; this exists so a handler reads it in one line
+// and a wiring mistake surfaces as an error rather than a zero value
+// that would scope every query to user 0.
+func (a *app) apiPrincipal(w http.ResponseWriter, r *http.Request) (principal, bool) {
+ p, ok := principalFrom(r)
+ if !ok {
+ a.logger.Error("slides: no principal past the bearer gate", "path", r.URL.Path)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return principal{}, false
+ }
+ return p, true
+}
+
+// apiDeckFor resolves the {sqid} in r to a deck the OPERATOR may read,
+// answering 404 itself when there is none — deckFor's rule and
+// deckFor's reason, in JSON: a deck that is not yours does not exist,
+// so a stranger holding a sqid cannot learn whether it is real.
+func (a *app) apiDeckFor(w http.ResponseWriter, r *http.Request, p principal) (Deck, bool) {
+ var d Deck
+ err := a.visibleDecks(r.Context(), p.operator).
+ Where("decks.sqid = ?", chi.URLParam(r, "sqid")).Take(&d).Error
+ switch {
+ case err == nil:
+ return d, true
+ case errors.Is(err, gorm.ErrRecordNotFound):
+ apiFail(w, http.StatusNotFound, errNotFound)
+ default:
+ a.logger.Error("slides: load a deck for the API", "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ }
+ return Deck{}, false
+}
+
+// --- GET /api/v1/decks ---
+
+// apiDeckSummary is one row of the list: everything an agent needs to
+// pick a deck without opening it, plus the slide count, which it can
+// only know by opening it.
+type apiDeckSummary struct {
+ Sqid string `json:"sqid"`
+ Title string `json:"title"`
+ Slides int `json:"slides"`
+ Rung string `json:"rung"`
+ UpdatedAt string `json:"updated_at"`
+ Seq int64 `json:"seq"`
+}
+
+// apiListDecks serves GET /api/v1/decks: every deck the operator may
+// open, most recently edited first — the deck list screen's query,
+// answered as JSON.
+//
+// The slide count costs one wasm instance per deck (decks.go's
+// slideCount says why), and a deck whose document will not open is
+// reported with a count of zero rather than taking the whole list
+// down with it.
+func (a *app) apiListDecks(w http.ResponseWriter, r *http.Request) {
+ p, ok := a.apiPrincipal(w, r)
+ if !ok {
+ return
+ }
+ var rows []Deck
+ if err := a.visibleDecks(r.Context(), p.operator).
+ Order("decks.updated_at DESC, decks.id DESC").Find(&rows).Error; err != nil {
+ a.logger.Error("slides: list decks for the API", "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+ out := make([]apiDeckSummary, 0, len(rows))
+ for _, d := range rows {
+ slides, _ := a.slideCount(r.Context(), d)
+ out = append(out, apiDeckSummary{
+ Sqid: d.Sqid,
+ Title: d.Title,
+ Slides: slides,
+ Rung: d.Rung,
+ UpdatedAt: d.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
+ Seq: d.Seq,
+ })
+ }
+ a.writeJSON(w, http.StatusOK, map[string]any{"decks": out})
+}
+
+// --- POST /api/v1/decks ---
+
+// apiCreateDeck serves POST /api/v1/decks. The deck is the OPERATOR's
+// — an agent owns nothing, because an agent can be revoked and a deck
+// must outlive that — and its first update is recorded against the
+// AGENT, so the history says which program made it.
+//
+// The opening batch is createDeck's own (decks.go's openingOps): a new
+// deck opens on a title slide whichever surface made it, so an agent
+// and a person produce the same thing.
+func (a *app) apiCreateDeck(w http.ResponseWriter, r *http.Request) {
+ p, ok := a.apiPrincipal(w, r)
+ if !ok {
+ return
+ }
+ var body struct {
+ Title string `json:"title"`
+ Theme string `json:"theme"`
+ }
+ if !a.readJSON(w, r, &body) {
+ return
+ }
+ title := strings.TrimSpace(body.Title)
+ if title == "" {
+ a.apiInvalid(w, "title is required", -1)
+ return
+ }
+ theme := body.Theme
+ if theme == "" {
+ theme = defaultTheme
+ }
+ if !validTheme(theme) {
+ a.apiInvalid(w, "theme must be one of "+strings.Join(themeIDs, ", "), -1)
+ return
+ }
+
+ d, err := a.createDeckRow(r.Context(), p.operator, title, theme)
+ if err != nil {
+ a.logger.Error("slides: create a deck for the API", "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+ ops, err := openingOps(title, theme)
+ if err != nil {
+ a.logger.Error("slides: build the opening ops for the API", "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+ if err := a.editDeck(r.Context(), &d, p.actor(), ops, nil); err != nil {
+ a.logger.Error("slides: write the opening ops for the API", "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+ a.writeJSON(w, http.StatusCreated, map[string]any{"sqid": d.Sqid})
+}
+
+// --- GET /api/v1/decks/{sqid} ---
+
+// apiGetDeck serves the whole document: the core's own view (§2's
+// model, core/src/view.rs) with the deck's sqid and seq alongside it.
+//
+// The seq is the ETag, unquoted nowhere and quoted everywhere, because
+// the seq is exactly what an ETag is for: it moves on every write and
+// on nothing else, so an agent holding one already has the document.
+func (a *app) apiGetDeck(w http.ResponseWriter, r *http.Request) {
+ p, ok := a.apiPrincipal(w, r)
+ if !ok {
+ return
+ }
+ d, ok := a.apiDeckFor(w, r, p)
+ if !ok {
+ return
+ }
+
+ etag := strconv.Quote(strconv.FormatInt(d.Seq, 10))
+ w.Header().Set("ETag", etag)
+ if matchesETag(r.Header.Get("If-None-Match"), etag) {
+ // Before the document is opened, which is the whole point: a
+ // 304 costs no wasm instance and no log replay.
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ h, _, err := a.openDeck(r.Context(), d)
+ if err != nil {
+ a.logger.Error("slides: open a deck for the API", "deck", d.Sqid, "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+ defer h.Close(r.Context())
+ raw, err := h.JSON(r.Context())
+ if err != nil {
+ a.logger.Error("slides: read a deck view for the API", "deck", d.Sqid, "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+
+ // Decoded to raw fragments rather than to any: the view carries
+ // fractional z values and box coordinates, and a round trip
+ // through map[string]any would reformat every number the core
+ // wrote. This adds two keys and re-encodes the rest byte for byte.
+ var view map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &view); err != nil {
+ a.logger.Error("slides: the deck view is not a JSON object", "deck", d.Sqid, "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+ view["sqid"], _ = json.Marshal(d.Sqid)
+ view["seq"], _ = json.Marshal(d.Seq)
+ a.writeJSON(w, http.StatusOK, view)
+}
+
+// matchesETag reads an If-None-Match header the way RFC 9110 says to:
+// a comma-separated list, `*` matching anything, and a weak validator
+// comparing equal to its strong twin (weak comparison is what
+// If-None-Match uses).
+func matchesETag(header, etag string) bool {
+ if header == "" {
+ return false
+ }
+ for _, candidate := range strings.Split(header, ",") {
+ candidate = strings.TrimSpace(candidate)
+ if candidate == "*" {
+ return true
+ }
+ if strings.TrimPrefix(candidate, "W/") == strings.TrimPrefix(etag, "W/") {
+ return true
+ }
+ }
+ return false
+}
+
+// --- POST /api/v1/decks/{sqid}/ops ---
+
+// coreOpIndex finds which operation the core refused. Its messages are
+// prefixed "op <i>: " and "op <i> (applying): " (core/src/ops.rs's
+// apply_ops), which is the only place that index exists — the batch is
+// checked as one thing, so nothing on this side knows it.
+var coreOpIndex = regexp.MustCompile(`^op (\d+)\b`)
+
+// apiOps serves the operation batch (spec §7.2): the one way anything
+// in a document changes.
+//
+// Two things it does that the editor's own path does not:
+//
+// - It MINTS the ids. The core never draws entropy (decks.go's
+// newID), so `add_slide`, `add_block` and `duplicate_slide` each
+// need an id supplied by the host — and an agent writing a batch
+// should not have to invent twelve base32 characters to add a
+// slide. Any it left out are minted here and returned keyed by the
+// operation's index, so the next batch can address what this one
+// made. Ids the caller DID supply are left exactly as they are.
+// - It reports which operation was refused, so a caller can fix that
+// one rather than bisecting its own batch.
+//
+// A refused batch applies nothing — that is the core's guarantee, not
+// this handler's (core/src/ops.rs's two-pass check) — and this answers
+// 422 with the core's own account of it.
+func (a *app) apiOps(w http.ResponseWriter, r *http.Request) {
+ p, ok := a.apiPrincipal(w, r)
+ if !ok {
+ return
+ }
+ d, ok := a.apiDeckFor(w, r, p)
+ if !ok {
+ return
+ }
+
+ // Raw fragments, not any: an op carries fractional z values and box
+ // coordinates the core wrote a contract for, and this handler adds
+ // keys to an op rather than rewriting it.
+ var body struct {
+ Ops []map[string]json.RawMessage `json:"ops"`
+ }
+ if !a.readJSON(w, r, &body) {
+ return
+ }
+ if len(body.Ops) == 0 {
+ a.apiInvalid(w, "ops must be a non-empty list of operations", -1)
+ return
+ }
+
+ ids, err := a.applyAgentOps(r.Context(), &d, p.actor(), body.Ops)
+ switch {
+ case err == nil:
+ a.writeJSON(w, http.StatusOK, map[string]any{"seq": d.Seq, "ids": ids})
+ case errors.Is(err, errDeckBusy):
+ // Two attempts have already been made; a third would be
+ // spinning. 409 is the honest answer, and the caller re-reads
+ // the deck and decides what its batch now means.
+ apiFail(w, http.StatusConflict, errConflict)
+ default:
+ var rejected *core.RejectedError
+ if errors.As(err, &rejected) {
+ msg := rejected.Error()
+ op := -1
+ if m := coreOpIndex.FindStringSubmatch(msg); m != nil {
+ if i, convErr := strconv.Atoi(m[1]); convErr == nil {
+ op = i
+ }
+ }
+ a.apiInvalid(w, msg, op)
+ return
+ }
+ a.logger.Error("slides: apply an agent's ops", "deck", d.Sqid, "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ }
+}
+
+// applyAgentOps mints the batch's missing ids and applies it, with one
+// retry on the optimistic lock.
+//
+// The deck is opened once on the happy path: minting needs the current
+// document (how many blocks a slide has, for duplicate_slide), and so
+// does applying, so both run against the one handle. A second writer
+// landing in between is errDeckBusy, and the second attempt re-reads
+// the row and reopens — the ids minted on the first attempt are
+// already written into the ops, so it mints nothing new and the caller
+// is told the same ids either way.
+func (a *app) applyAgentOps(ctx context.Context, d *Deck, actor string, ops []map[string]json.RawMessage) (map[string]string, error) {
+ var ids map[string]string
+ var lastErr error
+ for attempt := 0; attempt < 2; attempt++ {
+ h, view, err := a.openDeck(ctx, *d)
+ if err != nil {
+ return nil, err
+ }
+ minted, err := mintOpIDs(ops, view)
+ if err != nil {
+ h.Close(ctx)
+ return nil, err
+ }
+ if attempt == 0 {
+ ids = minted
+ }
+ encoded, err := json.Marshal(ops)
+ if err != nil {
+ h.Close(ctx)
+ return nil, fmt.Errorf("slides: encode ops: %w", err)
+ }
+ _, err = a.applyOps(ctx, h, d, actor, encoded, nil)
+ h.Close(ctx)
+ if err == nil {
+ return ids, nil
+ }
+ lastErr = err
+ if !errors.Is(err, errDeckBusy) {
+ return nil, err
+ }
+ // Our copy's Seq is what just lost; re-read it before retrying.
+ if err := a.db.G.WithContext(ctx).First(d, d.ID).Error; err != nil {
+ return nil, fmt.Errorf("slides: reload deck %s: %w", d.Sqid, err)
+ }
+ }
+ return nil, lastErr
+}
+
+// mintOpIDs fills in the ids the core requires and the caller left
+// out, in place, and returns them keyed by the operation's index as a
+// string — a JSON object's keys are strings, and "3" is the third
+// operation the caller sent.
+//
+// It keeps a running count of each slide's blocks, starting from the
+// document and updated as it walks the batch, because duplicate_slide
+// needs exactly as many new block ids as the slide it copies has —
+// including blocks an earlier operation in the same batch added.
+//
+// It validates nothing else. Every vocabulary, every reference and
+// every required field is the core's to check (core/src/ops.rs), and a
+// second opinion here would be a copy to keep in step for no checking
+// that is not already done.
+func mintOpIDs(ops []map[string]json.RawMessage, view *deck.Deck) (map[string]string, error) {
+ blocks := map[string]int{}
+ for _, s := range view.Slides {
+ blocks[s.ID] = len(s.Blocks)
+ }
+
+ ids := map[string]string{}
+ for i, op := range ops {
+ name := rawString(op, "op")
+ switch name {
+ case "add_slide":
+ id, err := existingOrNew(op, "id", ids, i)
+ if err != nil {
+ return nil, err
+ }
+ blocks[id] = 0
+ case "add_block":
+ if _, err := existingOrNew(op, "id", ids, i); err != nil {
+ return nil, err
+ }
+ blocks[rawString(op, "slide")]++
+ case "remove_block":
+ if slide := rawString(op, "slide"); blocks[slide] > 0 {
+ blocks[slide]--
+ }
+ case "remove_slide":
+ delete(blocks, rawString(op, "id"))
+ case "duplicate_slide":
+ source := rawString(op, "id")
+ copyID, err := existingOrNew(op, "new_id", ids, i)
+ if err != nil {
+ return nil, err
+ }
+ n := blocks[source]
+ if _, has := op["new_block_ids"]; !has {
+ list := make([]string, 0, n)
+ for range n {
+ id, err := newID()
+ if err != nil {
+ return nil, err
+ }
+ list = append(list, id)
+ }
+ encoded, err := json.Marshal(list)
+ if err != nil {
+ return nil, fmt.Errorf("slides: encode new_block_ids: %w", err)
+ }
+ op["new_block_ids"] = encoded
+ }
+ blocks[copyID] = n
+ }
+ }
+ return ids, nil
+}
+
+// existingOrNew returns the id at key, minting and recording one when
+// the caller left it out.
+func existingOrNew(op map[string]json.RawMessage, key string, ids map[string]string, index int) (string, error) {
+ if raw, has := op[key]; has {
+ var id string
+ if err := json.Unmarshal(raw, &id); err == nil && id != "" {
+ return id, nil
+ }
+ // A key that is there but is not a usable id is left alone for
+ // the core to refuse by name — overwriting it would silently
+ // turn a caller's mistake into a slide it never asked for.
+ return "", nil
+ }
+ id, err := newID()
+ if err != nil {
+ return "", err
+ }
+ encoded, err := json.Marshal(id)
+ if err != nil {
+ return "", fmt.Errorf("slides: encode a minted id: %w", err)
+ }
+ op[key] = encoded
+ ids[strconv.Itoa(index)] = id
+ return id, nil
+}
+
+// rawString reads a JSON string out of an op, answering "" for a key
+// that is absent or is not a string. The core is what refuses either;
+// this only needs enough of the op to know what to mint.
+func rawString(op map[string]json.RawMessage, key string) string {
+ raw, ok := op[key]
+ if !ok {
+ return ""
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err != nil {
+ return ""
+ }
+ return s
+}
+
+// --- GET /api/v1/decks/{sqid}/slides/{id}.html ---
+
+// apiSlide renders one slide as the section the viewer would show it
+// in — spec §7.2's "so an agent can look at what it made", standing in
+// for the SVG thumbnail until slice 3 has one.
+//
+// text/html rather than JSON, and the section alone rather than a
+// page: it is markup to look at, paste into a report, or diff against
+// the last one, and a whole document around it would be noise.
+func (a *app) apiSlide(w http.ResponseWriter, r *http.Request) {
+ p, ok := a.apiPrincipal(w, r)
+ if !ok {
+ return
+ }
+ d, ok := a.apiDeckFor(w, r, p)
+ if !ok {
+ return
+ }
+ slideID, ok := strings.CutSuffix(chi.URLParam(r, "file"), ".html")
+ if !ok || slideID == "" {
+ apiFail(w, http.StatusNotFound, errNotFound)
+ return
+ }
+
+ h, view, err := a.openDeck(r.Context(), d)
+ if err != nil {
+ a.logger.Error("slides: open a deck to render a slide", "deck", d.Sqid, "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+ defer h.Close(r.Context())
+
+ index := -1
+ for i, s := range view.Slides {
+ if s.ID == slideID {
+ index = i
+ break
+ }
+ }
+ if index < 0 {
+ apiFail(w, http.StatusNotFound, errNotFound)
+ return
+ }
+
+ vp := slidehtml.Desktop
+ if r.URL.Query().Get("vp") == "phone" {
+ vp = slidehtml.Phone
+ }
+ section, err := slidehtml.Slide(view, index, vp)
+ if err != nil {
+ a.logger.Error("slides: render a slide for the API", "deck", d.Sqid, "slide", slideID, "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("ETag", strconv.Quote(strconv.FormatInt(d.Seq, 10)+"-"+slideID+"-"+vp.String()))
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprint(w, section)
+}
+
+// --- POST /api/v1/decks/{sqid}/present ---
+
+// apiPresent starts a talk, or picks up the one this agent already has
+// open on this deck — startPresenting's behaviour (presentation.go)
+// and startPresenting's row, reached by an agent rather than by a
+// button.
+//
+// Reusing rather than starting a second session is what makes this
+// safe to call again: a projector already showing /p/{session} keeps
+// showing the same talk, and the agent gets a FRESH remote token,
+// which is the one surprising thing here and is deliberate — only the
+// hash is stored, so the old token cannot be handed back.
+//
+// All four addresses come back absolute, built on a.cfg.Origin rather
+// than on the request's Host: an agent puts these in a chat message or
+// on a projector, and the origin is what the app resolved at boot and
+// refused to start without (config.go).
+func (a *app) apiPresent(w http.ResponseWriter, r *http.Request) {
+ p, ok := a.apiPrincipal(w, r)
+ if !ok {
+ return
+ }
+ d, ok := a.apiDeckFor(w, r, p)
+ if !ok {
+ return
+ }
+
+ token, err := newRemoteToken()
+ if err != nil {
+ a.logger.Error("slides: mint a remote token for the API", "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+ actor := p.actor()
+
+ var live Presentation
+ err = a.livePresentations(r).Where("deck_id = ? AND actor = ?", d.ID, actor).
+ Order("started_at DESC").Take(&live).Error
+ switch {
+ case err == nil:
+ if err := a.db.G.WithContext(r.Context()).Model(&Presentation{}).
+ Where("id = ? AND ended_at IS NULL", live.ID).
+ Update("remote_token_hash", hashRemoteToken(token)).Error; err != nil {
+ a.logger.Error("slides: re-pair a presentation for the API", "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+ case errors.Is(err, gorm.ErrRecordNotFound):
+ live, err = a.createPresentation(r, d, actor, token)
+ if err != nil {
+ a.logger.Error("slides: start a presentation for the API", "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+ default:
+ a.logger.Error("slides: look for a live presentation for the API", "err", err)
+ apiFail(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+
+ a.writeJSON(w, http.StatusOK, map[string]any{
+ "session": live.Sqid,
+ // The projector's address: a viewing capability, safe to put on
+ // a screen in front of a room.
+ "present_url": a.cfg.Origin + "/p/" + live.Sqid,
+ // The presenter's own view, and the remote — both carry the
+ // driving capability, so both are secrets.
+ "notes_url": a.cfg.Origin + "/p/" + live.Sqid + "/notes?t=" + token,
+ "remote_url": a.cfg.Origin + "/r/" + live.Code + "?t=" + token,
+ "code": live.Code,
+ })
+}
diff --git a/internal/slides/app.go b/internal/slides/app.go
index a25efc6..426e519 100644
--- a/internal/slides/app.go
+++ b/internal/slides/app.go
@@ -116,6 +116,12 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
// destructive-action convention). The GET changes nothing.
r.Get("/d/{sqid}/delete", a.confirmDeleteDeck)
r.Post("/d/{sqid}/delete", a.deleteDeck)
+ // Agent tokens (agents.go, spec §7.1). A person's own, like
+ // their decks: minting one is handing a program the ability to
+ // write everything they can write.
+ r.Get("/agents", a.agents)
+ r.Post("/agents", a.createAgent)
+ r.Post("/agents/{id}/revoke", a.revokeAgent)
})
mux := http.NewServeMux()
@@ -125,6 +131,23 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
// handler serves cacheable-forever. Mounted outside the chi router
// so the sign-in page can load its stylesheet signed out.
mux.Handle("GET /static/", assets.Handler())
+ // The drive surface (api.go, spec §7), mounted HERE rather than in
+ // the chi router above, and this is the one deliberate exception in
+ // the whole app's routing.
+ //
+ // csrf.Protect refuses a POST that carries no Sec-Fetch-Site, no
+ // Origin and no Referer (csrf.SameOrigin's last clause), which is
+ // precisely what an agent's HTTP client sends: it is not a browser
+ // and has no such evidence to offer. Inside the chi router, every
+ // write over the API would be a 403 with an HTML body.
+ //
+ // Nothing is given up by moving it out. The API's whole gate is an
+ // `Authorization: Bearer` header, and a browser never attaches one
+ // on its own — so no page on any origin can make a request this
+ // surface will authenticate, which is what CSRF is an attack on.
+ // api.go's package comment says the same thing at length, and
+ // docs/api.md says it to the person reading the API.
+ mux.Handle(apiPrefix+"/", a.apiRouter())
mux.Handle("/", r)
return mux, a, nil
}
diff --git a/internal/slides/decks.go b/internal/slides/decks.go
index 014fc15..c5e7baf 100644
--- a/internal/slides/decks.go
+++ b/internal/slides/decks.go
@@ -446,6 +446,7 @@ func (a *app) ownedDeckFor(w http.ResponseWriter, r *http.Request, u User) (Deck
// every template.
type shell struct {
Person string
+ AgentsLabel string
SignoutLabel string
}
@@ -454,7 +455,11 @@ func shellFor(r *http.Request, u User) shell {
if person == "" {
person = u.Email
}
- return shell{Person: person, SignoutLabel: rastrillo.T(r, "slides.account.signout")}
+ return shell{
+ Person: person,
+ AgentsLabel: rastrillo.T(r, "slides.account.agents"),
+ SignoutLabel: rastrillo.T(r, "slides.account.signout"),
+ }
}
// selectOption is one entry of ui's field-select Options.
@@ -594,12 +599,13 @@ func (a *app) decks(w http.ResponseWriter, r *http.Request) {
EmptyBody: rastrillo.T(r, "slides.decks.empty.body"),
Decks: make([]deckRow, 0, len(rows)),
}
+ by := a.lastChangeBy(r.Context(), rows)
for _, d := range rows {
slides, counted := a.slideCount(r.Context(), d)
page.Decks = append(page.Decks, deckRow{
Href: "/d/" + d.Sqid,
Title: d.Title,
- Sub: rowSub(r, slides, counted, d.UpdatedAt),
+ Sub: rowSub(r, slides, counted, d.UpdatedAt, by[d.ID]),
RungLabel: rungLabel(r, d.Rung),
RungTone: rungTone(d.Rung),
})
@@ -607,6 +613,59 @@ func (a *app) decks(w http.ResponseWriter, r *http.Request) {
render(w, "decks", page)
}
+// lastChangeBy answers, for each deck, who made its most recent write,
+// as a person reads it: an address for a person, the robot mark plus
+// the principal for an agent (agents.go's actorLabels).
+//
+// Spec §6.2's actor column is what makes this possible at all, and
+// spec §7.1 is why it is on the screen: an agent's write is a
+// different kind of fact from a person's, and a list that showed them
+// identically would be hiding the one thing worth knowing about a deck
+// a program has been editing.
+//
+// One query for every row, not one per row: the subquery finds each
+// deck's highest seq and the join reads that row's actor. A failure
+// here is logged and the list renders without the line — the list is
+// how a person reaches every deck, so it must not go down over a
+// caption.
+func (a *app) lastChangeBy(ctx context.Context, decks []Deck) map[int64]string {
+ if len(decks) == 0 {
+ return nil
+ }
+ ids := make([]int64, 0, len(decks))
+ for _, d := range decks {
+ ids = append(ids, d.ID)
+ }
+
+ var latest []struct {
+ DeckID int64
+ Actor string
+ }
+ err := a.db.G.WithContext(ctx).Raw(`
+ SELECT u.deck_id AS deck_id, u.actor AS actor
+ FROM deck_updates u
+ JOIN (SELECT deck_id, MAX(seq) AS seq FROM deck_updates WHERE deck_id IN ? GROUP BY deck_id) m
+ ON m.deck_id = u.deck_id AND m.seq = u.seq`, ids).Scan(&latest).Error
+ if err != nil {
+ a.logger.Error("slides: read the last change on each deck", "err", err)
+ return nil
+ }
+
+ actors := make([]string, 0, len(latest))
+ for _, row := range latest {
+ actors = append(actors, row.Actor)
+ }
+ labels := a.actorLabels(ctx, actors)
+
+ out := make(map[int64]string, len(latest))
+ for _, row := range latest {
+ if label := labels[row.Actor]; label != "" {
+ out[row.DeckID] = label
+ }
+ }
+ return out
+}
+
// slideCount opens d far enough to count its slides. A deck that will
// not open is logged and reported as uncounted rather than failing the
// whole screen: the list is how a person reaches every OTHER deck, so
@@ -621,19 +680,25 @@ func (a *app) slideCount(ctx context.Context, d Deck) (int, bool) {
return len(view.Slides), true
}
-// rowSub is a list row's meta line: how many slides, and when it was
-// last edited. An uncounted deck (see slideCount) shows the time alone
-// rather than a count that would be a guess.
-func rowSub(r *http.Request, slides int, counted bool, updated time.Time) string {
+// rowSub is a list row's meta line: how many slides, when it was last
+// edited, and who made that change. An uncounted deck (see slideCount)
+// shows the time alone rather than a count that would be a guess, and
+// a deck whose last actor could not be resolved shows no name rather
+// than a raw "person:7".
+func rowSub(r *http.Request, slides int, counted bool, updated time.Time, by string) string {
when := whenWords(r, updated)
- if !counted {
- return rastrillo.Tf(r, "slides.decks.row.sub.plain", "when", when)
+ sub := rastrillo.Tf(r, "slides.decks.row.sub.plain", "when", when)
+ if counted {
+ count := rastrillo.Tf(r, "slides.decks.row.slides", "n", slides)
+ if slides == 1 {
+ count = rastrillo.T(r, "slides.decks.row.slides.one")
+ }
+ sub = rastrillo.Tf(r, "slides.decks.row.sub", "slides", count, "when", when)
}
- count := rastrillo.Tf(r, "slides.decks.row.slides", "n", slides)
- if slides == 1 {
- count = rastrillo.T(r, "slides.decks.row.slides.one")
+ if by == "" {
+ return sub
}
- return rastrillo.Tf(r, "slides.decks.row.sub", "slides", count, "when", when)
+ return rastrillo.Tf(r, "slides.decks.row.by", "sub", sub, "who", by)
}
// whenWords is a day-grained relative phrase, falling back to the date
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml
index de08886..71fb7d3 100644
--- a/internal/slides/locales/en.toml
+++ b/internal/slides/locales/en.toml
@@ -53,6 +53,11 @@ slides.decks.row.sub = "{slides} · edited {when}"
# The same line for a deck whose document would not open: the time is
# still true, and a slide count would be a guess.
slides.decks.row.sub.plain = "edited {when}"
+# Who made that last change (spec §6.2's actor). A person is their
+# address; an agent is the robot mark and its principal, so a deck a
+# program has been editing says so on the row rather than only in a
+# history nobody opens.
+slides.decks.row.by = "{sub} · by {who}"
# slides.when.* — day-grained relative time. A deck list is scanned for
# which deck, never for what minute, so nothing here is finer than a
@@ -113,8 +118,43 @@ slides.theme.candy = "Candy"
slides.cancel = "Cancel"
# slides.account.* — the shell's account menu.
+slides.account.agents = "Agents"
slides.account.signout = "Sign out"
+# slides.agents.* — GET/POST /agents, the tokens that let a program
+# drive this person's decks (spec §7.1).
+#
+# The warning is stated before the button rather than in a help page,
+# the share screen's rule: a token is genuinely useful and genuinely
+# means a program can write everything you can write, and the person
+# deciding should see both.
+slides.agents.title = "Agents"
+slides.agents.lead = "An agent is a program that edits your decks over the API — a coding assistant, a script, a bot. It gets its own token and its own name, and every change it makes is recorded under that name rather than yours."
+slides.agents.warning.title = "What a token can do"
+slides.agents.warning = "A token can read and change every deck you can, and start a presentation from any of them. It cannot mint another token, and it stops working the moment you revoke it."
+slides.agents.create = "Create an agent"
+slides.agents.name.label = "Name"
+slides.agents.name.hint = "Lowercase letters, digits and hyphens, 2 to 32 characters — claude, codex, release-bot."
+slides.agents.name.invalid = "Use 2 to 32 lowercase letters, digits or hyphens."
+slides.agents.name.taken = "You already have an agent with that name. Revoke it first, or pick another name."
+slides.agents.revoke = "Revoke"
+# "Revoke" repeated down a list does not say which agent it revokes,
+# so the button carries a full accessible name of its own.
+slides.agents.revoke.aria = "Revoke {principal}"
+slides.agents.active = "Active"
+slides.agents.revoked = "Revoked"
+slides.agents.empty.title = "No agents yet"
+slides.agents.empty.body = "Nothing is driving your decks but you. Create an agent when you want a program to write slides on your behalf."
+slides.agents.row.created = "Created {when}"
+
+# Shown once, at the moment of minting, because only the token's
+# fingerprint is stored and there is nothing to show a second time.
+slides.agents.new.title = "Copy this now — it is not shown again"
+slides.agents.new.body = "Only a fingerprint of this token is stored, so it cannot be shown again. If you lose it, revoke the agent and create another."
+slides.agents.new.label = "Agent token"
+slides.agents.new.copy = "Select the whole field and copy it."
+slides.agents.new.use = "Send it as: Authorization: Bearer <token> to {origin}/api/v1"
+
# slides.error.generic is this app's one fallback error string: a
# failure with nowhere styled to land. Still a key, never a literal,
# even though it is plain text today.
diff --git a/internal/slides/migrations/0005_create_agents.sql b/internal/slides/migrations/0005_create_agents.sql
new file mode 100644
index 0000000..b18c0bb
--- /dev/null
+++ b/internal/slides/migrations/0005_create_agents.sql
@@ -0,0 +1,6 @@
+CREATE TABLE `agents` (`id` integer PRIMARY KEY AUTOINCREMENT,`name` text,`operator_id` integer,`token_hash` text,`created_at` datetime,`revoked_at` datetime);
+
+CREATE UNIQUE INDEX `idx_agents_token_hash` ON `agents`(`token_hash`);
+
+CREATE INDEX `idx_agents_operator_id` ON `agents`(`operator_id`);
+
diff --git a/internal/slides/migrations/schema.sql b/internal/slides/migrations/schema.sql
index 336f15d..5a1e998 100644
--- a/internal/slides/migrations/schema.sql
+++ b/internal/slides/migrations/schema.sql
@@ -1,6 +1,8 @@
-- Generated by rastrillo migration generate; DO NOT EDIT.
-- The schema every migration in this directory adds up to.
+CREATE TABLE `agents` (`id` integer PRIMARY KEY AUTOINCREMENT,`name` text,`operator_id` integer,`token_hash` text,`created_at` datetime,`revoked_at` datetime);
+
CREATE TABLE `deck_grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`user_id` integer,`actor` text,`created_at` datetime);
CREATE TABLE `deck_updates` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`seq` integer,`actor` text,`bytes` blob,`created_at` datetime);
@@ -13,6 +15,10 @@ CREATE TABLE `share_links` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` int
CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text,`name` text,`colour` text,`created_at` datetime);
+CREATE INDEX `idx_agents_operator_id` ON `agents`(`operator_id`);
+
+CREATE UNIQUE INDEX `idx_agents_token_hash` ON `agents`(`token_hash`);
+
CREATE INDEX `idx_deck_grants_deck_id` ON `deck_grants`(`deck_id`);
CREATE INDEX `idx_deck_grants_user_id` ON `deck_grants`(`user_id`);
diff --git a/internal/slides/models.go b/internal/slides/models.go
index 8d90b11..ace5687 100644
--- a/internal/slides/models.go
+++ b/internal/slides/models.go
@@ -14,6 +14,7 @@ var Models = []any{
&DeckGrant{},
&ShareLink{},
&Presentation{},
+ &Agent{},
}
// User is a person who has signed in here — spec §6.1's
@@ -215,3 +216,45 @@ type Presentation struct {
StartedAt time.Time
EndedAt *time.Time
}
+
+// Agent is one non-human principal that may drive this instance's
+// decks — spec §7.1, and suite ruling 9 as this app reads it: an agent
+// is its OWN principal with an operator behind it, never a borrowed
+// session belonging to the person who minted it.
+//
+// The consequence lands on DeckUpdate.Actor. A write an agent makes is
+// recorded "agent:<id>", so a deck's history says which agent wrote a
+// slide and, through OperatorID, on whose authority — two facts a
+// shared session would have collapsed into one wrong one.
+//
+// Name is the short half of the principal (`claude`, `codex`), held to
+// lowercase letters, digits and hyphens so the principal string
+// `<name>~<operator email>` has exactly one separator in it and reads
+// the same everywhere it is shown. It is unique among one person's
+// LIVE agents, enforced in Go rather than by an index, because a name
+// that could never be reused would mean revoking `claude` costs you
+// the name forever.
+//
+// TokenHash is the SHA-256 hex of the token and carries the unique
+// index — share.go's construction and share.go's reason: only the
+// fingerprint is stored, so a bearer resolves in one indexed read and
+// the token itself can be shown exactly once. The screen that mints
+// one says so at the moment it shows it.
+//
+// RevokedAt rather than a delete, ShareLink's reason again: the page
+// keeps showing that the agent existed and that it is off, the actor
+// strings already written into deck histories still resolve to a name,
+// and a token still sitting in some machine's environment stops
+// working rather than silently matching a later agent that happened to
+// reuse the row.
+type Agent struct {
+ ID int64
+ Name string
+ // OperatorID is the person who minted this agent and whose
+ // visibility it borrows: an agent reads and writes exactly the
+ // decks its operator can, never more.
+ OperatorID int64 `gorm:"index"`
+ TokenHash string `gorm:"uniqueIndex"`
+ CreatedAt time.Time
+ RevokedAt *time.Time
+}
diff --git a/internal/slides/render.go b/internal/slides/render.go
index 1c69689..d89c3dd 100644
--- a/internal/slides/render.go
+++ b/internal/slides/render.go
@@ -55,7 +55,7 @@ var publicPages = []string{"signin", "errors"}
// with templates/account.html parsed after the layout, so the shell's
// account menu is defined once rather than in each page (see that
// file's own comment).
-var signedInPages = []string{"decks", "viewer", "deck_notes", "deck_new", "deck_settings", "deck_delete", "share"}
+var signedInPages = []string{"decks", "viewer", "deck_notes", "deck_new", "deck_settings", "deck_delete", "share", "agents"}
// linkPages are screens rendered from a signed-in page's own template
// for somebody who is not signed in — today, exactly one: the viewer,
diff --git a/internal/slides/templates/account.html b/internal/slides/templates/account.html
index 24a9cd1..0976a08 100644
--- a/internal/slides/templates/account.html
+++ b/internal/slides/templates/account.html
@@ -33,6 +33,11 @@
{{define "account"}}
<p slides-account-who>{{.Person}}</p>
<hr>
+{{/* Agents (agents.go): the tokens that let a program drive this
+ person's decks. A link and not a form — it is a screen, and
+ minting is a decision made on that screen rather than from a
+ menu. */}}
+<a href="/agents">{{.AgentsLabel}}</a>
{{/* Sign-out is a POST, so it is a form and not a link: csrf.Protect
guards state-changing methods, and a GET that ends a session is one
a link prefetch can fire. A form also works with scripts off, which
diff --git a/internal/slides/templates/agents.html b/internal/slides/templates/agents.html
new file mode 100644
index 0000000..4c4804c
--- /dev/null
+++ b/internal/slides/templates/agents.html
@@ -0,0 +1,80 @@
+{{/* agents — GET /agents, the tokens that let a program drive this
+ person's decks.
+
+ The share page's screen, for the share page's reason: a credential
+ that is shown once has to be shown well. Four things in the order
+ they matter — the token that was just minted, what a token can do,
+ the agents that already exist with the one button that turns each
+ of them off, and the form that makes another.
+
+ The new token is a callout plus a read-only field rather than a
+ line of prose, because the whole job of this screen at that moment
+ is "select this and copy it", and a field is what a browser lets
+ you select cleanly with no script at all. */}}
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+{{template "page-header" dict "Title" .Title}}
+
+{{if .NewToken}}
+<div rst-box>
+ {{template "callout" dict "Tone" "positive" "Title" .NewTitle "Body" .NewHint}}
+ {{/* Hand-written rather than {{template "field-text"}} for one
+ attribute: readonly, which that partial's contract does not
+ carry. The markup is field-text's own, attribute for attribute,
+ so the control still looks and reads like every other field on
+ every other screen — share.html says the same thing, and the day
+ readonly joins the partial both become one-line calls again. */}}
+ <div rst-field>
+ <label rst-field-label for="agent-token">{{.NewLabel}}</label>
+ <input rst-input id="agent-token" name="agent-token" type="text" value="{{.NewToken}}"
+ readonly aria-describedby="agent-token-hint">
+ <small rst-field-hint id="agent-token-hint">{{.CopyHint}}</small>
+ </div>
+ {{if .NewPrincipal}}<p><strong>{{.NewPrincipal}}</strong></p>{{end}}
+ <p class="rst-mono">{{.NewUse}}</p>
+</div>
+{{end}}
+
+<div rst-box>
+ <p>{{.Lead}}</p>
+</div>
+
+{{template "callout" dict "Tone" "warning" "Title" .WarningTitle "Body" .Warning}}
+
+{{if .Agents}}
+<div rst-list>
+ {{range .Agents}}
+ <div rst-row>
+ <span rst-row-main>
+ {{.Principal}}
+ <small rst-row-sub>{{.Created}}</small>
+ </span>
+ {{template "status-pill" dict "Tone" .StatusTone "Label" .StatusLabel}}
+ {{if not .Revoked}}
+ {{/* aria-label, because "Revoke" repeated down a list does not say
+ which agent it revokes — list-row-action's ActionAria rule,
+ applied to a hand-written row. */}}
+ <form method="post" action="{{.RevokeHref}}">
+ <button rst-btn="ghost" class="rst-danger" type="submit"
+ aria-label="{{.RevokeAria}}">{{.RevokeLabel}}</button>
+ </form>
+ {{end}}
+ </div>
+ {{end}}
+</div>
+{{else}}
+<div rst-box>
+ {{template "empty-state" dict "Title" .EmptyTitle "Body" .EmptyBody}}
+</div>
+{{end}}
+
+<div rst-box>
+ <form method="post" action="{{.Action}}" rst-form>
+ {{template "field-text" dict
+ "Name" "name" "Label" .NameLabel "Hint" .NameHint
+ "Value" .NameValue "Required" true
+ "Error" (index .Errors "Name")}}
+ {{template "form-foot" dict "Submit" .CreateLabel}}
+ </form>
+</div>
+{{end}}
diff --git a/internal/slidestest/agents_test.go b/internal/slidestest/agents_test.go
new file mode 100644
index 0000000..20867fe
--- /dev/null
+++ b/internal/slidestest/agents_test.go
@@ -0,0 +1,187 @@
+package slidestest
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// agentToken finds the token the agents page was redirected with. It
+// is shown once, in the query string of that one redirect, and never
+// again — the share link's bargain (share.go), for the same reason.
+var agentToken = regexp.MustCompile(`\?new=(sl_[A-Za-z0-9]{40})`)
+
+// agentRevokeAction finds an agent row's revoke form on /agents.
+var agentRevokeAction = regexp.MustCompile(`/agents/(\d+)/revoke`)
+
+// mintAgent posts the create form on /agents and returns the token it
+// was shown once.
+func mintAgent(t *testing.T, a *testApp, cookies []*http.Cookie, name string) string {
+ t.Helper()
+ rec := post(t, a, "/agents", url.Values{"name": {name}}, cookies...)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /agents %q: status %d, want 303\n%s", name, rec.Code, rec.Body)
+ }
+ loc := rec.Header().Get("Location")
+ m := agentToken.FindStringSubmatch(loc)
+ if m == nil {
+ t.Fatalf("POST /agents %q: Location = %q, carries no new token", name, loc)
+ }
+ return m[1]
+}
+
+// TestAgentTokenShownOnceAndHashed: minting shows the token once, in a
+// read-only field; coming back never shows it again; and what the
+// database holds is the fingerprint, never the token.
+func TestAgentTokenShownOnceAndHashed(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+
+ empty := mustGet(t, a, "/agents", http.StatusOK, cookies).Body.String()
+ if !strings.Contains(empty, "No agents yet") {
+ t.Errorf("a person with no agents is not told so:\n%s", empty)
+ }
+
+ token := mintAgent(t, a, cookies, "claude")
+
+ shown := mustGet(t, a, "/agents?new="+token, http.StatusOK, cookies).Body.String()
+ if !strings.Contains(shown, token) {
+ t.Errorf("the agents page does not show the new token %q:\n%s", token, shown)
+ }
+ if !strings.Contains(shown, "readonly") {
+ t.Errorf("the new token is not in a read-only field:\n%s", shown)
+ }
+ // The principal is what a person recognises the agent by in an
+ // activity list, so the page that mints it has to name it.
+ if want := "claude~" + allowedAddress; !strings.Contains(shown, want) {
+ t.Errorf("the agents page does not show the principal %q:\n%s", want, shown)
+ }
+
+ again := mustGet(t, a, "/agents", http.StatusOK, cookies).Body.String()
+ if strings.Contains(again, token) {
+ t.Errorf("coming back to /agents shows the token again:\n%s", again)
+ }
+
+ // Every column concatenated, so this is an assertion about the
+ // whole row rather than about the one column that was remembered.
+ var whole []string
+ err := a.db.G.Raw(`SELECT COALESCE(id,'') || COALESCE(name,'') || COALESCE(operator_id,'') ||
+ COALESCE(token_hash,'') || COALESCE(created_at,'') || COALESCE(revoked_at,'')
+ FROM agents`).Scan(&whole).Error
+ if err != nil {
+ t.Fatalf("read agents: %v", err)
+ }
+ if len(whole) != 1 {
+ t.Fatalf("agents holds %d rows, want 1", len(whole))
+ }
+ if strings.Contains(whole[0], token) {
+ t.Errorf("agents holds the plaintext token: %s", whole[0])
+ }
+
+ var stored struct {
+ Name string
+ TokenHash string
+ }
+ if err := a.db.G.Raw("SELECT name, token_hash FROM agents").Scan(&stored).Error; err != nil {
+ t.Fatalf("read agents: %v", err)
+ }
+ if stored.Name != "claude" {
+ t.Errorf("agents.name = %q, want %q", stored.Name, "claude")
+ }
+ sum := sha256.Sum256([]byte(token))
+ if want := hex.EncodeToString(sum[:]); stored.TokenHash != want {
+ t.Errorf("agents.token_hash = %q, want the SHA-256 hex %s", stored.TokenHash, want)
+ }
+}
+
+// TestRevokedTokenIs401: a token that worked stops working the moment
+// its agent is revoked, and the page says the agent is off rather than
+// dropping the row.
+func TestRevokedTokenIs401(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ token := mintAgent(t, a, cookies, "claude")
+
+ apiJSON(t, a, http.MethodGet, "/api/v1/decks", token, "", http.StatusOK)
+
+ page := mustGet(t, a, "/agents", http.StatusOK, cookies).Body.String()
+ m := agentRevokeAction.FindStringSubmatch(page)
+ if m == nil {
+ t.Fatalf("no revoke form on /agents:\n%s", page)
+ }
+ rec := post(t, a, "/agents/"+m[1]+"/revoke", nil, cookies...)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /agents/%s/revoke: status %d, want 303\n%s", m[1], rec.Code, rec.Body)
+ }
+
+ body := apiJSON(t, a, http.MethodGet, "/api/v1/decks", token, "", http.StatusUnauthorized)
+ if body["error"] != "unauthorized" {
+ t.Errorf("a revoked token: error = %v, want %q", body["error"], "unauthorized")
+ }
+
+ after := mustGet(t, a, "/agents", http.StatusOK, cookies).Body.String()
+ if !strings.Contains(after, "Revoked") {
+ t.Errorf("/agents does not say the agent is revoked:\n%s", after)
+ }
+}
+
+// TestAgentNameIsChecked: the name is a principal component, so it is
+// held to the shape a principal can carry, and one person cannot have
+// two live agents answering to the same name.
+func TestAgentNameIsChecked(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+
+ for _, name := range []string{"", "x", "Claude", "my agent", strings.Repeat("a", 33)} {
+ rec := post(t, a, "/agents", url.Values{"name": {name}}, cookies...)
+ if rec.Code != http.StatusUnprocessableEntity {
+ t.Errorf("POST /agents name=%q: status %d, want 422", name, rec.Code)
+ }
+ }
+
+ mintAgent(t, a, cookies, "claude")
+ rec := post(t, a, "/agents", url.Values{"name": {"claude"}}, cookies...)
+ if rec.Code != http.StatusUnprocessableEntity {
+ t.Errorf("POST /agents with a name already live: status %d, want 422\n%s", rec.Code, rec.Body)
+ }
+}
+
+// TestAgentsAreTheirOperatorsAlone: /agents lists the person's own
+// agents and nobody else's, and a revoke aimed at another person's
+// agent is a 404 rather than a revocation.
+func TestAgentsAreTheirOperatorsAlone(t *testing.T) {
+ a := newSharedApp(t)
+ mine := signedIn(t, a, allowedAddress)
+ theirs := signedIn(t, a, otherAddress)
+
+ mintAgent(t, a, mine, "claude")
+ page := mustGet(t, a, "/agents", http.StatusOK, mine).Body.String()
+ m := agentRevokeAction.FindStringSubmatch(page)
+ if m == nil {
+ t.Fatalf("no revoke form on /agents:\n%s", page)
+ }
+
+ other := mustGet(t, a, "/agents", http.StatusOK, theirs).Body.String()
+ if strings.Contains(other, "claude~") {
+ t.Errorf("another person's agents page lists this person's agent:\n%s", other)
+ }
+ rec := post(t, a, "/agents/"+m[1]+"/revoke", nil, theirs...)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("revoking another person's agent: status %d, want 404", rec.Code)
+ }
+}
+
+// TestAgentsLinkedFromTheAccountMenu: the screen exists, so the menu
+// every signed-in page carries has to reach it.
+func TestAgentsLinkedFromTheAccountMenu(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ page := mustGet(t, a, "/", http.StatusOK, cookies).Body.String()
+ if !strings.Contains(page, `href="/agents"`) {
+ t.Errorf("the account menu does not link to /agents:\n%s", page)
+ }
+}
diff --git a/internal/slidestest/api_test.go b/internal/slidestest/api_test.go
new file mode 100644
index 0000000..777ddb8
--- /dev/null
+++ b/internal/slidestest/api_test.go
@@ -0,0 +1,495 @@
+package slidestest
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// bearerRequest builds the request an agent's HTTP client makes: an
+// Authorization header and nothing else.
+//
+// What it deliberately does NOT send is the point of most of this
+// file: no cookie, no Origin, no Sec-Fetch-Site. A non-browser caller
+// has none of those to send, so the API has to be reachable without
+// them — and csrf.Protect refuses a POST carrying none of the three,
+// which is why /api/v1 is mounted outside it (api.go says so at
+// length).
+func bearerRequest(t *testing.T, method, target, token, body string) *http.Request {
+ t.Helper()
+ var r io.Reader
+ if body != "" {
+ r = strings.NewReader(body)
+ }
+ req := httptest.NewRequest(method, target, r)
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ if body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ return req
+}
+
+// send runs one built request against the whole app.
+func send(t *testing.T, a *testApp, req *http.Request) *httptest.ResponseRecorder {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ a.ServeHTTP(rec, req)
+ return rec
+}
+
+// apiJSON makes one API call, insists on the status, and decodes the
+// JSON body. Every API answer with a body is JSON, including every
+// error, so one helper covers the whole surface.
+func apiJSON(t *testing.T, a *testApp, method, target, token, body string, want int) map[string]any {
+ t.Helper()
+ rec := send(t, a, bearerRequest(t, method, target, token, body))
+ if rec.Code != want {
+ t.Fatalf("%s %s: status %d, want %d\n%s", method, target, rec.Code, want, rec.Body)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
+ t.Fatalf("%s %s: Content-Type = %q, want application/json", method, target, ct)
+ }
+ var out map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatalf("%s %s: body is not a JSON object: %v\n%s", method, target, err, rec.Body)
+ }
+ return out
+}
+
+// apiDeck creates a deck over the API and returns its sqid.
+func apiDeck(t *testing.T, a *testApp, token, title string) string {
+ t.Helper()
+ out := apiJSON(t, a, http.MethodPost, "/api/v1/decks", token,
+ `{"title":`+quote(title)+`}`, http.StatusCreated)
+ sqid, _ := out["sqid"].(string)
+ if len(sqid) != 10 {
+ t.Fatalf("POST /api/v1/decks: sqid = %v, want ten characters", out["sqid"])
+ }
+ return sqid
+}
+
+// quote is a JSON string literal, for building request bodies.
+func quote(s string) string {
+ b, _ := json.Marshal(s)
+ return string(b)
+}
+
+// TestApiNeverReadsCookie: the API is a bearer surface and only a
+// bearer surface. A request carrying a perfectly good session cookie
+// and no token is 401 — otherwise every page in a signed-in person's
+// browser would be able to drive their decks cross-site.
+func TestApiNeverReadsCookie(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+
+ req := bearerRequest(t, http.MethodGet, "/api/v1/decks", "", "")
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := send(t, a, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("GET /api/v1/decks with a session cookie: status %d, want 401\n%s", rec.Code, rec.Body)
+ }
+ var out map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatalf("the 401 body is not JSON: %v\n%s", err, rec.Body)
+ }
+ if out["error"] != "unauthorized" {
+ t.Errorf("error = %v, want %q", out["error"], "unauthorized")
+ }
+
+ // The mirror of the same rule: an HTML route never reads a bearer.
+ token := mintAgent(t, a, cookies, "claude")
+ page := send(t, a, bearerRequest(t, http.MethodGet, "/", token, ""))
+ if page.Code != http.StatusSeeOther {
+ t.Errorf("GET / with a bearer and no cookie: status %d, want a redirect to sign-in", page.Code)
+ }
+
+ // A garbage bearer is the same 401 as none at all.
+ bad := apiJSON(t, a, http.MethodGet, "/api/v1/decks", "sl_not-a-real-token", "", http.StatusUnauthorized)
+ if bad["error"] != "unauthorized" {
+ t.Errorf("a bad token: error = %v, want %q", bad["error"], "unauthorized")
+ }
+}
+
+// TestApiCreateDeckRecordsAgentActor: a deck the API makes is the
+// operator's, and every write it carries names the AGENT — never the
+// person whose token it is holding.
+func TestApiCreateDeckRecordsAgentActor(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ token := mintAgent(t, a, cookies, "claude")
+
+ sqid := apiDeck(t, a, token, "Q3 review")
+
+ var got struct {
+ Actor string
+ OwnerID int64
+ }
+ err := a.db.G.Raw(`SELECT u.actor AS actor, d.owner_id AS owner_id
+ FROM deck_updates u JOIN decks d ON d.id = u.deck_id
+ WHERE d.sqid = ? ORDER BY u.seq DESC LIMIT 1`, sqid).Scan(&got).Error
+ if err != nil {
+ t.Fatalf("read deck_updates: %v", err)
+ }
+ var agentID int64
+ if err := a.db.G.Raw("SELECT id FROM agents").Scan(&agentID).Error; err != nil {
+ t.Fatalf("read agents: %v", err)
+ }
+ if want := "agent:" + strconv.FormatInt(agentID, 10); got.Actor != want {
+ t.Errorf("deck_updates.actor = %q, want %q", got.Actor, want)
+ }
+ var operator int64
+ if err := a.db.G.Raw("SELECT id FROM users WHERE email = ?", allowedAddress).Scan(&operator).Error; err != nil {
+ t.Fatalf("read users: %v", err)
+ }
+ if got.OwnerID != operator {
+ t.Errorf("decks.owner_id = %d, want the operator %d", got.OwnerID, operator)
+ }
+
+ // The deck the agent made is the operator's to open, and the row
+ // says who last touched it: the robot mark plus the principal,
+ // never a bare id.
+ page := mustGet(t, a, "/", http.StatusOK, cookies).Body.String()
+ if !strings.Contains(page, "Q3 review") {
+ t.Fatalf("the deck the agent made is not in the operator's list:\n%s", page)
+ }
+ if want := "🤖 claude~" + allowedAddress; !strings.Contains(page, want) {
+ t.Errorf("the deck list does not badge the agent's write with %q:\n%s", want, page)
+ }
+ // And the sample deck the operator's own sign-in seeded is badged
+ // the other way — a person's address, with no robot mark. Matched
+ // with the separator in front of it so this cannot pass on the
+ // account menu, which shows the same address.
+ if want := "· by " + allowedAddress; !strings.Contains(page, want) {
+ t.Errorf("the deck list does not name the person behind a person's write with %q:\n%s", want, page)
+ }
+}
+
+// TestApiOpsMintIdsAndReturnThem: an agent may leave the ids out, and
+// the answer tells it what they were — otherwise it could never
+// address the slide it just made.
+func TestApiOpsMintIdsAndReturnThem(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ token := mintAgent(t, a, cookies, "claude")
+ sqid := apiDeck(t, a, token, "Q3 review")
+
+ before := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK)
+
+ out := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, `{"ops":[
+ {"op":"add_slide","layout":"flow","template":"section"},
+ {"op":"set_meta","title":"Q3 review"}
+ ]}`, http.StatusOK)
+
+ ids, ok := out["ids"].(map[string]any)
+ if !ok {
+ t.Fatalf("ops answered no ids map: %v", out)
+ }
+ slide, _ := ids["0"].(string)
+ if len(slide) != 12 {
+ t.Fatalf(`ids["0"] = %v, want a twelve-character slide id`, ids["0"])
+ }
+ if _, minted := ids["1"]; minted {
+ t.Errorf("set_meta needs no id, but one was minted for it: %v", ids)
+ }
+ if out["seq"].(float64) <= before["seq"].(float64) {
+ t.Errorf("seq did not move: %v then %v", before["seq"], out["seq"])
+ }
+
+ // The id it was handed is the id the document holds, so the next
+ // op an agent sends can address the slide it just made.
+ after := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK)
+ if !strings.Contains(mustJSON(t, after["slides"]), slide) {
+ t.Errorf("the minted slide id %q is not in the deck:\n%s", slide, mustJSON(t, after["slides"]))
+ }
+
+ // And a block goes on it the same way.
+ blockOut := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
+ `{"ops":[{"op":"add_block","slide":`+quote(slide)+`,"type":"heading","region":"heading","markdown":"Revenue"}]}`,
+ http.StatusOK)
+ blockIDs, _ := blockOut["ids"].(map[string]any)
+ block, _ := blockIDs["0"].(string)
+ if len(block) != 12 {
+ t.Fatalf("add_block minted no id: %v", blockOut)
+ }
+
+ // The rendered section is how an agent looks at what it made.
+ html := send(t, a, bearerRequest(t, http.MethodGet,
+ "/api/v1/decks/"+sqid+"/slides/"+slide+".html?vp=phone", token, ""))
+ if html.Code != http.StatusOK {
+ t.Fatalf("GET the slide html: status %d\n%s", html.Code, html.Body)
+ }
+ if ct := html.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
+ t.Errorf("slide html Content-Type = %q, want text/html", ct)
+ }
+ body := html.Body.String()
+ if !strings.HasPrefix(strings.TrimSpace(body), "<section") {
+ t.Errorf("the slide endpoint answered a whole page rather than the section:\n%s", body)
+ }
+ if !strings.Contains(body, "Revenue") {
+ t.Errorf("the rendered slide does not carry the block the agent added:\n%s", body)
+ }
+ if !strings.Contains(body, `data-vp="phone"`) {
+ t.Errorf("?vp=phone did not reach the renderer:\n%s", body)
+ }
+}
+
+// TestApiDuplicateSlideMintsEveryBlockId: duplicate_slide is the op
+// that needs more than one id — a new slide id and exactly one new id
+// per block being copied — and an agent should not have to count the
+// blocks itself to use it.
+func TestApiDuplicateSlideMintsEveryBlockId(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ token := mintAgent(t, a, cookies, "claude")
+ sqid := apiDeck(t, a, token, "Q3 review")
+
+ // The deck opens on one slide carrying one heading; add a second
+ // block so the copy has two ids to mint rather than one.
+ deck := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK)
+ slides, _ := deck["slides"].([]any)
+ if len(slides) != 1 {
+ t.Fatalf("a new deck has %d slides, want 1", len(slides))
+ }
+ first, _ := slides[0].(map[string]any)
+ source, _ := first["id"].(string)
+
+ apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
+ `{"ops":[{"op":"add_block","slide":`+quote(source)+`,"type":"text","region":"body","markdown":"Up 14%"}]}`,
+ http.StatusOK)
+
+ out := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
+ `{"ops":[{"op":"duplicate_slide","id":`+quote(source)+`}]}`, http.StatusOK)
+ ids, _ := out["ids"].(map[string]any)
+ copyID, _ := ids["0"].(string)
+ if len(copyID) != 12 {
+ t.Fatalf("duplicate_slide minted no new slide id: %v", out)
+ }
+
+ after := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK)
+ list, _ := after["slides"].([]any)
+ if len(list) != 2 {
+ t.Fatalf("after duplicate_slide the deck has %d slides, want 2", len(list))
+ }
+ // The copy's blocks are new ids, not the originals: two slides
+ // sharing a block id would be one block in two places.
+ seen := map[string]bool{}
+ for _, raw := range list {
+ s, _ := raw.(map[string]any)
+ blocks, _ := s["blocks"].([]any)
+ if len(blocks) != 2 {
+ t.Fatalf("slide %v has %d blocks, want 2", s["id"], len(blocks))
+ }
+ for _, b := range blocks {
+ id, _ := b.(map[string]any)["id"].(string)
+ if seen[id] {
+ t.Errorf("block id %q appears on two slides", id)
+ }
+ seen[id] = true
+ }
+ }
+}
+
+// TestApiRejectedBatchAppliesNothing: the core refuses a batch as one
+// thing, so a batch whose second op is wrong must leave the deck
+// exactly as it was — including the good op in front of it.
+func TestApiRejectedBatchAppliesNothing(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ token := mintAgent(t, a, cookies, "claude")
+ sqid := apiDeck(t, a, token, "Q3 review")
+
+ before := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK)
+
+ out := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, `{"ops":[
+ {"op":"add_slide","layout":"flow","template":"section"},
+ {"op":"set_text","slide":"nosuchslide","id":"nosuchblock","markdown":"x"}
+ ]}`, http.StatusUnprocessableEntity)
+
+ if out["error"] != "invalid" {
+ t.Errorf("a refused batch: error = %v, want %q", out["error"], "invalid")
+ }
+ if op, ok := out["op"].(float64); !ok || op != 1 {
+ t.Errorf("a refused batch: op = %v, want the index 1", out["op"])
+ }
+ if msg, _ := out["message"].(string); msg == "" {
+ t.Errorf("a refused batch carries no message: %v", out)
+ }
+
+ after := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK)
+ if after["seq"] != before["seq"] {
+ t.Errorf("a refused batch moved the seq: %v then %v", before["seq"], after["seq"])
+ }
+ if mustJSON(t, after["slides"]) != mustJSON(t, before["slides"]) {
+ t.Errorf("a refused batch changed the slides:\n%s\n%s",
+ mustJSON(t, before["slides"]), mustJSON(t, after["slides"]))
+ }
+
+ // An op the vocabulary has never heard of is the same answer.
+ unknown := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
+ `{"ops":[{"op":"delete_everything"}]}`, http.StatusUnprocessableEntity)
+ if unknown["error"] != "invalid" {
+ t.Errorf("an unknown op: error = %v, want %q", unknown["error"], "invalid")
+ }
+}
+
+// TestApiGetHonoursEtag: the seq is the deck's version, so an agent
+// that already has it can be told so instead of being handed the whole
+// document again.
+func TestApiGetHonoursEtag(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ token := mintAgent(t, a, cookies, "claude")
+ sqid := apiDeck(t, a, token, "Q3 review")
+
+ rec := send(t, a, bearerRequest(t, http.MethodGet, "/api/v1/decks/"+sqid, token, ""))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET the deck: status %d\n%s", rec.Code, rec.Body)
+ }
+ etag := rec.Header().Get("ETag")
+ if etag == "" || !strings.HasPrefix(etag, `"`) {
+ t.Fatalf("GET the deck: ETag = %q, want a quoted seq", etag)
+ }
+
+ req := bearerRequest(t, http.MethodGet, "/api/v1/decks/"+sqid, token, "")
+ req.Header.Set("If-None-Match", etag)
+ cached := send(t, a, req)
+ if cached.Code != http.StatusNotModified {
+ t.Fatalf("If-None-Match with the current ETag: status %d, want 304\n%s", cached.Code, cached.Body)
+ }
+ if cached.Body.Len() != 0 {
+ t.Errorf("a 304 carries a body: %s", cached.Body)
+ }
+
+ // One write later the same If-None-Match is stale and the whole
+ // document comes back.
+ apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
+ `{"ops":[{"op":"add_slide","layout":"flow","template":"section"}]}`, http.StatusOK)
+ stale := send(t, a, req)
+ if stale.Code != http.StatusOK {
+ t.Errorf("If-None-Match with a stale ETag: status %d, want 200", stale.Code)
+ }
+ if stale.Header().Get("ETag") == etag {
+ t.Errorf("the ETag did not move with the deck: still %q", etag)
+ }
+}
+
+// TestApiListsWhatTheOperatorCanSee: the API's visibility is the
+// operator's visibility, not a wider one — an agent is exactly as
+// powerful as the person who minted it.
+func TestApiListsWhatTheOperatorCanSee(t *testing.T) {
+ a := newSharedApp(t)
+ mine := signedIn(t, a, allowedAddress)
+ theirs := signedIn(t, a, otherAddress)
+ token := mintAgent(t, a, mine, "claude")
+
+ hidden := createDeck(t, a, theirs, "Their private deck")
+ apiDeck(t, a, token, "Q3 review")
+
+ out := apiJSON(t, a, http.MethodGet, "/api/v1/decks", token, "", http.StatusOK)
+ listed := mustJSON(t, out["decks"])
+ if !strings.Contains(listed, "Q3 review") {
+ t.Errorf("the list is missing the operator's own deck:\n%s", listed)
+ }
+ if strings.Contains(listed, "Their private deck") {
+ t.Errorf("the list carries another person's private deck:\n%s", listed)
+ }
+
+ // And addressing it directly is a 404, never a 403: a deck that is
+ // not yours does not exist (decks.go's visibleDecks).
+ sqid := strings.TrimPrefix(hidden, "/d/")
+ gone := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusNotFound)
+ if gone["error"] != "not_found" {
+ t.Errorf("a deck the operator cannot see: error = %v, want %q", gone["error"], "not_found")
+ }
+}
+
+// TestApiPresentStartsATalk: an agent can put a deck on a projector,
+// and gets back the three addresses that reach the session.
+func TestApiPresentStartsATalk(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ token := mintAgent(t, a, cookies, "claude")
+ sqid := apiDeck(t, a, token, "Q3 review")
+
+ out := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/present", token, "", http.StatusOK)
+ session, _ := out["session"].(string)
+ if len(session) != 10 {
+ t.Fatalf("present: session = %v, want a sqid", out["session"])
+ }
+ for _, key := range []string{"present_url", "notes_url", "remote_url"} {
+ got, _ := out[key].(string)
+ if !strings.HasPrefix(got, testOrigin+"/") {
+ t.Errorf("present: %s = %q, want an absolute URL on %s", key, got, testOrigin)
+ }
+ }
+ if want := testOrigin + "/p/" + session; out["present_url"] != want {
+ t.Errorf("present_url = %v, want %q", out["present_url"], want)
+ }
+
+ // The surface it named is live for somebody with no session at all
+ // — which is the whole point of putting it on a projector.
+ rec := get(t, a, "/p/"+session)
+ if rec.Code != http.StatusOK {
+ t.Errorf("the present URL an agent was handed: status %d, want 200", rec.Code)
+ }
+
+ // Pressing it again picks the same talk up rather than starting a
+ // second one.
+ twice := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/present", token, "", http.StatusOK)
+ if twice["session"] != out["session"] {
+ t.Errorf("presenting twice made a second session: %v then %v", out["session"], twice["session"])
+ }
+}
+
+// TestApiRefusesBodiesItCannotRead: the three refusals a JSON surface
+// owes a caller — the wrong media type, a body too big to be a deck
+// edit, and JSON that is not what the endpoint takes.
+func TestApiRefusesBodiesItCannotRead(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ token := mintAgent(t, a, cookies, "claude")
+ sqid := apiDeck(t, a, token, "Q3 review")
+
+ form := bearerRequest(t, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, `{"ops":[]}`)
+ form.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if rec := send(t, a, form); rec.Code != http.StatusUnsupportedMediaType {
+ t.Errorf("a form-encoded body: status %d, want 415\n%s", rec.Code, rec.Body)
+ }
+
+ huge := `{"ops":[{"op":"set_meta","title":"` + strings.Repeat("x", 1<<20) + `"}]}`
+ if rec := send(t, a, bearerRequest(t, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, huge)); rec.Code != http.StatusRequestEntityTooLarge {
+ t.Errorf("a body over a mebibyte: status %d, want 413", rec.Code)
+ }
+
+ bad := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, `{"ops":"nope"}`, http.StatusUnprocessableEntity)
+ if bad["error"] != "invalid" {
+ t.Errorf("ops that are not a list: error = %v, want %q", bad["error"], "invalid")
+ }
+ empty := apiJSON(t, a, http.MethodPost, "/api/v1/decks", token, `{"title":""}`, http.StatusUnprocessableEntity)
+ if empty["error"] != "invalid" {
+ t.Errorf("a deck with no title: error = %v, want %q", empty["error"], "invalid")
+ }
+ theme := apiJSON(t, a, http.MethodPost, "/api/v1/decks", token, `{"title":"x","theme":"neon"}`, http.StatusUnprocessableEntity)
+ if theme["error"] != "invalid" {
+ t.Errorf("a theme that is not one of the six: error = %v, want %q", theme["error"], "invalid")
+ }
+}
+
+// mustJSON re-encodes a decoded fragment so a test can compare or
+// search it as text.
+func mustJSON(t *testing.T, v any) string {
+ t.Helper()
+ b, err := json.Marshal(v)
+ if err != nil {
+ t.Fatalf("re-encode: %v", err)
+ }
+ return string(b)
+}