fix: final review — conflict 409, set_meta row sync, token off the URL, presentation cascade, copy
applyOps inserted the update row before it bumped decks.seq, and the (deck_id, seq) unique index is the same optimistic lock one statement earlier. With GORM's TranslateError on, a losing writer came out of the INSERT holding gorm.ErrDuplicatedKey and never reached the clause that would have said errDeckBusy — so editDeck's retry never ran and a lost race was a 500 where docs/api.md promises a 409. The insert maps that error to errDeckBusy; createPresentation stops matching "UNIQUE" in the driver's message for the same reason. An agent's set_meta applied to the document and left the decks row holding the old title and theme, so the deck list and the settings form never saw a rename. The batch is pre-scanned for set_meta and the columns it implies are folded into applyOps' one transaction, where saveDeckSettings already puts them — never derived from the resulting view, which only exists after the commit. The agent token rode a redirect's query string into the browser's history, the Referer of everything the landing page loads, and any log that records paths. The POST now renders the page itself with 200 and Cache-Control: no-store, and GET /agents no longer reads ?new= at all. deleteDeck cascaded the log, the grants and the share links and left the presentations, so a projector held a session whose deck was gone and the pairing code stayed live. The rows go in the same transaction, and every live session is told it ended after the commit. Copy: the empty viewer offered an editor that does not exist; PRODUCT.md had the catalog key count wrong. Also readJSON refuses trailing bytes rather than half-reading a body, share.html's Revoke buttons get an accessible name, and docs/api.md says who can see an agent session's pairing block.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
PRODUCT.md+1 −1docs/api.md+16 −5internal/slides/agents.go+22 −9internal/slides/api.go+70 −5internal/slides/decks.go+39 −0internal/slides/decks_test.go+97 −0internal/slides/locales/en.toml+5 −1internal/slides/presentation.go+6 −8internal/slides/share.go+9 −1internal/slides/templates/share.html+5 −1internal/slidestest/agents_test.go+39 −14internal/slidestest/api_test.go+92 −0internal/slidestest/present_test.go+67 −0
CI passed — run details
Intent
<task-notification> <task-id>a8f31e63316cf8932</task-id> <tool-use-id>toolu_01EbBFCrCokCGVW7nahSmSjh</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a8f31e63316cf8932.output</output-file> <status>completed</status> <summary>Agent "Implement Task 7: drive API" 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:** complete, `make ci` green (vet, gofmt, tests, migration check). **Commit:** `d8ec71e` — `feat: drive API with agent principals` (branch `worktree-slice-1`, not pushed). **Tests:** 14 new in `internal/slidestest/{agents,api}_test.go` — all 7 brief-named ones plus 7 more; all failed before implementation, all pass now. **Key findings / concerns:** - CSRF ruling confirmed: `csrf.SameOrigin` explicitly refuses a request with no `Sec-Fetch-Site`/`Origin`/`Referer`, so `/api/v1/` is mounted on the ServeMux ahead of the chi router, outside `csrf.Protect`, gated by the bearer alone. Explained in `api.go`, `app.go` and `docs/api.md`. - The `op` index in a 422 is real, not inferred: the Rust core already prefixes rejections with `op {i}:`, and the handler parses it back. - Error shape follows the task constraint (`{"error":"invalid","op":N,"message":…}`), not the brief's literal `{"error":"<core message>"}` — `error` is a fixed identifier on every status. - `actorFor` kept its `(u User)` signature; the API composes `agent:<id>` from the resolved principal instead of a request-scoped switch inside a function every screen calls. - Copy was **not** put through the `copy-review` gate (22 new catalog keys + `docs/api.md`) — it needs an interactive session. - The 409 conflict branch is untested (no concurrency seam in the harness); `register_asset` has no upload endpoint yet; no in-process rate limit on the API. **Report:** `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-07-slice-1-live/task-7-report.md`</result> <usage><subagent_tokens>225098</subagent_tokens><tool_uses>103</tool_uses><duration_ms>1124652</duration_ms></usage> </task-notification>
<task-notification> <task-id>aff4729d477c0b906</task-id> <tool-use-id>toolu_01Lj1JT4TaaZrE5k4UdHEJMR</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/aff4729d477c0b906.output</output-file> <status>completed</status> <summary>Agent "Review Task 7 (spec + quality)" 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>## Spec Compliance **Verdict: compliant.** Every named risk in the review brief was checked against the actual code and passes. - **Bearer gate failure modes** (`internal/slides/agents.go:597-645` in the diff, i.e. `bearerActor`): empty header → `bearerToken` returns `""` (`presentation.go:1240-1247`, reused not duplicated) → 401 `unauthorized`; wrong prefix → `EqualFold` fails → same 401; revoked and never-issued tokens both fall through `gorm.ErrRecordNotFound` to the identical 401 (no oracle); a constant-time compare runs after the indexed hash lookup. Verified with `TestApiNeverReadsCookie` (revoked path in `TestRevokedTokenIs401`). - **HTML reachable with a bearer?** No — `/api/v1` is mounted on the raw `http.ServeMux` (`app.go`), `bearerActor` is only `Use()`d inside `apiRouter()`. `TestApiNeverReadsCookie` proves the mirror: `GET /` with a bearer and no cookie still redirects to sign-in (303), and `GET /api/v1/decks` with a valid session cookie and no bearer is 401. - **CSRF ruling.** Verified independently by reading `/home/paulca/go/pkg/mod/amadan.net/rastrillo/rastrillo@v0.26.0/csrf/csrf.go:25-45`: `SameOrigin` falls through all three checks to a bare `return false` when `Sec-Fetch-Site`, `Origin`, and `Referer` are all absent, which is exactly what a non-browser client sends. The report's claim is accurate. - **Ops id-minting before validation.** `mintOpIDs` (`api.go`) mutates the local `ops` slice in place, but the response body on a rejected batch (`apiInvalid`, called from `apiOps`'s `default` branch) only ever carries `error`/`message`/`op` — the `ids` map computed in `applyAgentOps` is discarded on every non-nil error return, so a refused batch never leaks minted ids. Confirmed against `core/src/ops.rs:1092-1121`'s two-pass check/apply split, which really does write nothing on a rejected batch. - **`If-None-Match`.** `matchesETag` (`api.go`) handles a comma-separated list, `*`, and weak (`W/`) validators per RFC 9110, checked before `openDeck` runs (skips the wasm replay entirely on a 304). - **1 MiB limit.** `readJSON` wraps `r.Body` in `http.MaxBytesReader(w, r.Body, maxAPIBody)` and unwraps `*http.MaxBytesError` via `errors.As` to answer 413; exercised by `TestApiRefusesBodiesItCannotRead`. - **`present` reuse.** `apiPresent` calls the same `livePresentations`/`createPresentation` (`presentation.go`) that `startPresenting` uses, scoped by `actor` the same way, so repeated calls re-pair rather than duplicate — matches `TestApiPresentStartsATalk`'s "same session twice" assertion. - **Actor rendering escaping.** `lastChangeBy`/`rowSub` write the badge string (`🤖 name~email`) into a plain Go `string` field consumed by `html/template` (`render.go` imports `html/template`), so it is contextually auto-escaped; no `template.HTML` cast anywhere in the diff. - Global constraints — `Agent` model shape, token format (`sl_`+40 `[A-Za-z0-9]`, `crypto/rand` with proper modulo-bias rejection, SHA-256 hex, constant-time compare), principal `<name>~<email>`, `agent:<id>` actor on every write, endpoints/JSON shapes, error identifiers, "Last change by" resolution, `docs/api.md` coverage, catalog-keyed copy, scriptless forms, rastrillo §3-4 scoping — all checked directly against `models.go`, `agents.go`, `api.go`, `decks.go`, `en.toml`, `agents.html`, and `docs/api.md`, and all match. - Spot-checked every `docs/api.md` curl example's route and JSON shape against the handlers and against `core/src/ops.rs`'s actual `Op` field names (`markdown`, `new_id`/`new_block_ids`, etc., not the spec's looser prose) — all accurate. ## Strengths - The CSRF-mount decision is independently correct, not just asserted — I read `csrf.go` myself and it holds. - The refused-batch/id-minting interaction is subtle and handled right: ids are written into the batch before the core's two-pass check so later ops in the same batch can reference them, but never surfaced to the caller unless the whole batch actually committed. - `applyAgentOps`'s single retry re-reads the deck row and reuses the ids already baked into `ops` rather than re-minting, so a retried batch can't hand back different ids for the same logical request. - Test coverage is broad and adversarial in the right places: cookie-vs-bearer isolation tested both directions, cross-operator visibility (`TestApiListsWhatTheOperatorCanSee`, `TestAgentsAreTheirOperatorsAlone`), duplicate_slide's multi-id minting, ETag staleness, and all three body-refusal paths (415/413/422). - `actorLabels`/`lastChangeBy` are two queries regardless of row count, not N+1, and degrade gracefully (empty label) rather than leaking a raw `person:7` on a deleted actor. - `docs/api.md` is accurate against the actual handlers, not the spec's paraphrase — it reflects the real field names (`markdown`, not `text`), which is what an implementer actually needs. ## Issues #### Critical None found. #### Important - **Copy not run through the mandatory `copy-review` gate.** 22 new catalog keys and all of `docs/api.md` shipped without the review the skill requires before user-facing language is written into a file. The implementer flagged this themselves (report §Concerns 1). The prose reads well on inspection, but it hasn't been through the required gate, so it should not be treated as final. Not a defect in the code under review, but it's a real gap that should close before this ships. #### Minor - `mintOpIDs`'s per-attempt `blocks` map is recomputed from a freshly reopened view on the 409 retry, but `new_block_ids` (once already minted on attempt 0) is not regenerated — so a `duplicate_slide` chain inside one batch that depends on a later op's block count could theoretically use a stale count if a concurrent writer changed the *source* slide's block count in the retry window. Extremely narrow (needs a genuine 409 race plus a same-batch nested `duplicate_slide`), and the report already discloses the 409 path as untested end-to-end (`api.go`'s `applyAgentOps`). Worth a follow-up test once the harness has a seam for concurrent writers, not worth blocking on. - Self-disclosed, reasonable-as-is perf notes (`api.go`'s `apiListDecks` opening every deck for a slide count; `apiGetDeck` reading the view twice) mirror pre-existing costs `decks.go`'s own list screen already pays — no new problem introduced, just inherited. - `app.go`'s comment that `/api/v1` is mounted "ahead of" the chi router implies registration order matters for `http.ServeMux` routing; it doesn't — `ServeMux` picks the most specific pattern regardless of order. Harmless (the more-specific pattern always wins here), but the comment overstates the mechanism. ## Assessment **Task quality:** Approved **Reasoning:** Every named risk in the review brief was verified against the actual code (not just the report's claims) and holds up: the CSRF-mount rationale is correct per an independent read of `csrf.go`, the bearer gate fails closed and uniformly across revoked/malformed/empty/wrong-prefix cases, minted ids never leak on a refused batch, `If-None-Match` and the 1 MiB body limit are implemented correctly, `present` correctly reuses the existing presentation machinery, and actor strings are safely auto-escaped. Tests are substantive and check the properties that matter (isolation, atomicity of refused batches, visibility scoping) rather than just happy paths. `docs/api.md` was spot-checked endpoint-by-endpoint against the handlers and the core's real op vocabulary and is accurate. The only outstanding item is procedural — the mandatory copy-review gate wasn't run — which the implementer already surfaced and which doesn't affect the correctness of the code itself.</result> <usage><subagent_tokens>147569</subagent_tokens><tool_uses>41</tool_uses><duration_ms>348319</duration_ms></usage> </task-notification>
<task-notification> <task-id>a3c00ca80f09a5f5b</task-id> <tool-use-id>toolu_01BCgxbNA497iQ5TL8D3cAhG</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a3c00ca80f09a5f5b.output</output-file> <status>completed</status> <summary>Agent "Final whole-branch review" 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>I did this review myself in five passes (docs and ledger; router, auth, API and presenting; decks, share, viewer, hub and templates; catalog, tests, gate and docs; the canary and one experiment in a scratch copy under `~/.claude/tmp`, the worktree untouched). `go vet`, `gofmt`, `rastrillo migration check` and `go test ./...` are green on `d8ec71e`. ### Summary Slice 1 is a careful, well-seamed implementation: the security surfaces (allowlist oracle, hashed capabilities, missing-vs-revoked, cookie/bearer isolation, Referer redirect, CSP/Referrer-Policy on the canary) hold up under cross-cutting inspection, and the canary answers the right shapes (`/api/version` → `d8ec71e`, JSON 401 on `/api/v1`, HTML 404 on `/s/`, `/p/`, `/r/`). Two Important defects sit in the write path: the optimistic-lock retry in `applyOps` is dead code (a real conflict returns a wrapped `gorm.ErrDuplicatedKey`, not `errDeckBusy`, so the API answers 500 where `docs/api.md` promises 409, proven by a test in the scratch copy), and an agent's `set_meta` never updates the `decks` row, so the list, the API list and the settings page drift from the document. Nothing is Critical: no data is lost or exposed by either. ### Plan alignment Deviations, all recorded in the ledger and justified except where noted: - `slides.New` returns `(*http.ServeMux, *app, error)` with `Close` (ledger, Task 3). Fine. - Concern packages nested under `internal/slides/` (ruling; `migration check` needs one dir). - Sqids: plan said `sqids-go` from `ID`, min length 8; `decks.go:33-58` mints 10 random chars over Docs' alphabet. Better than the plan (unguessable, no ID leak) but **not recorded as a deviation**. Note it in the ledger. - `decks.html` has no Present/share affordances (ruling, Task 6): one entry point in the viewer. Fine. - `applyOps` signature grew a `rowUpdates` map (fix round, Task 3). Fine. - Spec §3.4 "the slide's transition" is not rendered on `/p/{session}`; `transition` is in the model only. Not mentioned anywhere; Minor. - `present.js` is 117 lines, under the plan's 120. Everything else in the file map exists where the plan put it. ### Findings #### Critical None. #### Important 1. **The write-conflict retry never fires; a real conflict is a 500, not a 409.** `internal/slides/decks.go:262-273` inserts `DeckUpdate{Seq: d.Seq+1}` *before* the `WHERE id = ? AND seq = ?` bump at `:278`. rastrillo's `db.Open` sets `TranslateError: true` (`db/db.go:97`), so under the `(deck_id, seq)` unique index the loser's insert returns `gorm.ErrDuplicatedKey`, wrapped at `:271` as `"record deck %s update %d: %w"`, and `editDeck` (`:322`) and `applyAgentOps` (`api.go:470`) see `!errors.Is(err, errDeckBusy)` and give up. Reproduced in the scratch copy: second writer got `slides: record deck … update 1: duplicated key not allowed`, `errors.Is(err, gorm.ErrDuplicatedKey) == true`, never `errDeckBusy`. Data is safe (the transaction rolls back), but two agents on one deck get a 500 with `{"error":"internal"}` and an HTML writer gets the 500 page, while `docs/api.md:65,178` promise 409. Fix: in `applyOps`, `if errors.Is(err, gorm.ErrDuplicatedKey) { return errDeckBusy }` on the insert (or do the seq bump first and insert second), and add the two-handles-one-seq test above to `decks_test.go`. 2. **An agent's `set_meta` leaves the `decks` row stale.** `api.go:396` calls `applyOps(..., nil)` with no `rowUpdates`, so `title`/`theme` in the document move but `decks.title`/`decks.theme` do not. `decks.go:456` (list), `api.go:243` (API list `title`), and `decks.go:730` (settings pre-fill) all read the row; the viewer reads `view.Meta.Title`. `models.go:64-66` says "both move together on every write" and `docs/api.md:247` documents `set_meta` as the way to set the title. Fix: after a successful batch in `applyAgentOps`, derive `rowUpdates` from the resulting view's `Meta.Title`/`Meta.Theme` (the view is already returned by `applyOps`) and fold them into the same transaction, or have `apiOps` scan the batch for `set_meta` and pass `title`/`theme` as `rowUpdates`, validating `theme` with `validTheme`. 3. **Agent tokens ride a GET URL.** `agents.go:451` redirects to `/agents?new=sl_…`, a 238-bit long-lived write credential landing in browser history, the CARLOS edge's access log, and the `Referer` of every same-origin click made from that page. The share and remote tokens are URL capabilities by spec design, so their `?new=`/`?t=` is the accepted trade; the agent token is meant for an `Authorization` header only and is worse to leak. Fix: render `agents.html` straight from the POST with a 200 (the double-submit guard in `rastrillo.js` and a `Cache-Control: no-store` cover the PRG concern), or stash the token server-side under a one-shot random nonce and redirect with the nonce. 4. **`deleteDeck` does not cascade `presentations`.** `decks.go:876-892` deletes updates, grants and share links but not `Presentation` rows. A live session on a deleted deck keeps its pairing code "taken" for 12 h (`createPresentation`, `presentation.go:340`), `liveSessionFor` still finds it, `/p/{sqid}` answers 404 from `deckOf` while `/p/{sqid}/command` still writes to the row, and the audience never hears `ended`. Fix: `tx.Where("deck_id = ?", d.ID).Delete(&Presentation{})` in the same transaction, and `a.hub.Publish(sqid, Event{Ended: true})` for any live one after commit. 5. **`slides.viewer.empty.body` still says "Add one from the editor"** (`locales/en.toml:181`). Confirmed. There is no editor; an agent or the API is the way. Fix the sentence before merge since it renders on a real state. #### Minor - `createPresentation` (`presentation.go:352`) string-matches `"UNIQUE"` via `isUniqueViolation` while `createDeckRow` (`decks.go:355`) uses `gorm.ErrDuplicatedKey`; with `TranslateError` on, the driver text may not contain `UNIQUE` at all. Use `errors.Is(err, gorm.ErrDuplicatedKey)` in both. - `share.html:56-58`: the Revoke button has no accessible name naming the link, while `agents.html:52` does (`aria-label="Revoke {principal}"`). Add `RevokeAria` with the created date. - `saveDeckSettings` writes the row through `applyOps`' `WHERE id = ? AND seq = ?` (`decks.go:278`), i.e. not owner-scoped in SQL; ownership is established by `ownedDeckFor` just before. Acceptable given team decks are meant to be writable, but note it against SKILL.md §3. - `presenterNotes` shows the pairing block only for `holder && presenter` (`presentation.go:735`); a session started via `POST /api/v1/decks/{sqid}/present` has `Actor = agent:N`, so a person opening `notes_url` never sees the QR/code. The JSON carries both, so it works, but say so in `docs/api.md:194-216`. - `/s/{token}` performs a write (`views + 1`, `share.go:319`) and a full wasm replay per request from an unauthenticated holder; `/p/{session}/stream` lets any sqid holder open unbounded SSE goroutines (5 min each). Bounded per request, unbounded in count; Docs made the same trade. Worth an edge rate limit note in AGENTS.md. - `readJSON` (`api.go:188`) does not reject trailing bytes after the JSON value; add `dec.Decode(&struct{}{}) != io.EOF` → 422. - `PRODUCT.md:151` says "121 keys today"; `en.toml` has 142. The ledger's "off by 2" is now off by 21. - `app.go:170` comment says `/api/v1` is mounted "ahead of" the chi router; `ServeMux` picks by pattern specificity, not order (ledger already parked this). - Spec §3.4 transitions (`fade`) are not rendered on the surface; either render `data-transition` and a CSS rule or record it as slice 2. - `presentation.go:1003` `heartbeat.Reset` on an undrained timer relies on Go 1.23+ semantics; `go.mod` says 1.25, so fine, but pin the reason in a comment. **Done well:** `gateBegin`/`sameAnswer` (auth.go:120-208) is the most careful allowlist-oracle defence I have seen in the family; `visibleDecks`' parenthesised OR; hashed capabilities with constant-time compares everywhere; `presentStream` subscribing before re-reading the row; the hub dropping rather than skipping; the API mounted on the `ServeMux` outside `csrf.Protect` with `bearerActor` reading only the header (verified: `/api/v1/decks` with a cookie is 401, and no HTML route accepts an agent bearer, `presentCommand`'s bearer is the remote token); `render.Slide` filtering hrefs and always emitting `alt`; the templates carry no literal English (scanned: only the "Slides" brand, a recorded ruling), bare `rst-*` grammar with only allowed utility classes, 56 px remote targets, blackout as words; the test suite hits the real router, the SSE test is bounded by a 10 s context and there is not a `time.Sleep` anywhere. ### Deferred/parked triage Fix before merge: - Task 8 minor: `slides.viewer.empty.body` mentions an editor (Important 5 above). - Task 7 minor "409 branch untested": it is untestable because it is unreachable (Important 1). The test I wrote above is the missing one. - Task 8 minor: PRODUCT.md key count (one line). Can wait (follow-up issue each): - Copy-review of every new string by Paul (Tasks 1, 5, 6, 7): not a code defect; gate on Paul. - Task 3: `decks.go` size; `theme.invalid` integration test. Task 4: `render.go` size; `meta.aspect`; indent 2-3 untested; CSS unseen in a browser (walk the canary once signed in). - Task 5/7: hand-written `readonly` field, upstream request to rastrillo `field-text`. - Task 6: Present twice mints a fresh token (documented); two simultaneous presenters pick the newest session; `goto` out-of-range is in fact tested (`present_test.go:246`); bearer callers to `/p/*/command` must send `Origin` (documented in `api.go`). - Task 7: no API rate limit; nested `duplicate_slide` on a 409 retry (moot until Important 1 lands, then re-check `mintOpIDs` is idempotent across the retry — it is, ids are written into `ops` on attempt 0). - Task 1 minors (Sec-Fetch-Site making Origin dead in the test POST helper is already covered by `postLegacy`; `extractEnvFlag` now refuses a valueless `-env`). - Task 9: DNS CNAME for `slides.oficina.works` and the `SLIDES_ORIGIN` flip; the branch discussion and `amadan ci status` exit 0 are still owed per AGENTS.md before the squash. ### Verdict: Needs fixes Blocking: (1) map the duplicate-key insert to `errDeckBusy` so the retry and the documented 409 exist, with the conflict test; (2) sync `decks.title`/`theme` on an agent `set_meta`; (3) keep the agent token out of the GET URL; (4) cascade `presentations` on deck delete; (5) reword `slides.viewer.empty.body`. All five are small, contained changes; after them, plus the branch discussion and a green `amadan ci status`, this is ready to squash.</result> <usage><subagent_tokens>267139</subagent_tokens><tool_uses>68</tool_uses><duration_ms>475017</duration_ms></usage> </task-notification>
Attributed to this commit: 29.9 g CO₂e (3764629 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/PRODUCT.md b/PRODUCT.md| index d3bade2..733cbf3 100644 |
| --- a/PRODUCT.md |
| +++ b/PRODUCT.md |
| @@ -148,7 +148,7 @@ has a named trigger in §9 rather than a date. |
| - **Only the SHA-256 of a share link is stored**, so the URL is shown |
| exactly once and the page says so at that moment (`share.go`, §5). |
| - **Every user-visible string is a catalog key** resolved through |
| - `rastrillo.T` (`internal/slides/locales/en.toml`, 121 keys today). |
| + `rastrillo.T` (`internal/slides/locales/en.toml`, 143 keys today). |
| Twelve base locales are the suite target; one, `en`, exists. A |
| hardcoded English string is a bug. Deck titles and timestamps are data, |
| not authored copy, and are correctly not keys. |
diff --git a/docs/api.md b/docs/api.md| index eea2e9d..79af1e6 100644 |
| --- a/docs/api.md |
| +++ b/docs/api.md |
| @@ -12,10 +12,11 @@ Replace it with your own. |
| 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. |
| +agent a name, and press Create. The token is shown once, on the page |
| +Create answers with, and never again: only its fingerprint is stored, |
| +so there is nothing to show you a second time, and it is in no URL to |
| +go back to. Copy it before you leave that page; 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 |
| @@ -65,7 +66,7 @@ Every answer with a body is JSON. Every error is: |
| | 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. | |
| +| 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. A body carrying anything after its JSON value is refused here too, rather than half-read. | |
| | 500 | `internal` | Our fault. The server log has a matching line. | |
| --- |
| @@ -215,6 +216,16 @@ 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. |
| +This JSON is the only place the pairing block exists for a session an |
| +agent started. Press Present in a browser and the presenter view shows |
| +the code and a QR to scan it with; that block belongs to whoever |
| +started the talk, and for your session that is the agent, not the |
| +person opening the page — so `notes_url` renders the talk and the |
| +controls, and no code and no QR. Pass on what you were given: the |
| +`code` for somebody to type into `/r/`, or `remote_url` outright. It is |
| +not recoverable afterwards, and calling present again mints a fresh |
| +token, which retires the URLs you are holding. |
| + |
| --- |
| ## The operations |
diff --git a/internal/slides/agents.go b/internal/slides/agents.go| index cccf809..fd7e489 100644 |
| --- a/internal/slides/agents.go |
| +++ b/internal/slides/agents.go |
| @@ -366,7 +366,12 @@ func (a *app) agents(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - a.renderAgentsPage(w, r, u, r.URL.Query().Get("new"), "", nil) |
| + // No token from the query string, deliberately: the one that was |
| + // just minted is written straight into the POST's own answer |
| + // (createAgent), and a GET is a URL — history, Referer, every log |
| + // that records a path — so there is nowhere here for a credential |
| + // to arrive from that it should be honoured out of. |
| + a.renderAgentsPage(w, r, u, "", "", nil) |
| } |
| func (a *app) renderAgentsPage(w http.ResponseWriter, r *http.Request, u User, newToken, name string, errs map[string]string) { |
| @@ -434,14 +439,19 @@ func (a *app) renderAgentsPage(w http.ResponseWriter, r *http.Request, u User, n |
| // --- POST /agents --- |
| -// createAgent mints one agent and its token, and lands back on the |
| -// page carrying the token so it can be shown once. |
| +// createAgent mints one agent and its token, and answers with the |
| +// agents page itself carrying the token, so it is 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. |
| +// The answer is rendered from the POST rather than redirected to, |
| +// which is the one place this app breaks POST-redirect-GET on purpose: |
| +// a redirect would have to carry the token in its URL, and a URL with |
| +// a live credential in it lands in the browser's history, in the |
| +// Referer of everything the page then loads, and in any log that |
| +// records paths. The body is the only place it can be shown that is |
| +// not one of those, so the token goes there and the response is marked |
| +// no-store. The cost is a re-POST prompt on refresh, which mints |
| +// another agent — visible, named and revocable, which a leaked token |
| +// is not. |
| func (a *app) createAgent(w http.ResponseWriter, r *http.Request) { |
| u, ok := a.viewer(w, r) |
| if !ok { |
| @@ -492,7 +502,10 @@ func (a *app) createAgent(w http.ResponseWriter, r *http.Request) { |
| } |
| a.logger.Info("slides: agent minted", |
| "agent", agent.ID, "principal", agentPrincipal(agent.Name, u.Email)) |
| - http.Redirect(w, r, "/agents?new="+token, http.StatusSeeOther) |
| + // The one response that carries a credential, so no cache — shared |
| + // or the browser's own back/forward store — may keep it. |
| + w.Header().Set("Cache-Control", "no-store") |
| + a.renderAgentsPage(w, r, u, token, "", nil) |
| } |
| // --- POST /agents/{id}/revoke --- |
diff --git a/internal/slides/api.go b/internal/slides/api.go| index 2963d0b..a563ac7 100644 |
| --- a/internal/slides/api.go |
| +++ b/internal/slides/api.go |
| @@ -37,6 +37,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "io" |
| "mime" |
| "net/http" |
| "regexp" |
| @@ -157,10 +158,18 @@ func (a *app) apiInvalid(w http.ResponseWriter, message string, op int) { |
| // --- 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. |
| +// readJSON reads a request body into dst, answering the four refusals |
| +// a JSON surface owes a caller: the wrong media type, a body too big |
| +// to be an edit, bytes that are not the shape the endpoint takes, and |
| +// a body that carries anything after its JSON value. It reports |
| +// whether the caller may carry on. |
| +// |
| +// The last of those is the one a decoder will not raise on its own. |
| +// json.Decoder reads ONE value and stops, so `{...}{...}` — an encoder |
| +// that wrote twice, two batches concatenated by a shell — would have |
| +// applied the first batch and thrown the second away silently. Anything |
| +// but whitespace after the value is a 422, and the caller sends the |
| +// batch it meant. |
| func (a *app) readJSON(w http.ResponseWriter, r *http.Request, dst any) bool { |
| ct := r.Header.Get("Content-Type") |
| if ct != "" { |
| @@ -181,6 +190,18 @@ func (a *app) readJSON(w http.ResponseWriter, r *http.Request, dst any) bool { |
| a.apiInvalid(w, "the body is not the JSON this endpoint takes: "+err.Error(), -1) |
| return false |
| } |
| + // io.EOF, and nothing else, means the value was the whole body. |
| + // Trailing whitespace decodes to EOF too, so `curl -d @file`'s |
| + // newline is not an error. |
| + if err := dec.Decode(new(json.RawMessage)); !errors.Is(err, io.EOF) { |
| + var tooBig *http.MaxBytesError |
| + if errors.As(err, &tooBig) { |
| + apiFail(w, http.StatusRequestEntityTooLarge, errTooLarge) |
| + return false |
| + } |
| + a.apiInvalid(w, "the body carries more than one JSON value", -1) |
| + return false |
| + } |
| return true |
| } |
| @@ -509,7 +530,7 @@ func (a *app) applyAgentOps(ctx context.Context, d *Deck, actor string, ops []ma |
| h.Close(ctx) |
| return nil, fmt.Errorf("slides: encode ops: %w", err) |
| } |
| - _, err = a.applyOps(ctx, h, d, actor, encoded, nil) |
| + _, err = a.applyOps(ctx, h, d, actor, encoded, metaRowUpdates(ops)) |
| h.Close(ctx) |
| if err == nil { |
| return ids, nil |
| @@ -526,6 +547,50 @@ func (a *app) applyAgentOps(ctx context.Context, d *Deck, actor string, ops []ma |
| return nil, lastErr |
| } |
| +// metaRowUpdates is the decks row's share of a batch: the title and |
| +// theme columns a list, a link and the settings form read, which live |
| +// in the row as well as in the document's meta. |
| +// |
| +// It reads the batch rather than the view the batch produces, and that |
| +// is the point: the row write has to be IN applyOps' one transaction |
| +// with the update-log insert (saveDeckSettings' own rule — the row and |
| +// the document commit or roll back together), and the view only exists |
| +// after the commit, because reading it is a round trip into the wasm |
| +// module that would hold SQLite's single writer for its whole |
| +// duration. set_meta is the only operation that touches meta, so |
| +// walking the batch answers exactly what the view would: last write in |
| +// the batch wins, a key the batch left out is a column left alone. |
| +// |
| +// The theme is folded in only when it is one of the app's six. The |
| +// core takes any string there, so an agent can put something else in |
| +// the document's meta; the row is what themes a render and what the |
| +// settings form offers, and a value that is not in that set has no |
| +// business in it. Nil when the batch changes no meta at all, which is |
| +// the vast majority of them. |
| +func metaRowUpdates(ops []map[string]json.RawMessage) map[string]any { |
| + var out map[string]any |
| + set := func(column string, value any) { |
| + if out == nil { |
| + out = map[string]any{} |
| + } |
| + out[column] = value |
| + } |
| + for _, op := range ops { |
| + if rawString(op, "op") != "set_meta" { |
| + continue |
| + } |
| + if _, has := op["title"]; has { |
| + set("title", rawString(op, "title")) |
| + } |
| + if _, has := op["theme"]; has { |
| + if theme := rawString(op, "theme"); validTheme(theme) { |
| + set("theme", theme) |
| + } |
| + } |
| + } |
| + return out |
| +} |
| + |
| // 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 |
diff --git a/internal/slides/decks.go b/internal/slides/decks.go| index c5e7baf..71dd2b7 100644 |
| --- a/internal/slides/decks.go |
| +++ b/internal/slides/decks.go |
| @@ -25,6 +25,7 @@ import ( |
| "amadan.net/oficina/slides/internal/slides/core" |
| "amadan.net/oficina/slides/internal/slides/deck" |
| + "amadan.net/oficina/slides/internal/slides/present" |
| ) |
| // --- identifiers --- |
| @@ -234,6 +235,16 @@ var errDeckBusy = errors.New("slides: the deck moved while this update was being |
| // no row matched, another writer got there first and the whole |
| // transaction rolls back with errDeckBusy. |
| // |
| +// The (deck_id, seq) unique index is the SAME lock, hit one statement |
| +// earlier — the update row goes in before the bump, so a losing writer |
| +// violates the index before it ever reaches the clause. rastrillo's |
| +// db.Open sets GORM's TranslateError, which makes that violation a |
| +// gorm.ErrDuplicatedKey, and it is translated here into errDeckBusy: |
| +// it is not a bug in the batch, it is the same lost race, and the |
| +// caller's answer to it is the same retry. Without this the loser came |
| +// out as a 500 where docs/api.md promises a 409, and editDeck's retry |
| +// never ran. |
| +// |
| // One honest wrinkle: h.Ops runs INSIDE the transaction, so a rollback |
| // leaves the handle holding a change the database does not. That is |
| // harmless while handles are per-request and discarded after a failure |
| @@ -268,6 +279,11 @@ func (a *app) applyOps(ctx context.Context, h *core.Handle, d *Deck, actor strin |
| Bytes: update, |
| CreatedAt: time.Now().UTC(), |
| }).Error; err != nil { |
| + if errors.Is(err, gorm.ErrDuplicatedKey) { |
| + // Somebody else's update is already sitting at this |
| + // seq: the lost race, reported as the lost race. |
| + return errDeckBusy |
| + } |
| return fmt.Errorf("slides: record deck %s update %d: %w", d.Sqid, next, err) |
| } |
| @@ -978,6 +994,15 @@ func (a *app) confirmDeleteDeck(w http.ResponseWriter, r *http.Request) { |
| // log and its grants go in one transaction: a deck's updates without |
| // their deck are unreachable bytes nothing will ever replay, and a |
| // grant to a deck that is gone is a permission pointing at nothing. |
| +// |
| +// The presentations go too, and they are the one part with a second |
| +// half. A session outliving its deck is a live pairing code and a |
| +// projector holding a talk that can never move again, so the rows go |
| +// in the same transaction; but every page watching one is parked on |
| +// the stream, and the stream only ever says anything because the hub |
| +// does. So the live sessions are read inside the transaction and told |
| +// after the commit — never before, because an "ended" for a delete |
| +// that then rolled back would stop a talk that is still running. |
| func (a *app) deleteDeck(w http.ResponseWriter, r *http.Request) { |
| u, ok := a.viewer(w, r) |
| if !ok { |
| @@ -988,7 +1013,16 @@ func (a *app) deleteDeck(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| + var live []Presentation |
| err := a.db.G.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { |
| + live = nil |
| + if err := tx.Where("deck_id = ? AND ended_at IS NULL", d.ID). |
| + Find(&live).Error; err != nil { |
| + return err |
| + } |
| + if err := tx.Where("deck_id = ?", d.ID).Delete(&Presentation{}).Error; err != nil { |
| + return err |
| + } |
| if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckUpdate{}).Error; err != nil { |
| return err |
| } |
| @@ -1008,5 +1042,10 @@ func (a *app) deleteDeck(w http.ResponseWriter, r *http.Request) { |
| a.fail(w, r, "delete deck", err) |
| return |
| } |
| + // Every projector, phone and follower watching one of this deck's |
| + // talks, told at once that it is over. |
| + for _, p := range live { |
| + a.hub.Publish(p.Sqid, present.Event{Ended: true}) |
| + } |
| http.Redirect(w, r, "/", http.StatusSeeOther) |
| } |
diff --git a/internal/slides/decks_test.go b/internal/slides/decks_test.go| index e6046d1..8dfc63b 100644 |
| --- a/internal/slides/decks_test.go |
| +++ b/internal/slides/decks_test.go |
| @@ -2,6 +2,7 @@ package slides |
| import ( |
| "context" |
| + "errors" |
| "testing" |
| ) |
| @@ -77,3 +78,99 @@ func TestSettingsRowAndDocumentChangeTogether(t *testing.T) { |
| t.Errorf("document meta title = %q after a failed settings write, want %q (unchanged)", view.Meta.Title, "Original title") |
| } |
| } |
| + |
| +// TestConcurrentWritersConflictThenRetry is the regression test for a |
| +// 500 where docs/api.md promises a 409. |
| +// |
| +// applyOps' optimistic lock reads as the `WHERE id = ? AND seq = ?` |
| +// bump, and that clause is genuinely a lock — but it is not what a |
| +// losing writer hits FIRST. The update row goes in before the bump, |
| +// under the (deck_id, seq) unique index, and rastrillo's db.Open sets |
| +// GORM's TranslateError, so the loser came out of the INSERT holding a |
| +// wrapped gorm.ErrDuplicatedKey and never reached the clause that |
| +// would have said errDeckBusy. editDeck's one retry and the API's 409 |
| +// both hang off errDeckBusy, so in practice neither existed. |
| +// |
| +// Two handles opened on one deck at one seq is that race exactly, with |
| +// no goroutine and no sleep: the second apply must report the deck |
| +// moved, and editDeck from the stale row must land on its retry. |
| +func TestConcurrentWritersConflictThenRetry(t *testing.T) { |
| + d := testDB(t) |
| + a, _, err := newApp(d, Config{Env: EnvDevelopment, Logger: quietLogger()}) |
| + if err != nil { |
| + t.Fatalf("newApp: %v", err) |
| + } |
| + t.Cleanup(func() { a.Close() }) |
| + ctx := context.Background() |
| + |
| + owner, _, err := ensureUser(ctx, d.G, "paul@example.test") |
| + if err != nil { |
| + t.Fatalf("ensureUser: %v", err) |
| + } |
| + row, err := a.createDeckRow(ctx, owner, "Two writers", defaultTheme) |
| + if err != nil { |
| + t.Fatalf("createDeckRow: %v", err) |
| + } |
| + opening, err := openingOps("Two writers", defaultTheme) |
| + if err != nil { |
| + t.Fatalf("openingOps: %v", err) |
| + } |
| + if err := a.editDeck(ctx, &row, actorFor(owner), opening, nil); err != nil { |
| + t.Fatalf("seed editDeck: %v", err) |
| + } |
| + |
| + // Two copies of the row at one seq: two requests that each read the |
| + // deck before either of them wrote to it. |
| + first, second := row, row |
| + |
| + h1, _, err := a.openDeck(ctx, first) |
| + if err != nil { |
| + t.Fatalf("openDeck (first): %v", err) |
| + } |
| + defer h1.Close(ctx) |
| + h2, _, err := a.openDeck(ctx, second) |
| + if err != nil { |
| + t.Fatalf("openDeck (second): %v", err) |
| + } |
| + defer h2.Close(ctx) |
| + |
| + firstOps, err := opsJSON([]op{{"op": "set_meta", "title": "First writer"}}) |
| + if err != nil { |
| + t.Fatalf("opsJSON: %v", err) |
| + } |
| + secondOps, err := opsJSON([]op{{"op": "set_meta", "title": "Second writer"}}) |
| + if err != nil { |
| + t.Fatalf("opsJSON: %v", err) |
| + } |
| + |
| + if _, err := a.applyOps(ctx, h1, &first, actorFor(owner), firstOps, nil); err != nil { |
| + t.Fatalf("the first writer failed: %v", err) |
| + } |
| + _, err = a.applyOps(ctx, h2, &second, actorFor(owner), secondOps, nil) |
| + if !errors.Is(err, errDeckBusy) { |
| + t.Fatalf("the second writer got %v, want errDeckBusy", err) |
| + } |
| + |
| + // The losing request's own next move: editDeck reopens, replays the |
| + // batch against the state that beat it, and lands. |
| + stale := second |
| + if err := a.editDeck(ctx, &stale, actorFor(owner), secondOps, nil); err != nil { |
| + t.Fatalf("editDeck did not recover from the conflict: %v", err) |
| + } |
| + |
| + var reloaded Deck |
| + if err := d.G.First(&reloaded, row.ID).Error; err != nil { |
| + t.Fatalf("reload deck row: %v", err) |
| + } |
| + if reloaded.Seq != 3 { |
| + t.Errorf("deck row seq = %d after three landed batches, want 3", reloaded.Seq) |
| + } |
| + h, view, err := a.openDeck(ctx, reloaded) |
| + if err != nil { |
| + t.Fatalf("openDeck: %v", err) |
| + } |
| + defer h.Close(ctx) |
| + if view.Meta.Title != "Second writer" { |
| + t.Errorf("document meta title = %q, want %q", view.Meta.Title, "Second writer") |
| + } |
| +} |
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml| index 71fb7d3..81ecb42 100644 |
| --- a/internal/slides/locales/en.toml |
| +++ b/internal/slides/locales/en.toml |
| @@ -178,7 +178,7 @@ slides.viewer.present = "Present" |
| # document was emptied by something other than a screen, which is |
| # exactly when a page saying "not found" would read as data loss. |
| slides.viewer.empty.title = "No slides yet" |
| -slides.viewer.empty.body = "This deck has no slides in it. Add one from the editor and it will show up here." |
| +slides.viewer.empty.body = "This deck has no slides yet. An agent or the API adds them; see docs/api.md." |
| # slides.notes.* — GET /d/{sqid}/notes, every slide's notes on one page. |
| slides.notes.title = "Speaker notes" |
| @@ -196,6 +196,10 @@ slides.share.warning.title = "What a share link does" |
| slides.share.warning = "Anyone who has the link can open this deck, including anyone it gets forwarded to. It shows the deck as it is now, so later edits are visible through it too. Revoke it and the URL stops working." |
| slides.share.create = "Create a share link" |
| slides.share.revoke = "Revoke" |
| +# "Revoke" repeated down a list does not say which link it revokes, and |
| +# a link has no name — the URL is shown once — so the date it was made |
| +# is what the accessible name has to carry. |
| +slides.share.revoke.aria = "Revoke the link created {when}" |
| slides.share.active = "Active" |
| slides.share.revoked = "Revoked" |
| slides.share.empty.title = "No share links" |
diff --git a/internal/slides/presentation.go b/internal/slides/presentation.go| index 645eccd..cff9685 100644 |
| --- a/internal/slides/presentation.go |
| +++ b/internal/slides/presentation.go |
| @@ -345,20 +345,18 @@ func (a *app) createPresentation(r *http.Request, d Deck, actor, token string) ( |
| if err == nil { |
| return p, nil |
| } |
| - if !isUniqueViolation(err) { |
| + // A typed check, not a search for "UNIQUE" in the message: |
| + // rastrillo's db.Open sets GORM's TranslateError, so a |
| + // violated index arrives as gorm.ErrDuplicatedKey whatever the |
| + // driver phrased it as — and a string match is a retry loop |
| + // resting on wording nobody in this repo controls. |
| + if !errors.Is(err, gorm.ErrDuplicatedKey) { |
| return Presentation{}, err |
| } |
| } |
| return Presentation{}, errors.New("slides: no free pairing code after several attempts") |
| } |
| -// isUniqueViolation is deliberately string-shaped: the driver's error |
| -// is not typed, and the alternative — retrying on every error — would |
| -// turn a real failure into a silent loop. |
| -func isUniqueViolation(err error) bool { |
| - return err != nil && strings.Contains(strings.ToUpper(err.Error()), "UNIQUE") |
| -} |
| - |
| // --- the pages --- |
| // presentShell is the account slot for the four capability screens. |
diff --git a/internal/slides/share.go b/internal/slides/share.go| index 7e6eaa0..5ec051a 100644 |
| --- a/internal/slides/share.go |
| +++ b/internal/slides/share.go |
| @@ -75,6 +75,7 @@ type shareRow struct { |
| StatusTone string |
| RevokeAction string |
| RevokeLabel string |
| + RevokeAria string |
| Revoked bool |
| } |
| @@ -156,12 +157,19 @@ func (a *app) renderSharePage(w http.ResponseWriter, r *http.Request, u User, d |
| page.CopyHint = rastrillo.T(r, "slides.share.new.copy") |
| } |
| for _, l := range links { |
| + when := whenWords(r, l.CreatedAt) |
| row := shareRow{ |
| - Created: rastrillo.Tf(r, "slides.share.row.created", "when", whenWords(r, l.CreatedAt)), |
| + Created: rastrillo.Tf(r, "slides.share.row.created", "when", when), |
| Views: viewWords(r, l.Views), |
| Revoked: l.RevokedAt != nil, |
| RevokeAction: fmt.Sprintf("%s/share/%d/revoke", base, l.ID), |
| RevokeLabel: rastrillo.T(r, "slides.share.revoke"), |
| + // A share link has no name — the URL is shown once and |
| + // never again — so the date it was made is the only thing |
| + // that tells one row from another. agents.html's rule |
| + // ("Revoke" repeated down a list says which nothing), |
| + // applied with the only handle this list has. |
| + RevokeAria: rastrillo.Tf(r, "slides.share.revoke.aria", "when", when), |
| } |
| if row.Revoked { |
| row.StatusLabel = rastrillo.T(r, "slides.share.revoked") |
diff --git a/internal/slides/templates/share.html b/internal/slides/templates/share.html| index c411077..f39e85a 100644 |
| --- a/internal/slides/templates/share.html |
| +++ b/internal/slides/templates/share.html |
| @@ -48,8 +48,12 @@ |
| </span> |
| {{template "status-pill" dict "Tone" .StatusTone "Label" .StatusLabel}} |
| {{if not .Revoked}} |
| + {{/* aria-label, because "Revoke" repeated down a list does not say |
| + which link it revokes, and a link's only name is when it was |
| + made — agents.html's row, with this list's own handle. */}} |
| <form method="post" action="{{.RevokeAction}}"> |
| - <button rst-btn="ghost" class="rst-danger" type="submit">{{.RevokeLabel}}</button> |
| + <button rst-btn="ghost" class="rst-danger" type="submit" |
| + aria-label="{{.RevokeAria}}">{{.RevokeLabel}}</button> |
| </form> |
| {{end}} |
| </div> |
diff --git a/internal/slidestest/agents_test.go b/internal/slidestest/agents_test.go| index 20867fe..81c8e4c 100644 |
| --- a/internal/slidestest/agents_test.go |
| +++ b/internal/slidestest/agents_test.go |
| @@ -10,10 +10,14 @@ import ( |
| "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})`) |
| +// agentToken finds the token on the page the create form answers with. |
| +// It is shown once, in that one response, and never again. |
| +// |
| +// That response is the POST's own body rather than a redirect it |
| +// carried the token into: a URL with a live credential in it lands in |
| +// the browser's history, in a Referer and in any log that records |
| +// paths, and none of those is somewhere a token should be resting. |
| +var agentToken = regexp.MustCompile(`(sl_[A-Za-z0-9]{40})`) |
| // agentRevokeAction finds an agent row's revoke form on /agents. |
| var agentRevokeAction = regexp.MustCompile(`/agents/(\d+)/revoke`) |
| @@ -23,13 +27,12 @@ var agentRevokeAction = regexp.MustCompile(`/agents/(\d+)/revoke`) |
| 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) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("POST /agents %q: status %d, want 200\n%s", name, rec.Code, rec.Body) |
| } |
| - loc := rec.Header().Get("Location") |
| - m := agentToken.FindStringSubmatch(loc) |
| + m := agentToken.FindStringSubmatch(rec.Body.String()) |
| if m == nil { |
| - t.Fatalf("POST /agents %q: Location = %q, carries no new token", name, loc) |
| + t.Fatalf("POST /agents %q: the answer carries no new token\n%s", name, rec.Body) |
| } |
| return m[1] |
| } |
| @@ -46,15 +49,30 @@ func TestAgentTokenShownOnceAndHashed(t *testing.T) { |
| 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) |
| + // The POST answers with the page itself, token and all: the one |
| + // moment it is readable. |
| + minted := post(t, a, "/agents", url.Values{"name": {"claude"}}, cookies...) |
| + if minted.Code != http.StatusOK { |
| + t.Fatalf("POST /agents: status %d, want 200\n%s", minted.Code, minted.Body) |
| + } |
| + if loc := minted.Header().Get("Location"); loc != "" { |
| + t.Errorf("POST /agents redirects to %q; a token must not ride a URL", loc) |
| } |
| + shown := minted.Body.String() |
| + m := agentToken.FindStringSubmatch(shown) |
| + if m == nil { |
| + t.Fatalf("POST /agents shows no token:\n%s", shown) |
| + } |
| + token := m[1] |
| if !strings.Contains(shown, "readonly") { |
| t.Errorf("the new token is not in a read-only field:\n%s", shown) |
| } |
| + // A page carrying a credential is not one a cache — shared, or the |
| + // browser's own back/forward store — may keep or hand to anybody |
| + // else. |
| + if cc := minted.Header().Get("Cache-Control"); cc != "no-store" { |
| + t.Errorf("the page showing a token has Cache-Control %q, want %q", cc, "no-store") |
| + } |
| // 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) { |
| @@ -65,6 +83,13 @@ func TestAgentTokenShownOnceAndHashed(t *testing.T) { |
| if strings.Contains(again, token) { |
| t.Errorf("coming back to /agents shows the token again:\n%s", again) |
| } |
| + // And there is no URL that will show it either: the query |
| + // parameter that used to carry it is not read any more, so a token |
| + // out of somebody's history cannot be replayed into the page. |
| + replayed := mustGet(t, a, "/agents?new="+token, http.StatusOK, cookies).Body.String() |
| + if strings.Contains(replayed, token) { |
| + t.Errorf("/agents?new=<token> shows the token:\n%s", replayed) |
| + } |
| // Every column concatenated, so this is an assertion about the |
| // whole row rather than about the one column that was remembered. |
diff --git a/internal/slidestest/api_test.go b/internal/slidestest/api_test.go| index 777ddb8..c0ba98a 100644 |
| --- a/internal/slidestest/api_test.go |
| +++ b/internal/slidestest/api_test.go |
| @@ -481,6 +481,98 @@ func TestApiRefusesBodiesItCannotRead(t *testing.T) { |
| if theme["error"] != "invalid" { |
| t.Errorf("a theme that is not one of the six: error = %v, want %q", theme["error"], "invalid") |
| } |
| + |
| + // Two JSON values in one body is not one request with something |
| + // extra after it, it is a caller whose encoder is wrong or whose |
| + // batch got concatenated — and reading only the first one would |
| + // silently apply half of what was sent. |
| + trailing := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, |
| + `{"ops":[{"op":"set_meta","title":"one"}]}{"ops":[{"op":"set_meta","title":"two"}]}`, |
| + http.StatusUnprocessableEntity) |
| + if trailing["error"] != "invalid" { |
| + t.Errorf("a body with bytes after the JSON value: error = %v, want %q", trailing["error"], "invalid") |
| + } |
| + // And it applied nothing: the second title is not the deck's, and |
| + // neither is the first. |
| + got := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK) |
| + if s := mustJSON(t, got["meta"]); strings.Contains(s, `"one"`) || strings.Contains(s, `"two"`) { |
| + t.Errorf("a refused body changed the document: meta = %s", s) |
| + } |
| + |
| + // A trailing newline is not trailing bytes: every `curl -d @file` |
| + // sends one. |
| + apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, |
| + `{"ops":[{"op":"set_meta","title":"fine"}]}`+"\n", http.StatusOK) |
| +} |
| + |
| +// TestApiSetMetaUpdatesTheDeckRow: a title and a theme live in two |
| +// places — the document everyone reads and the decks row a list, a |
| +// link and the settings form read — and an agent's set_meta has to |
| +// move both, exactly as the settings screen's own save does. |
| +// |
| +// It did not: apiOps applied the batch with no rowUpdates at all, so |
| +// an agent could rename a deck and the deck list would go on showing |
| +// the old name forever. |
| +func TestApiSetMetaUpdatesTheDeckRow(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + token := mintAgent(t, a, cookies, "claude") |
| + sqid := apiDeck(t, a, token, "Working title") |
| + |
| + apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, |
| + `{"ops":[{"op":"set_meta","title":"The real title","theme":"forest"}]}`, |
| + http.StatusOK) |
| + |
| + list := apiJSON(t, a, http.MethodGet, "/api/v1/decks", token, "", http.StatusOK) |
| + decks, _ := list["decks"].([]any) |
| + var found map[string]any |
| + for _, row := range decks { |
| + if m, ok := row.(map[string]any); ok && m["sqid"] == sqid { |
| + found = m |
| + } |
| + } |
| + if found == nil { |
| + t.Fatalf("GET /api/v1/decks does not list %s:\n%s", sqid, mustJSON(t, list)) |
| + } |
| + if found["title"] != "The real title" { |
| + t.Errorf("the deck list shows title %v, want %q", found["title"], "The real title") |
| + } |
| + |
| + // And the settings form pre-fills from the same row, so what a |
| + // person sees when they open it is what the agent wrote. |
| + page := mustGet(t, a, "/d/"+sqid+"/settings", http.StatusOK, cookies).Body.String() |
| + if !strings.Contains(page, `value="The real title"`) { |
| + t.Errorf("the settings form does not pre-fill the agent's title:\n%s", page) |
| + } |
| + if !strings.Contains(page, `<option value="forest" selected>`) { |
| + t.Errorf("the settings form does not pre-select the agent's theme:\n%s", page) |
| + } |
| +} |
| + |
| +// TestApiOpsConflictIs409: two writers landing at once is a 409 with |
| +// the documented body, never a 500. |
| +// |
| +// The race is staged rather than run: the decks row is put one seq |
| +// BEHIND its own update log, which is precisely the state a request |
| +// holds when somebody else's batch landed between it reading the row |
| +// and writing its own. Both of applyAgentOps' attempts re-read that |
| +// row, so both lose, which is the case docs/api.md describes — and no |
| +// goroutine and no sleep are involved, so the test cannot flake. |
| +func TestApiOpsConflictIs409(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + token := mintAgent(t, a, cookies, "claude") |
| + sqid := apiDeck(t, a, token, "Contended") |
| + |
| + if err := a.db.G.Exec("UPDATE decks SET seq = seq - 1 WHERE sqid = ?", sqid).Error; err != nil { |
| + t.Fatalf("stage the lost race: %v", err) |
| + } |
| + |
| + out := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, |
| + `{"ops":[{"op":"set_meta","title":"Mine"}]}`, http.StatusConflict) |
| + if out["error"] != "conflict" { |
| + t.Errorf("a lost race: error = %v, want %q", out["error"], "conflict") |
| + } |
| } |
| // mustJSON re-encodes a decoded fragment so a test can compare or |
diff --git a/internal/slidestest/present_test.go b/internal/slidestest/present_test.go| index fe8013d..42d961b 100644 |
| --- a/internal/slidestest/present_test.go |
| +++ b/internal/slidestest/present_test.go |
| @@ -358,6 +358,73 @@ func TestEndedSessionIs410(t *testing.T) { |
| mustGet(t, a, "/r/"+code, http.StatusNotFound, nil) |
| } |
| +// TestDeletingADeckEndsItsPresentations: deleting a deck takes its live |
| +// talks with it. |
| +// |
| +// deleteDeck cascaded the update log, the grants and the share links |
| +// and left the presentations behind, so a projector went on holding a |
| +// session whose deck no longer existed, a pairing code went on being |
| +// live, and every page watching sat there waiting for an event that |
| +// was never coming. The row goes in the same transaction as the deck, |
| +// and the hub is told the session is over, which is what a projector |
| +// and a phone act on. |
| +func TestDeletingADeckEndsItsPresentations(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + path := sampleDeck(t, a, cookies) |
| + session, token := startPresenting(t, a, path, cookies) |
| + |
| + notes := mustGet(t, a, "/p/"+session+"/notes?t="+token, http.StatusOK, cookies).Body.String() |
| + code := codeOn(t, notes) |
| + mustGet(t, a, "/p/"+session, http.StatusOK, nil) |
| + |
| + // A projector watching, so this asserts what a projector actually |
| + // hears rather than only what the database holds. |
| + srv := httptest.NewServer(a) |
| + defer srv.Close() |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/p/"+session+"/stream", nil) |
| + if err != nil { |
| + t.Fatalf("build the stream request: %v", err) |
| + } |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("open the stream: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + events := bufio.NewReader(resp.Body) |
| + if data := nextSlideEvent(t, events); !strings.Contains(data, `"ended":false`) { |
| + t.Fatalf("the connect event is %q, want a live session", data) |
| + } |
| + |
| + rec := post(t, a, path+"/delete", nil, cookies...) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST %s/delete: status %d, want 303\n%s", path, rec.Code, rec.Body) |
| + } |
| + |
| + if data := nextSlideEvent(t, events); !strings.Contains(data, `"ended":true`) { |
| + t.Errorf("the event after the deck was deleted is %q, want the session ended", data) |
| + } |
| + |
| + // Gone or never-was are both honest here; still answering is not. |
| + if rec := get(t, a, "/p/"+session); rec.Code != http.StatusNotFound && rec.Code != http.StatusGone { |
| + t.Errorf("GET /p/%s after its deck was deleted: status %d, want 404 or 410\n%s", session, rec.Code, rec.Body) |
| + } |
| + // The pairing code is a 404 rather than a gone, for the reason a |
| + // dead code always is: it tells whoever typed it nothing. |
| + mustGet(t, a, "/r/"+code, http.StatusNotFound, nil) |
| + mustGet(t, a, "/r/"+code+"?t="+token, http.StatusNotFound, nil) |
| + |
| + var left int64 |
| + if err := a.db.G.Raw("SELECT COUNT(*) FROM presentations").Scan(&left).Error; err != nil { |
| + t.Fatalf("count presentations: %v", err) |
| + } |
| + if left != 0 { |
| + t.Errorf("%d presentation rows outlived their deck, want 0", left) |
| + } |
| +} |
| + |
| // TestScriptlessRemoteButtonsAreForms: the remote is four buttons, and |
| // with scripts off each one is a form that posts and comes back. This |
| // is §8's floor for the one screen most likely to be used on a phone |