fix: codex 1, 2, 5, 6, 13, 15 — staged uploads, upload receipts, follow behind its own capability, offboarding, remote ordering and attribution
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
19 files changed,
+1692
−170
AGENTS.md+24 −9docs/api.md+118 −26docs/superpowers/specs/2026-09-07-slides-design.md+257 −8internal/slides/agents.go+18 −0internal/slides/app.go+7 −0internal/slides/assets.go+247 −35internal/slides/auth.go+142 −4internal/slides/decks.go+17 −3internal/slides/locales/en.toml+1 −0internal/slides/migrations/0008_create_asset_uploads.sql+6 −0internal/slides/migrations/schema.sql+6 −0internal/slides/models.go+60 −0internal/slides/presentation.go+102 −9internal/slides/share.go+85 −3internal/slides/static/present.js+67 −17internal/slides/templates/present.html+7 −1internal/slidestest/assets_test.go+118 −55internal/slidestest/capabilities_test.go+399 −0internal/slidestest/present_test.go+11 −0
Intent
Attributed to this commit: 10.5 g CO₂e (1379995 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on slice-3-export, 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/AGENTS.md b/AGENTS.md| index ff40176..dd70e49 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -62,7 +62,9 @@ seeded on first sign-in — and comes back out as a PDF. |
| - **Presenting** (`presentation.go`, `present/hub.go`): a server-side |
| session with a pairing code, the fullscreen surface, the presenter view |
| with notes and a clock, the phone remote, and audience follow from a |
| - share link. |
| + share link — which **stays on** `/s/{token}` routes, stream included, |
| + so revoking the link ends the follow. The session sqid is a projector |
| + address and is never rendered onto a follow page (spec §3.4, codex 5). |
| - **The editor** (`editor.go`, `templates/editor.html`, |
| `static/editor.css`, and the committed bundle built from `fixtures/`): |
| the strip of server-rendered thumbnails, the stage, the inspector for |
| @@ -75,9 +77,14 @@ seeded on first sign-in — and comes back out as a PDF. |
| served by cursor, an SSE stream of advisory seqs and whole presence |
| sets, and a snapshot every 200 updates so opening a long-lived deck is |
| not a full replay. |
| -- **Assets** (`assets.go`, `svg.go`): presigned PUT browser-to-bucket, |
| - registration that re-reads and sniffs what actually landed, SVG |
| - sanitised, and three capability-checked routes serving the bytes back. |
| +- **Assets** (`assets.go`, `svg.go`): presigned PUT browser-to-bucket |
| + into a **staging key this app mints** (never an address the client |
| + named), registration that resolves the upload receipt for it, re-reads |
| + and sniffs what actually landed, sanitises an SVG, and **promotes the |
| + bytes to a final object keyed by their own hash** — then three |
| + capability-checked routes serving them back. A ref alone registers |
| + nothing, and no PUT is ever signed for an address something is already |
| + served from (spec §2.7, codex 1 and 2). |
| - **The layout engine and thumbnails** (`layout/`, `svg/`, `fonts/`, |
| `thumbs.go`): every slide measured into a paint list with the same |
| embedded Liberation faces the browser sets it in, written as an SVG, |
| @@ -155,11 +162,19 @@ A handful of functions carry most of the app's invariants. Go through them |
| rather than around them; a new handler that reaches past one is the usual |
| way a rule stops holding. |
| -`currentUser(r)` (`auth.go`) maps `sessions.Current(r).Subject` — an email |
| -address under rastrillo/auth, never a user id — to the `users` row, making |
| -it on first sign-in. `actorFor(u)` turns that row into the `person:<id>` |
| -string every write records (§6.2); the API's bearer middleware produces |
| -`agent:<id>` the same way. |
| +`currentUser(w, r)` (`auth.go`) maps `sessions.Current(r).Subject` — an |
| +email address under rastrillo/auth, never a user id — to the `users` row, |
| +making it on first sign-in. It is also **the admission recheck**: an |
| +address no longer on `SLIDES_ALLOWED_ADDRESSES` is signed out here |
| +(`offboard` — every session row for it deleted, live talks ended, share |
| +links left alone) and told so on the sign-in page, which is why it takes |
| +a ResponseWriter and answers `sessionOffboarded` rather than a bare |
| +false. The bearer middleware does the same for an agent's operator. |
| +Without both, taking an address off the list removed nothing that address |
| +already held (spec §6.1, codex 6). `actorFor(u)` turns the row into the |
| +`person:<id>` string every write records (§6.2); the API's bearer |
| +middleware produces `agent:<id>` the same way, and a presentation remote |
| +produces `remote:<session>`, the one deliberately anonymous actor (§6.2). |
| `visibleDecks(ctx, viewer)` is the read scope: owner, team rung, or a |
| grant. Its `OR` chain is parenthesised inside the string on purpose — |
diff --git a/docs/api.md b/docs/api.md| index 0e94e8c..8ee1d73 100644 |
| --- a/docs/api.md |
| +++ b/docs/api.md |
| @@ -50,6 +50,14 @@ 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. |
| +A token also stops working when its **operator** does. If the person |
| +who minted it is taken off `SLIDES_ALLOWED_ADDRESSES`, every request |
| +this token makes is a `401` from then on — the same 401 a revoked token |
| +gets, and for the same reason: an agent sees exactly what its operator |
| +sees, and an operator who is no longer admitted here sees nothing. This |
| +is checked on every request rather than at minting, so an offboarding |
| +takes effect without anybody hunting down tokens. |
| + |
| ## Shapes |
| Requests with a body send JSON and say so: |
| @@ -67,7 +75,7 @@ Every answer with a body is JSON. Every error is: |
| | 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. | |
| +| 401 | `unauthorized` | No token, an unknown token, a revoked one, or one whose operator is no longer admitted on this instance. All four 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. | |
| @@ -310,6 +318,21 @@ 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. |
| +`present_url` is a *projector* address, though, and not an audience |
| +one. It lives as long as the talk and no revocation reaches it, so |
| +sending it to a room is sending something you cannot take back. For an |
| +audience, mint a share link and let them follow: `/s/{token}?follow=1` |
| +shows the presenter's own slide behind a capability that can be |
| +revoked, and everything that page then reads — its slide fragments, its |
| +stream at `/s/{token}/stream`, its pictures — resolves the token again |
| +on every request. Revoke the link mid-talk and it stops, immediately. |
| + |
| +Every command against a session is logged as `remote:{session}` rather |
| +than as whoever started it. The remote token is transferable by design |
| +— that is the feature — so the server cannot say who is holding it, and |
| +§6.2's "every write names its real actor" is kept by naming the |
| +capability and logging the issuer beside it. |
| + |
| 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 |
| @@ -424,12 +447,21 @@ slide give a `region`; on a canvas slide give a `box` and optionally |
| -d '{"ops":[{"op":"remove_block","slide":"'"$S"'","id":"'"$B"'"}]}' |
| ``` |
| -**`move_block`** — reorder within its slide. |
| +**`move_block`** — reorder within its slide. The view's `blocks` come |
| +back in the new order; under the hood the block keeps its identity and |
| +gets an `order` written on it, so somebody typing into it at the same |
| +moment loses nothing (spec §2.2). |
| ```sh |
| -d '{"ops":[{"op":"move_block","slide":"'"$S"'","id":"'"$B"'","after":null}]}' |
| ``` |
| +A `columns` block's `props.children` names blocks on the same slide, |
| +each at most once and each in at most one `children` list on the slide, |
| +and never a block the columns block already sits under (spec §2.4). An |
| +op that would share a child or close a cycle is refused with a message |
| +saying which id. |
| + |
| **`set_block`** — its props, region, box or z. `null` for `region` or |
| `box` clears that one. |
| @@ -481,9 +513,15 @@ which is content-addressed and shared. |
| Images never travel through this API. They go browser-to-bucket, and |
| three endpoints on the *signed-in* surface arrange it. They take a |
| session cookie rather than a bearer token, because they are what the |
| -editor's own upload uses — an agent that wants to add a picture puts the |
| -bytes in the store some other way and then names the address with |
| -`register_asset`. |
| +editor's own upload uses. |
| + |
| +**An agent cannot add a picture today.** It can name a ref in a |
| +`register_asset` op, and the document will hold it, but no surface will |
| +serve those bytes: serving requires a verification row, and a |
| +verification row is only ever written by the registration endpoint |
| +below, for an upload it signed itself. Naming an address is not a way |
| +to publish one. A bearer-authenticated upload is the obvious next |
| +thing to build and is not built. |
| Limits, on all three: 20 MiB per image, and PNG, JPEG, WebP or SVG. |
| @@ -493,41 +531,77 @@ Limits, on all three: 20 MiB per image, and PNG, JPEG, WebP or SVG. |
| {"name": "roadmap.png", "size": 184320, "type": "image/png", "hash": "9f2c1e...ab"} |
| ``` |
| -`hash` is the file's SHA-256, computed by the client. That is not a |
| -trust decision: the store is content-addressed, so the key *is* the |
| -hash, and the next endpoint re-reads what actually landed before the |
| -deck ever refers to it. |
| +`hash` is the file's SHA-256, computed by the client. Nothing is |
| +addressed by it. It is recorded, and on the development path — where |
| +this app receives the PUT and hashes the bytes anyway — a disagreement |
| +is reported as `hash_mismatch`; on a real bucket it is not consulted at |
| +all. The address the bytes go to is the app's own, always. |
| The answer is where to PUT and what header to send: |
| ```json |
| -{"ref": "9f2c1e...ab", |
| - "url": "https://bucket.example/9f2c1e...ab?X-Amz-Signature=...", |
| +{"ref": "4d0a77...e1", |
| + "url": "https://bucket.example/4d0a77...e1?X-Amz-Signature=...", |
| "headers": {"Content-Type": "image/png"}} |
| ``` |
| +**That `ref` is a staging address, not the address the picture will be |
| +served from.** It is minted here, once, for this deck and this |
| +principal, and it is the only address this app will ever sign a PUT |
| +for. Two things follow, and both are the point: |
| + |
| +- A caller cannot ask for a PUT to an address something is already |
| + served from, in this deck or any other. A presigned PUT is |
| + unconditional, so an address a browser can name is an address a |
| + browser can overwrite — which is how, before this, replacement bytes |
| + could change a registered picture with no document update anywhere. |
| +- Registration will only look at bytes staged this way. Knowing a ref |
| + is not a way to get it registered; see the next endpoint. |
| + |
| +Sign twice for the same file and you get two staging addresses. Ask for |
| +one and never use it and nothing happens: a staged upload nobody |
| +registered within two hours stops being registrable. |
| + |
| With no object store bound it answers `503` and |
| `{"error": "no_store", "message": "…"}` instead. That is the honest |
| answer, not a fallback — there is nowhere for the bytes to go. |
| In development the bound store is a directory (`blobs.Dir`), which has |
| nothing to presign, so `url` comes back as a same-origin |
| -`/d/{sqid}/uploads/{ref}` and this app receives the PUT itself. On a |
| -real bucket that path answers `404`: 20 MiB does not go through an app |
| -process that has one small instance's memory. |
| +`/d/{sqid}/uploads/{ref}` and this app receives the PUT itself. That |
| +path resolves the same receipt before it reads a byte, so it is not a |
| +way round the staging rule either. On a real bucket it answers `404`: |
| +20 MiB does not go through an app process that has one small instance's |
| +memory. |
| ### `POST /d/{sqid}/assets` — what actually landed |
| ```json |
| -{"ref": "9f2c1e...ab", "name": "roadmap.png", "width": 1600, "height": 900, |
| +{"ref": "4d0a77...e1", "name": "roadmap.png", "width": 1600, "height": 900, |
| "alt": "Three phases, each with a date", "decorative": false} |
| ``` |
| -The server reads the object back, sniffs the bytes (a file that *says* |
| -PNG and is not one is refused here, which is the only place the bytes |
| -are ever looked at), and sanitises an SVG — scripts, event handlers and |
| -foreign HTML stripped; anything still pointing outside the document |
| -refused. Alternative text is required unless `decorative` is true (§2.4). |
| +`ref` here is the **staging address** the sign endpoint answered with, |
| +and nothing else is accepted. This endpoint resolves it to the upload |
| +receipt this app wrote when it signed the PUT: same deck, same |
| +principal, not already registered, not older than two hours. Anything |
| +else — a ref from another deck, a ref you happen to know, a receipt |
| +somebody else staged, a receipt already spent — is `404` |
| +`not_found`, the same answer an address holding nothing gets. |
| + |
| +That matters more than it looks. A ref is a content address, so knowing |
| +one is knowing the SHA-256 of somebody's file, and this endpoint writes |
| +the verification row that `GET /d/{sqid}/assets/{ref}` requires. If a |
| +ref alone were enough, knowing one would be permission to read it. |
| + |
| +The server then reads the staged object, sniffs the bytes (a file that |
| +*says* PNG and is not one is refused here, which is the only place the |
| +bytes are ever looked at), sanitises an SVG — scripts, event handlers |
| +and foreign HTML stripped; anything still pointing outside the document |
| +refused — and **puts what it read back as a final object addressed by |
| +its own SHA-256**. That final address is what the deck refers to, and |
| +no browser has ever been handed a PUT for it. Alternative text is |
| +required unless `decorative` is true (§2.4). |
| It answers `200` with the record to register: |
| @@ -536,9 +610,10 @@ It answers `200` with the record to register: |
| "width": 1600, "height": 900, "alt": "Three phases, each with a date"} |
| ``` |
| -**The `ref` that comes back may not be the one that went in.** A |
| -sanitised SVG is different bytes, and different bytes have a different |
| -address in a content-addressed store. Register what came back. |
| +**The `ref` that comes back is never the one that went in.** The one |
| +that went in was a staging address; this one is the hash of the bytes |
| +the server read, which for a sanitised SVG is not even the hash of what |
| +was uploaded. Register what came back. |
| It does not write the document. The client applies `register_asset` |
| with this record itself, so an upload reaches the deck through the same |
| @@ -547,9 +622,11 @@ change. |
| Refusals are `{"error": "...", "message": "..."}` — `no_store`, |
| `too_large`, `not_an_image`, `unsafe_svg`, `alt_required`, `invalid`, |
| -`not_found`. (`unsupported_type` is the sign endpoint's word, for a |
| -declared type outside the four; here the bytes are what is judged, and |
| -a file of the wrong kind is `not_an_image`.) The `error` word is fixed |
| +`not_found`. (`unsupported_type` and `already_verified` are the sign |
| +endpoint's own and `hash_mismatch` is the development PUT's; |
| +`unsupported_type` is a *declared* type outside the four, where here |
| +the bytes are what is judged and a file of the wrong kind is |
| +`not_an_image`.) The `error` word is fixed |
| English for a program to branch on; the `message` is for a person and |
| follows `Accept-Language`. |
| @@ -689,6 +766,21 @@ view — what `GET /api/v1/decks/{sqid}` hands you — flattens all of that |
| into runs with a plain `href` string, so none of this reaches you |
| unless you are writing updates yourself. |
| +The gate on `POST /d/{sqid}/updates` checks the text you leave behind, |
| +not the view of it (spec §2.6): a formatting attribute outside those |
| +five, an element other than `<paragraph>` at the top of a text, or an |
| +element or fragment inside a paragraph is a `422`, and nothing is |
| +stored. The view would have read all of those; the editor would have |
| +deleted the text on binding, which is why the check is on the |
| +document. |
| + |
| +Block order is a key, not the array. A slide's `blocks` array is the |
| +set of its blocks; `order` on each block map (a number, array index |
| +when absent) is the reading order, and the view sorts by it (spec |
| +§2.2). Reorder by writing `order`, never by deleting and re-inserting a |
| +block map — the second loses whatever another peer typed into it in the |
| +meantime. |
| + |
| --- |
| ## A worked example |
diff --git a/docs/superpowers/specs/2026-09-07-slides-design.md b/docs/superpowers/specs/2026-09-07-slides-design.md| index 2500412..e33c4f7 100644 |
| --- a/docs/superpowers/specs/2026-09-07-slides-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-slides-design.md |
| @@ -82,6 +82,13 @@ calls. |
| | 11 | Repo `oficina/slides`, module `amadan.net/oficina/slides`, scaffolded by `rastrillo new` v0.26.0 (day theme, topbar shell). | Spanish name (`presentaciones`); the suite names generic apps in English. | Session, by the naming precedent | |
| | 12 | `href` stored as `{href}`; marks named after Y attributes | plain-string href (does not survive y-prosemirror) | Session, 2026-09-07, from the Task 2 finding | |
| | 13 | PDF via Rust svg2pdf under wazero: **viable** — `svg2pdf` 0.13 with `usvg` 0.45 (text) and `fontdb` fed the Liberation TTFs from linear memory builds for wasm32-unknown-unknown with zero host imports, a 3.5 MiB reproducible module, and renders text as subset embedded TrueType (Type0/CIDFontType2, FontFile2, ToUnicode), 200 pages in 140 ms; slice 3 proceeds with svg2pdf (`core/pdf`, `internal/slides/pdfcore`). | The Go paint-list writer (`pdfgo`), §4.3's recorded fallback: stays recorded, not built. | Session, 2026-09-08, Task 1 spike | |
| +| 14 | Uploads are **staged then promoted**: `POST /uploads` mints a staging key of the app's own and signs a PUT for that alone; registration resolves an upload receipt (this deck, this actor, unspent, under two hours), reads the bytes, and puts them back at their own SHA-256. No PUT is ever signed for an address anything is served from, and a ref alone registers nothing. | Signing a PUT for the client's declared hash and trusting the object afterwards — which made a verified picture overwritable and made knowing a ref permission to read it. Also rejected: an authorised cross-deck copy, which nothing asks for yet. | Session, 2026-09-08, from codex 1 and codex 2 | |
| +| 15 | **Presenting is every member's; minting a share link is the owner's**, and an audience follows through the **share link**, never the session sqid. The sqid is a projector address: unrevocable, so it goes only to whoever is presenting. | Making presenting owner-only (reading a deck to a room is reading a deck), or leaving followers on `/p/{session}` where the link's revocation could not reach them. | Session, 2026-09-08, from codex 5 | |
| +| 16 | **Admission is rechecked wherever a principal is resolved** — the session's subject in `currentUser`, the operator's address in the bearer gate — and fails closed. Removing an address revokes its sessions on every device and ends its live talks; its share links keep working, because a share link is the deck's and the deck is the team's. | The allowlist gating only the minting of a session, which left a removed person everything they already held, agents included. | Session, 2026-09-08, from codex 6 | |
| +| 17 | A presentation command is recorded as **`remote:<session sqid>`**, explicitly anonymous, with the session's issuer logged beside it. The remote token is transferable by design, so no name can be put on those writes honestly. | Recording the session creator, which made ruling 7 false for exactly the writes nobody could vouch for. | Session, 2026-09-08, from codex 15 | |
| +| 18 | **A slide's `blocks` array is the set of its blocks; reading order is an `order` key on each block map** (a number; array index when absent, array index as the tie-break), and every reorder — `move_block`, canvas-to-flow — writes the key rather than moving array entries. The view sorts by it and does not expose it, so every reader sees one sequence. | Delete-and-reinsert in the array (loses a peer's concurrent edit to the moved block, reproduced by the review). A separate `blockOrder` id list on the slide (a second place the block set lives, which can drift from the array). | Session, 2026-09-08 (codex 4) | |
| +| 19 | **The write gate checks the document, not its projection.** A posted update is refused (422) if any text fragment holds anything outside §2.6 — a formatting attribute other than `b`, `i`, `c`, `h`, `href`, a non-`paragraph` child, an element or fragment inside a paragraph, an embed — because y-prosemirror 1.3.7 deletes an `XmlText` whose attributes it cannot map to a mark, and the tolerant view reads right through exactly that. The check is a core ABI call (`slides_core_check`, ABI 2) mirrored in `fixtures/check.mjs`; the browser also strips unknown attributes from a fragment before binding, for documents that already carry one. | Parsing the JSON view alone (what slice 2 did; passes the update that loses the words). | Session, 2026-09-08 (codex 3) | |
| +| 20 | **The children of a slide's `columns` blocks form a forest**: a block appears in at most one `children` list on its slide, at most once, and a columns block is never placed under its own descendant. Both implementations refuse the op with the same message. A document that nonetheless holds a cycle (two peers' concurrent assignments merged) renders and exports with the unreached blocks appended at the end, marked, never dropped. | Rejecting only self-reference (what slice 2 did; a two-block cycle vanished from the page in silence). | Session, 2026-09-08 (codex 7) | |
| --- |
| @@ -107,8 +114,16 @@ A slide Y.Map holds: |
| - `layout` — `flow` or `canvas`. |
| - `template` — for flow slides: `title`, `title-body`, `two-column`, |
| `section`, `image-full`, `quote`, `blank`. Ignored on canvas slides. |
| -- `blocks` — Y.Array of block Y.Maps in reading order (flow) or creation |
| - order with a `z` (canvas). |
| +- `blocks` — Y.Array of block Y.Maps: the SET of the slide's blocks. |
| + Reading order is the `order` key on each block map, a number; a block |
| + without one ranks at its array index, and array index breaks ties |
| + (ruling 18, codex 4). A slide nobody has reordered carries no `order` |
| + and reads in array order, as before. `move_block` and canvas-to-flow |
| + write a dense `0..n-1` onto every block; `add_block` on a slide that |
| + carries orders renumbers so the new block lands where `after` asked. |
| + The JSON view sorts by it and does not expose it. On a canvas slide |
| + `z` is stacking, `order` is reading order, and the two are different |
| + things. |
| - `notes` — Y.XmlFragment, speaker notes, the same text schema as §2.6. |
| - `background` — `{kind: theme | color | image, value}`. |
| - `phone` — `inherit` (default) or `reflow`. Only meaningful on canvas |
| @@ -132,8 +147,20 @@ which case the renderer emits the blocks as a flow slide with the |
| Switching a slide's mode is an operation, not a conversion the person |
| does by hand: flow to canvas assigns each block the box its region gave |
| -it; canvas to flow drops boxes and assigns regions by position. Both are |
| -lossy in one direction and say so in the inspector. |
| +it (`z` is the reading-order index); canvas to flow drops boxes, |
| +assigns regions by position, and sets the reading order top to bottom |
| +then left to right. Both are lossy in one direction and say so in the |
| +inspector. |
| + |
| +The reorder in canvas-to-flow is written as `order` on each block map |
| +(§2.2) and never by rebuilding the `blocks` array (codex 4). Rebuilding |
| +it — clone every block, clear the array, insert the copies — is what |
| +slice 2 did, and a peer typing into a block at that moment lost the |
| +sentence: their edit addressed the deleted original and the merge kept |
| +the copy. Placement is allowed to be lossy; content is not. The same |
| +rule holds for `move_block`. (`move_slide` still clones and re-inserts |
| +the slide map, and carries the same hazard for edits concurrent with a |
| +slide move; it is noted here rather than fixed in this pass.) |
| ### 2.4 Blocks |
| @@ -156,7 +183,24 @@ vocabulary, the "maximally viable" set: |
| | `spacer` | `size` | no | |
| Every block additionally carries `region` (flow) or `box` and `z` |
| -(canvas), and an optional `phone` override map (§2.5). |
| +(canvas), an optional `order` (§2.2), and an optional `phone` override |
| +map (§2.5). |
| + |
| +The `children` of a slide's `columns` blocks form a forest (ruling 16, |
| +codex 7): a block id appears in at most one `children` list on its |
| +slide, at most once in that list, and a columns block may not name a |
| +block that it already sits under. `add_block` and `set_block` refuse |
| +any of those, in both implementations with one message per case |
| +(`names "x" twice`, `names "x", already a child of columns "y"`, `names |
| +"x", which would put columns "self" inside itself`), and the batch |
| +applies nothing. A concurrent pair of assignments can still merge into a |
| +shared child or a cycle, so the renderer and the layout engine apply |
| +one deterministic policy: the first columns block in reading order that |
| +names a child owns it, and whatever no root reaches is rendered after |
| +everything else — in its own trailing region on a flow slide, at its box |
| +on a canvas one — marked `data-unreached` in HTML and reported as a |
| +`unreached` warning by the layout engine. Content is never dropped for |
| +being badly parented. |
| Charts from Sheets, embeds and video are **not in v1** (§9). |
| @@ -171,6 +215,19 @@ The editor's desktop/phone switcher (§3.2) writes to this map when the |
| stage is in phone view. The renderer reads it when the viewport is a |
| phone, and the print engine never reads it. |
| +`order` is reading order, not a visual effect (codex 10). Rendering for |
| +the phone viewport EMITS the blocks in the effective phone sequence — |
| +regions in region order; within a region and within a columns block, a |
| +stable sort by `order` with an unset order counting as 0 — so the |
| +sequence a screen reader hears and the Tab key follows is the one a |
| +sighted reader sees (§8, WCAG 2.2 AA, no exemptions). The stylesheet's |
| +`order: var(--slides-phone-order, 0)` is kept with the same key for the |
| +one case it still serves — a desktop rendering opened on a narrow |
| +screen — and is no longer what makes the order true. A reflowed canvas |
| +slide takes §2.3's z-then-top-left sequence first and the phone `order` |
| +on top of it. Rendering for the desktop viewport keeps the desktop |
| +document order. |
| + |
| ### 2.6 Text |
| Text is a ProseMirror document under a constrained schema: `paragraph` |
| @@ -200,6 +257,24 @@ read as set on any truthy value (y-prosemirror writes `{}` for an |
| attr-less mark); `href` is written as the map and read as either the map |
| or a plain string (documents written before this ruling). |
| +**The accepted shape is narrower than what the reader tolerates** |
| +(ruling 19, codex 3). The reader's tolerance is for documents that |
| +already exist; it is not the gate on a write, because the editor does |
| +not consume the reader's projection — it binds y-prosemirror to the |
| +shared text, and y-prosemirror 1.3.7 deletes an `XmlText` whose |
| +attributes name a mark the schema lacks (`schema.mark` throws a |
| +`RangeError`; `createTextNodesFromYText`'s catch deletes the item). A |
| +posted update is therefore accepted only if every text fragment in the |
| +document it produces — each slide's `notes`, each block's `text`, each |
| +table's `cells` — is `<paragraph>` elements holding only text whose |
| +formatting attributes are among `b`, `i`, `c`, `h`, `href`, with no |
| +embeds. Anything else is refused before it is stored (§3.3). The |
| +browser, for its part, strips an unknown attribute from a fragment |
| +before binding, so a document that already carries one loses the mark |
| +and not the words — that is the smallest change the library leaves |
| +room for, since it offers no hook between reading the delta and |
| +constructing the mark. |
| + |
| ### 2.7 Assets |
| An image is uploaded browser-to-bucket by presigned PUT (rastrillo |
| @@ -210,6 +285,54 @@ before registering. Limits: 20 MiB per image, PNG, JPEG, WebP, SVG |
| asset leaves the asset; a deck-level "unused assets" list lets a person |
| remove them. There is no cross-deck asset library in v1. |
| +**Stage, then promote (codex 1, codex 2).** The browser never names the |
| +address its bytes will live at, and a ref is never on its own enough to |
| +get anything registered. Ruling 14; the shape: |
| + |
| +- `POST /d/{sqid}/uploads` mints a **staging key** — a hash of the deck, |
| + the actor and fresh randomness — writes an upload **receipt** |
| + (`asset_uploads`: deck, actor, staging key, object key, declared |
| + hash, consumed_at) and signs a PUT for that key and nothing else. It |
| + refuses to sign for an address any `deck_assets` row verifies, which |
| + a fresh random key can never be: the check is there so the mistake |
| + cannot come back. |
| +- `POST /d/{sqid}/assets` resolves the receipt — same deck, same actor, |
| + unconsumed, under two hours — reads the staged bytes, sniffs them, |
| + sanitises an SVG, and **puts what it read back** as a final object |
| + addressed by its own SHA-256. That address is what the deck refers |
| + to. The receipt is then spent, single-use, and the staged copy is |
| + deleted unless it is already the promoted object. |
| +- Nothing anywhere signs a PUT for a final address. A verified object |
| + is immutable by construction, not by nobody having tried. |
| + |
| +Why, in one line each. A presigned PUT is unconditional |
| +(`rastrillo/blobs`' own note says the server must verify what landed), |
| +so an address a browser can name is an address a browser can overwrite — |
| +naming a ref another deck was already serving from changed that |
| +picture, in that deck, with no document update anywhere. And |
| +registration wrote the destination deck's verification row for any ref |
| +it was handed, with no receipt and no access to the source deck — so |
| +knowing a content address, which is knowing the SHA-256 of somebody's |
| +file, was permission to read it. Both were reachable by any member. |
| + |
| +The cost, accepted: registration now reads **and** re-writes up to |
| +20 MiB per picture instead of only reading it. The download was already |
| +happening; the upload is the price of a final address derived from |
| +bytes this app has actually seen. |
| + |
| +**An agent cannot add a picture.** It can name a ref in a |
| +`register_asset` op and the document will hold it, but no surface will |
| +serve those bytes, because serving requires a verification row and a |
| +verification row is only written for an upload this app signed. A |
| +bearer-authenticated upload is the obvious next thing and is not built |
| +(§9). |
| + |
| +The **copy** path codex 2 offers as an alternative — registering a ref |
| +from a deck the person may read, scoped through `visibleDecks` — is |
| +deliberately not built. Nothing asks for it, and a cross-deck asset |
| +library is already out of v1 by the paragraph above; when it lands it |
| +goes through `visibleDecks` and takes a receipt of its own. |
| + |
| ### 2.8 Fixtures are the contract |
| The JS side generates Y.Doc updates for a fixed set of decks |
| @@ -274,6 +397,15 @@ second implementation, which is the estilo rule's trigger: whatever |
| Docs' hub and stream code turns out to need in both places is extracted |
| that week, and the Slides spec records the extraction when it happens. |
| +The gate on `POST /d/{sqid}/updates` applies the bytes to a fresh |
| +handle and, before the transaction opens, checks the document they |
| +leave (codex 3): first the core's accepted-shape check over every text |
| +fragment (§2.6; `slides_core_check`, ABI 2), then the typed parse of |
| +the JSON view for shapes the reader cannot hold at all. Either finding |
| +is a 422 and nothing is stored. The check is on the document rather |
| +than on the view because the view is a tolerant projection, and what it |
| +tolerates is precisely what the editor cannot. |
| + |
| Presence rides the same stream as a second event type, `presence`, |
| carrying `{client, name, colour, slide, block}`; the server holds |
| presence in memory only (it is not document state) and drops a client |
| @@ -322,6 +454,48 @@ for that deck shows "follow the presenter". Following subscribes the |
| viewer to the session stream read-only; the viewer can detach and |
| browse, and reattach. |
| +**An audience stays behind the capability it arrived with (codex 5).** |
| +Following is `/s/{token}?follow=1`, and every read that page then makes |
| +is a `/s/{token}` route: the slide fragments |
| +(`?follow=1&slide=N&fragment=1`), the stream (`/s/{token}/stream`), the |
| +thumbnails and the pictures. Each resolves the token again, and the |
| +stream re-resolves it before every event and every heartbeat, so |
| +revoking the link ends the follow immediately rather than at the end of |
| +the talk. **The session sqid is never rendered onto a follow page**, and |
| +the two URLs the script uses come down as `data-stream` and |
| +`data-fragment` attributes rather than being assembled in the browser |
| +out of a sqid it does not have. Before this, following handed over the |
| +sqid and every later read went to `/p/…`, which asks only for a live |
| +presentation — a capability the person who granted the link could not |
| +take back. |
| + |
| +**Who may publish which capability**, settled (codex 5 asked; ruling |
| +15). Presenting stays open to **every member who can read the deck**, |
| +not the owner alone: reading a deck to a room is reading a deck, and |
| +`POST /d/{sqid}/present` is `deckFor`'s audience like the editor and |
| +the export. Minting a **share link** stays the owner's, because a share |
| +link grants read to people with no account here and outlives any talk. |
| +The two are different powers and the mismatch was only apparent: the |
| +session sqid is a **projector address** — it lives as long as the talk, |
| +carries no notes and cannot be revoked — and it is now given only to |
| +whoever is presenting, never to an audience. An audience gets a share |
| +link, which is revocable, which is why audience-follow derives from the |
| +link and not from the session. |
| + |
| +**A projector page is not a nothing-happens page.** `/p/{session}` |
| +still answers to anybody holding the sqid, deliberately: that is what |
| +makes "put this on the screen" a URL. What changed is that nobody is |
| +handed one who was not meant to have it. |
| + |
| +**Out-of-order fragments (codex 13).** The script tracks *requested* |
| +and *displayed* separately and stamps every fetch with a monotonic id: |
| +a response whose id is not the newest is discarded, displayed only |
| +moves after an install succeeds, and a failed fetch rolls requested |
| +back to displayed. One state — "the slide the page has announced" — |
| +left a projector permanently behind: with the record already at 3, a |
| +late response for 2 overwrote the stage and the event that would have |
| +corrected it was suppressed as a slide the page was already on. |
| + |
| --- |
| ## 4. Rendering and export |
| @@ -433,7 +607,18 @@ outside the session group, only the token's SHA-256 stored, the URL |
| shown once at creation, revocable from the deck's share page, with a |
| view count. The link opens the viewer (§8) with the deck as it is now, |
| not a snapshot. If a presentation session is live, the link offers |
| -follow (§3.4). |
| +follow (§3.4) — and following stays **on** `/s/{token}` routes, so |
| +revocation reaches it (codex 5). |
| + |
| +**Revocation is the property, so nothing may outlive it.** Every read a |
| +link holder makes — page, slide fragment, thumbnail, picture, |
| +presentation stream — resolves the token on that request. A capability |
| +handed out *through* a link that the link's own revocation cannot |
| +reach is a bug, not a feature; codex 5 found the one there was. |
| + |
| +A share link is the **deck's** capability and not the person's who |
| +minted it. It survives that person being offboarded (§6.1), and it is |
| +turned off from the deck's share page by somebody who still has access. |
| The **present link** is the same capability with `?present=1`, opening |
| the fullscreen viewer directly. |
| @@ -459,6 +644,44 @@ The person row is `users(id, email, name, colour)` created on first |
| sign-in. `sessions.Current(r).Subject` is the email, mapped to the row |
| before any scoping, per the rastrillo §5 warning. |
| +**Offboarding (codex 6, ruling 16).** The allowlist is the *only* |
| +membership control a lone instance has, so it has to work in both |
| +directions: taking an address off `SLIDES_ALLOWED_ADDRESSES` must |
| +remove the access that address already had, not merely stop it getting |
| +more. It gated the minting of a session and nothing else — an existing |
| +session resolved its user row without asking, rastrillo's |
| +`RequireSession` asks nothing, and an agent needed only an unrevoked |
| +token and an operator row — so a removed person kept everything until |
| +their session expired, agents included. |
| + |
| +So admission is **rechecked wherever a principal is resolved**, on |
| +every request, and fails closed: |
| + |
| +- `currentUser` (auth.go) checks the session's subject. Not admitted |
| + means: every session row for that address is deleted (real |
| + revocation, every device, not just this browser's cookie), the cookie |
| + is cleared, and the person is sent to `/signin?err=removed` with a |
| + sentence saying their access was removed rather than that sign-in |
| + failed. A non-page request gets a 403 instead, which is rauth's own |
| + split. |
| +- The bearer middleware (agents.go) checks the **operator's** address. |
| + Not admitted means 401 — the same 401 a revoked token gets. An agent |
| + sees exactly what its operator sees, and this is the one path out of |
| + the instance that survives a browser being signed out. |
| + |
| +The outstanding capabilities, decided: |
| + |
| +| Capability | On offboarding | Why | |
| +|---|---|---| |
| +| Sessions (every device) | **revoked** | It is the person's own credential and nothing else depends on it. | |
| +| Agent tokens | **refused, not revoked** | The row stays, so re-admitting the address restores the agent rather than making somebody mint a new token; the recheck is what makes the refusal current. | |
| +| Live presentations they started | **ended** | A live session is a public URL that reads the deck out. Every projector, phone and follower gets the `ended` event at once. | |
| +| Share links on their decks | **kept working** | A share link is the *deck's* capability (§5), the deck is the team's, and revoking the team's links because one member left is an outage the departure did not call for. Somebody with access turns them off from the share page. | |
| +| Decks they own | **kept** | Ownership is not access. A deck with an owner who cannot sign in is still readable by the team rung and by grants; transferring ownership needs a screen and has none (§9). | |
| + |
| +Re-adding the address restores everything except the sessions and the |
| +talks, which are gone: signing in again is one email. |
| + |
| ### 6.2 Actors |
| Every write records an **actor**: `person:<user id>` or |
| @@ -466,6 +689,24 @@ Every write records an **actor**: `person:<user id>` or |
| tokens and the deck's activity list all carry it. The Y.Doc update log |
| carries it too (a column on the updates table, not in the CRDT). |
| +**One explicitly anonymous actor: `remote:<session sqid>`** (codex 15, |
| +ruling 17). A presentation command (`POST /p/{session}/command`) is |
| +authenticated by the remote token, which is a **transferable |
| +capability** — that is the feature: it goes on a phone, the phone is |
| +shown as a QR to a room, the phone may be somebody else's. The server |
| +therefore cannot establish who is holding it, and recording the |
| +session's creator on those writes was a claim about identity the app |
| +could not support. So a remote command is recorded as |
| +`remote:<session sqid>`, which is honest, with the **issuer** — the |
| +session's own actor, the person or agent who started the talk — logged |
| +beside it, so the trail still reaches somebody answerable. |
| + |
| +This is the exception and it is named as one: ruling 7's "every write |
| +names its real actor" holds, and this is what the real actor of a |
| +transferable capability is. Nothing else in the app writes anonymously, |
| +and a remote command touches only the presentation row — never the |
| +document, whose log has no anonymous entries at all. |
| + |
| ### 6.3 Deploy |
| One static binary, `CGO_ENABLED=0`, `linux-arm64`, `make release` with |
| @@ -490,6 +731,11 @@ following amadan's own convention. A revoked token fails closed. Tokens |
| are `Authorization: Bearer` on the API only; the API never reads a |
| session cookie and the HTML routes never read a bearer. |
| +A token also fails closed on its **operator's** admission, rechecked on |
| +every request (§6.1, codex 6): an operator taken off the allowlist |
| +means 401, indistinguishable from a revoked token. An agent sees |
| +exactly what its operator sees, and that includes seeing nothing. |
| + |
| ### 7.2 Operations |
| `POST /api/v1/decks/{sqid}/ops` takes a JSON array of operations and |
| @@ -602,7 +848,10 @@ most expensive part is built on it. |
| ## 12. Review record |
| -Revision 1, with §2.6 and ruling 12 rewritten during slice 2 and §4.2 |
| -and §4.3 amended on 2026-09-08 from what slice 3 shipped: unreviewed. |
| +Revision 1, with §2.6 and ruling 12 rewritten during slice 2, §4.2 |
| +and §4.3 amended on 2026-09-08 from what slice 3 shipped, and §2.2, |
| +§2.3, §2.4, §2.5, §2.6 and §3.3 amended with rulings 18 to 20 the same |
| +day from the Codex review's findings 3, 4, 7 and 10 (each paragraph |
| +marked `codex N`): unreviewed. |
| The review pass is the first item after slice 1 |
| ships, so that the reviewer has a running instance and not a document. |
diff --git a/internal/slides/agents.go b/internal/slides/agents.go| index fd7e489..6ecd10d 100644 |
| --- a/internal/slides/agents.go |
| +++ b/internal/slides/agents.go |
| @@ -212,6 +212,24 @@ func (a *app) bearerActor(next http.Handler) http.Handler { |
| apiFail(w, http.StatusUnauthorized, errUnauthorized) |
| return |
| } |
| + // And an agent whose operator is no longer admitted here is an |
| + // agent with nobody answerable for it either — spec §6.1's |
| + // offboarding rule (codex 6). An agent sees exactly what its |
| + // operator sees; when the operator sees nothing, so does it. |
| + // |
| + // This is the recheck that makes taking an address off |
| + // SLIDES_ALLOWED_ADDRESSES mean something. Without it, a token |
| + // nobody revoked plus an operator row nobody deleted was |
| + // enough to keep writing team decks indefinitely, which is the |
| + // one path out of this instance that survives a browser being |
| + // signed out. Fail closed, and the same 401 a revoked token |
| + // gets: an agent learns nothing about why. |
| + if !a.allowed.allows(operator.Email) { |
| + a.logger.Warn("slides: an agent's operator is no longer admitted; refusing the token", |
| + "agent", agent.ID, "operator", operator.ID) |
| + apiFail(w, http.StatusUnauthorized, errUnauthorized) |
| + return |
| + } |
| ctx := context.WithValue(r.Context(), principalKey{}, principal{agent: agent, operator: operator}) |
| next.ServeHTTP(w, r.WithContext(ctx)) |
diff --git a/internal/slides/app.go b/internal/slides/app.go| index af9aafa..92d0dd1 100644 |
| --- a/internal/slides/app.go |
| +++ b/internal/slides/app.go |
| @@ -77,6 +77,13 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| r.Get("/s/{token}/assets/{ref}", a.sharedAsset) |
| // And the thumbnails, for the same reason (thumbs.go). |
| r.Get("/s/{token}/slides/{file}", a.sharedSlideThumb) |
| + // And the presentation stream, when the link's deck is being |
| + // presented and its holder chose to follow along (share.go's |
| + // sharedStream, spec §3.4). Here rather than on /p/{session} for |
| + // the reason presentation.go's own comment gives: an audience |
| + // stays behind the capability it arrived with, so revoking the |
| + // link ends the follow. |
| + r.Get("/s/{token}/stream", a.sharedStream) |
| // Presenting (presentation.go, spec §3.4). Outside the group for |
| // the share link's reason and then some: the projector has no |
diff --git a/internal/slides/assets.go b/internal/slides/assets.go| index 9723a5f..707a62d 100644 |
| --- a/internal/slides/assets.go |
| +++ b/internal/slides/assets.go |
| @@ -10,6 +10,18 @@ |
| // POST /d/{sqid}/assets verify what landed, hand back the record |
| // GET /d/{sqid}/assets/{ref} the bytes, by the deck's own capability |
| // |
| +// STAGE, THEN PROMOTE (codex 1, codex 2). The browser never names the |
| +// address its bytes will live at. The first endpoint mints a staging |
| +// key of this app's own — models.go's AssetUpload says how it is built |
| +// and why — writes the receipt for it, and signs a PUT for that key and |
| +// nothing else. The second endpoint will only look at bytes a receipt |
| +// says THIS principal staged for THIS deck; it reads them, hashes what |
| +// it actually read, and puts them back as a final object keyed by that |
| +// hash. So no browser is ever handed a PUT for an address anything is |
| +// already served from, and knowing a ref is never a way to get one |
| +// registered. Both of those were true the other way round until the |
| +// codex review, and both were reachable by any member of the team. |
| +// |
| // The middle one is the shape worth reading twice. It does NOT write |
| // the document. It answers with a validated record and the browser then |
| // applies `register_asset` with it, so an asset arrives in the deck |
| @@ -38,8 +50,12 @@ package slides |
| import ( |
| "bytes" |
| "context" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| "encoding/json" |
| "errors" |
| + "fmt" |
| "io" |
| "net/http" |
| "regexp" |
| @@ -49,6 +65,7 @@ import ( |
| "unicode/utf8" |
| "github.com/go-chi/chi/v5" |
| + "gorm.io/gorm" |
| "gorm.io/gorm/clause" |
| "amadan.net/rastrillo/rastrillo" |
| @@ -130,16 +147,73 @@ type presigner interface { |
| // because a 20 MiB file on a bad connection is not a five-minute job. |
| const presignTTL = time.Hour |
| +// --- the staging key --- |
| + |
| +// stagingLife is how long a receipt is registrable for. Twice the |
| +// presigned PUT's own hour, because the PUT may be minted at the start |
| +// of that hour and land at the end of it, and registration happens |
| +// after the bytes are up. Long enough that a 20 MiB file on hotel wifi |
| +// still registers; short enough that a staged object nobody registered |
| +// stops being a thing this app will read. |
| +const stagingLife = 2 * presignTTL |
| + |
| +// newStagingKey mints the address one upload may be written to. |
| +// |
| +// It is a SHA-256 because that is the only shape rastrillo/blobs will |
| +// take as a key (blobs.checkHash), and it is a hash of the deck, the |
| +// actor and 32 fresh random bytes so the address is literally a |
| +// function of the principal and the deck it was signed for — codex 1's |
| +// "never a caller-chosen final address". The randomness is what makes |
| +// it unguessable; the domain prefix is what keeps it from ever |
| +// colliding with a content address, which is a hash of bytes and not of |
| +// this string. |
| +func newStagingKey(deckID int64, actor string) (string, error) { |
| + nonce := make([]byte, 32) |
| + if _, err := rand.Read(nonce); err != nil { |
| + return "", fmt.Errorf("slides: generate a staging key: %w", err) |
| + } |
| + sum := sha256.Sum256([]byte(fmt.Sprintf("slides/asset-staging\x00%d\x00%s\x00%x", deckID, actor, nonce))) |
| + return hex.EncodeToString(sum[:]), nil |
| +} |
| + |
| +// stagedUpload resolves one receipt: this staging key, staged by this |
| +// actor for this deck, not yet registered and not yet stale. |
| +// |
| +// All four conditions are the codex 2 fix in one query. The key alone |
| +// is not enough (that was the hole: a ref was), the deck alone is not |
| +// enough, and a receipt cannot be replayed into a second registration. |
| +func (a *app) stagedUpload(ctx context.Context, d Deck, actor, key string) (AssetUpload, bool, error) { |
| + var up AssetUpload |
| + err := a.db.G.WithContext(ctx). |
| + Where("staging_key = ? AND deck_id = ? AND actor = ? AND consumed_at IS NULL AND created_at > ?", |
| + key, d.ID, actor, time.Now().UTC().Add(-stagingLife)). |
| + Take(&up).Error |
| + switch { |
| + case err == nil: |
| + return up, true, nil |
| + case errors.Is(err, gorm.ErrRecordNotFound): |
| + return AssetUpload{}, false, nil |
| + default: |
| + return AssetUpload{}, false, err |
| + } |
| +} |
| + |
| // --- POST /d/{sqid}/uploads --- |
| // signUpload answers "where do I put these bytes". |
| // |
| -// The hash comes from the client, and that is not a trust decision: the |
| -// store is content-addressed, so the key IS the hash, and a client that |
| -// lies about it uploads bytes to a key that does not describe them — |
| -// which registerAsset then reads back and re-hashes before the deck |
| -// ever refers to it. Nothing between here and there believes the |
| -// number. |
| +// The answer is a staging key of this app's own and never the hash the |
| +// client sent (codex 1). The hash is still read, because the browser |
| +// computes it anyway and the development PUT can cheaply check what |
| +// landed against it, but nothing is addressed by it: registration |
| +// hashes the bytes it actually reads and that hash is what the deck |
| +// ends up referring to. |
| +// |
| +// Why it matters that the client cannot choose: rastrillo's store signs |
| +// an unconditional PUT (blobs/s3.go's PresignPut), so an address a |
| +// browser can name is an address a browser can overwrite. Naming a ref |
| +// another deck's picture is already served from would have changed that |
| +// picture, in that deck, with no document update anywhere. |
| func (a *app) signUpload(w http.ResponseWriter, r *http.Request) { |
| u, ok := a.viewer(w, r) |
| if !ok { |
| @@ -174,17 +248,61 @@ func (a *app) signUpload(w http.ResponseWriter, r *http.Request) { |
| assetFail(w, r, http.StatusRequestEntityTooLarge, "too_large", "toolarge") |
| return |
| } |
| + // The declared hash is still held to the shape a ref has, because a |
| + // malformed one is a client bug worth reporting rather than a |
| + // number to record. It addresses nothing either way. |
| if !assetRefPattern.MatchString(body.Hash) { |
| assetFail(w, r, http.StatusUnprocessableEntity, "invalid", "invalid") |
| return |
| } |
| + kind := strings.ToLower(strings.TrimSpace(body.Type)) |
| + key, err := newStagingKey(d.ID, actorFor(u)) |
| + if err != nil { |
| + a.fail(w, r, "mint a staging key", err) |
| + return |
| + } |
| + // Belt to the staging key's braces. A fresh random key cannot be an |
| + // address this deck already serves from, so this can only fire if |
| + // somebody reintroduces a caller-chosen key — which is the mistake |
| + // codex 1 found, and this is the line that would catch it coming |
| + // back. Checked across every deck, not just this one: an address |
| + // verified anywhere is an address in use. |
| + var verified int64 |
| + if err := a.db.G.WithContext(r.Context()).Model(&DeckAsset{}). |
| + Where("ref = ?", key).Count(&verified).Error; err != nil { |
| + a.fail(w, r, "check whether an upload address is already verified", err) |
| + return |
| + } |
| + if verified > 0 { |
| + a.logger.Error("slides: refused to sign a PUT for a verified address", |
| + "deck", d.Sqid, "ref", key) |
| + assetFail(w, r, http.StatusConflict, "already_verified", "failed") |
| + return |
| + } |
| + |
| + // The receipt goes in BEFORE the URL goes out. A receipt with no |
| + // upload against it is a row that expires unread; a URL with no |
| + // receipt is bytes nothing will ever register, which is worse |
| + // because somebody waits for it. |
| + up := AssetUpload{ |
| + DeckID: d.ID, |
| + Actor: actorFor(u), |
| + StagingKey: key, |
| + DeclaredHash: body.Hash, |
| + ContentType: kind, |
| + Size: body.Size, |
| + CreatedAt: time.Now().UTC(), |
| + } |
| out := map[string]any{ |
| - "ref": body.Hash, |
| + "ref": key, |
| "headers": map[string]string{"Content-Type": body.Type}, |
| } |
| if p, canPresign := store.(presigner); canPresign { |
| - url, err := p.PresignPut(body.Hash, presignTTL) |
| + // The browser writes the staging key itself, so that is where |
| + // the bytes will be. |
| + up.ObjectKey = key |
| + url, err := p.PresignPut(key, presignTTL) |
| if err != nil { |
| a.logger.Error("slides: presign an upload", "deck", d.Sqid, "err", err) |
| assetFail(w, r, http.StatusInternalServerError, "internal", "failed") |
| @@ -195,8 +313,14 @@ func (a *app) signUpload(w http.ResponseWriter, r *http.Request) { |
| // blobs.Dir has nothing to sign, so development uploads the |
| // same way through this app. Production with a real bucket |
| // never reaches this branch and never proxies a byte — see |
| - // putUpload's own refusal. |
| - out["url"] = "/d/" + d.Sqid + "/uploads/" + body.Hash |
| + // putUpload's own refusal. ObjectKey stays empty: a |
| + // content-addressed store picks the key itself, and putUpload |
| + // records which one. |
| + out["url"] = "/d/" + d.Sqid + "/uploads/" + key |
| + } |
| + if err := a.db.G.WithContext(r.Context()).Create(&up).Error; err != nil { |
| + a.fail(w, r, "record a staged upload", err) |
| + return |
| } |
| writeAssetJSON(w, http.StatusOK, out) |
| } |
| @@ -212,12 +336,18 @@ func (a *app) signUpload(w http.ResponseWriter, r *http.Request) { |
| // not travel through an app process that has one small instance's |
| // memory, and an endpoint that silently accepted it anyway would be a |
| // performance cliff nobody could see in the code. |
| +// |
| +// It takes a staging key, not a ref, and resolves the receipt for it |
| +// before it reads a byte (codex 1). Without that this endpoint was its |
| +// own copy of the same hole: any member could write any address in the |
| +// store, presigning or not. |
| func (a *app) putUpload(w http.ResponseWriter, r *http.Request) { |
| u, ok := a.viewer(w, r) |
| if !ok { |
| return |
| } |
| - if _, ok := a.deckFor(w, r, u); !ok { |
| + d, ok := a.deckFor(w, r, u) |
| + if !ok { |
| return |
| } |
| store := a.cfg.Blobs |
| @@ -229,11 +359,22 @@ func (a *app) putUpload(w http.ResponseWriter, r *http.Request) { |
| assetFail(w, r, http.StatusNotFound, "not_found", "invalid") |
| return |
| } |
| - ref := chi.URLParam(r, "ref") |
| - if !assetRefPattern.MatchString(ref) { |
| + key := chi.URLParam(r, "ref") |
| + if !assetRefPattern.MatchString(key) { |
| assetFail(w, r, http.StatusUnprocessableEntity, "invalid", "invalid") |
| return |
| } |
| + up, staged, err := a.stagedUpload(r.Context(), d, actorFor(u), key) |
| + if err != nil { |
| + a.fail(w, r, "resolve a staged upload", err) |
| + return |
| + } |
| + if !staged { |
| + // The same 404 an address nobody signed gets. A member learns |
| + // nothing about what other people have staged. |
| + assetFail(w, r, http.StatusNotFound, "not_found", "invalid") |
| + return |
| + } |
| r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes) |
| stored, err := store.Put(r.Context(), r.Body, r.Header.Get("Content-Type")) |
| @@ -247,13 +388,24 @@ func (a *app) putUpload(w http.ResponseWriter, r *http.Request) { |
| assetFail(w, r, http.StatusInternalServerError, "internal", "failed") |
| return |
| } |
| - // The store hashed what it read. A client that named a different |
| - // key gets told so rather than being left to register a ref |
| - // nothing is under. |
| - if stored.Hash != ref { |
| + // The store hashed what it read, and the client said what it was |
| + // sending. A disagreement is a client bug and is reported as one — |
| + // it is no longer a security question, because what registration |
| + // promotes is addressed by the bytes this app read either way. |
| + if up.DeclaredHash != "" && stored.Hash != up.DeclaredHash { |
| assetFail(w, r, http.StatusUnprocessableEntity, "hash_mismatch", "invalid") |
| return |
| } |
| + // Where the bytes actually landed. blobs.Dir is content-addressed |
| + // and has no way to write a key of somebody else's choosing, so the |
| + // receipt records the key it chose rather than pretending the |
| + // staging key holds anything. |
| + if err := a.db.G.WithContext(r.Context()).Model(&AssetUpload{}). |
| + Where("id = ?", up.ID). |
| + Updates(map[string]any{"object_key": stored.Hash, "size": stored.Size}).Error; err != nil { |
| + a.fail(w, r, "record where an upload landed", err) |
| + return |
| + } |
| w.WriteHeader(http.StatusNoContent) |
| } |
| @@ -267,11 +419,26 @@ func (a *app) putUpload(w http.ResponseWriter, r *http.Request) { |
| // header, and this is the one place between an upload and a deck full |
| // of readers where somebody can check that a file called a PNG is one. |
| // |
| +// What it will look at is a STAGED upload and nothing else: the `ref` |
| +// in the body is the staging key POST /d/{sqid}/uploads answered with, |
| +// and a receipt has to say this principal staged it for this deck |
| +// (codex 2). A ref on its own is never enough — that was the hole, and |
| +// it turned knowing another deck's content address into permission to |
| +// read it, because the row this writes plus a `register_asset` op are |
| +// exactly what serveAsset asks for. |
| +// |
| +// It then does what this comment used to claim and did not do (codex |
| +// 1): it hashes the bytes it read and PUTS THEM BACK, so the address |
| +// the deck ends up referring to is derived from bytes this app has |
| +// seen. That costs one upload of at most 20 MiB per registration, on |
| +// top of the download registration already did. Known, and the price |
| +// of a final object no browser was ever handed a PUT for. |
| +// |
| // An SVG goes through svg.go on the way, and comes back different — a |
| // stripped document has different bytes and therefore, in a |
| // content-addressed store, a different address. So the ref this answers |
| -// with is not necessarily the ref it was asked about, and the client |
| -// registers what came back rather than what it uploaded. |
| +// with is never the ref it was asked about, and the client registers |
| +// what came back rather than what it uploaded. |
| func (a *app) registerAsset(w http.ResponseWriter, r *http.Request) { |
| u, ok := a.viewer(w, r) |
| if !ok { |
| @@ -313,7 +480,29 @@ func (a *app) registerAsset(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| - raw, err := a.readAsset(r.Context(), store, body.Ref) |
| + // The receipt. Everything after this point is about bytes THIS |
| + // principal staged for THIS deck; a `ref` that names anything else |
| + // gets the same 404 an address holding nothing gets. |
| + up, staged, err := a.stagedUpload(r.Context(), d, actorFor(u), body.Ref) |
| + if err != nil { |
| + a.fail(w, r, "resolve a staged upload", err) |
| + return |
| + } |
| + if !staged { |
| + assetFail(w, r, http.StatusNotFound, "not_found", "missing") |
| + return |
| + } |
| + // Where the bytes are: the staging key on a presigning store, where |
| + // the browser wrote them itself, or whatever key blobs.Dir chose |
| + // when the PUT came through this app. Empty means the PUT never |
| + // landed, which reads the same as an empty address. |
| + object := up.ObjectKey |
| + if object == "" { |
| + assetFail(w, r, http.StatusNotFound, "not_found", "missing") |
| + return |
| + } |
| + |
| + raw, err := a.readAsset(r.Context(), store, object) |
| if err != nil { |
| if errors.Is(err, blobs.ErrNotFound) { |
| assetFail(w, r, http.StatusNotFound, "not_found", "missing") |
| @@ -333,7 +522,7 @@ func (a *app) registerAsset(w http.ResponseWriter, r *http.Request) { |
| assetFail(w, r, http.StatusUnprocessableEntity, "not_an_image", "notimage") |
| return |
| } |
| - ref := body.Ref |
| + final := raw |
| if kind == "image/svg+xml" { |
| clean, err := sanitiseSVG(raw) |
| if err != nil { |
| @@ -341,21 +530,23 @@ func (a *app) registerAsset(w http.ResponseWriter, r *http.Request) { |
| assetFail(w, r, http.StatusUnprocessableEntity, "unsafe_svg", "svg") |
| return |
| } |
| - if !bytes.Equal(clean, raw) { |
| - // Stripped, so these are different bytes at a different |
| - // address. Stored under the new one; the old one is left |
| - // where it is, unreferenced by any deck and therefore |
| - // never servable — serveAsset only ever answers for a ref |
| - // this deck's `assets` map holds. |
| - stored, err := store.Put(r.Context(), bytes.NewReader(clean), kind) |
| - if err != nil { |
| - a.logger.Error("slides: store a sanitised SVG", "deck", d.Sqid, "err", err) |
| - assetFail(w, r, http.StatusInternalServerError, "internal", "failed") |
| - return |
| - } |
| - ref = stored.Hash |
| - } |
| + final = clean |
| + } |
| + |
| + // The promotion: what was read, put back. The store is |
| + // content-addressed, so the key this answers with IS the SHA-256 of |
| + // the bytes above — the rehash this endpoint's comment promised |
| + // before codex 1 pointed out that nothing did it — and no browser |
| + // has ever been handed a PUT for that address, so it holds these |
| + // bytes for as long as it exists. Idempotent by construction: |
| + // registering the same picture twice is one stored object. |
| + stored, err := store.Put(r.Context(), bytes.NewReader(final), kind) |
| + if err != nil { |
| + a.logger.Error("slides: promote a staged upload", "deck", d.Sqid, "err", err) |
| + assetFail(w, r, http.StatusInternalServerError, "internal", "failed") |
| + return |
| } |
| + ref := stored.Hash |
| // The verification, written down (models.go's DeckAsset). The |
| // record this answers with is what the client will name in a |
| @@ -372,6 +563,27 @@ func (a *app) registerAsset(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| + // The receipt is spent, and the staged copy goes. Both after the |
| + // verification row, so a failure here costs a stale row and a |
| + // staged object rather than a picture that can never be served. |
| + // |
| + // The delete is skipped when the staged key IS the final one, which |
| + // is the ordinary development case: blobs.Dir stored the bytes |
| + // under their own hash, sanitising changed nothing, and the object |
| + // the browser uploaded is the promoted object. Deleting it there |
| + // would delete the picture. |
| + now := time.Now().UTC() |
| + if err := a.db.G.WithContext(r.Context()).Model(&AssetUpload{}). |
| + Where("id = ? AND consumed_at IS NULL", up.ID). |
| + Update("consumed_at", now).Error; err != nil { |
| + a.logger.Error("slides: spend an upload receipt", "deck", d.Sqid, "upload", up.ID, "err", err) |
| + } |
| + if object != ref { |
| + if err := store.Delete(r.Context(), object); err != nil { |
| + a.logger.Error("slides: discard a staged upload", "deck", d.Sqid, "ref", object, "err", err) |
| + } |
| + } |
| + |
| id, err := newID() |
| if err != nil { |
| a.fail(w, r, "mint an asset id", err) |
diff --git a/internal/slides/auth.go b/internal/slides/auth.go| index 5f047bb..4a2e3fa 100644 |
| --- a/internal/slides/auth.go |
| +++ b/internal/slides/auth.go |
| @@ -26,6 +26,7 @@ import ( |
| "net/http" |
| "net/url" |
| "strings" |
| + "time" |
| "gorm.io/gorm" |
| "gorm.io/gorm/clause" |
| @@ -34,6 +35,8 @@ import ( |
| rauth "amadan.net/rastrillo/rastrillo/auth" |
| "amadan.net/rastrillo/rastrillo/mail" |
| "amadan.net/rastrillo/rastrillo/sessions" |
| + |
| + "amadan.net/oficina/slides/internal/slides/present" |
| ) |
| // signinPath is where the sign-in page lives. It must keep matching |
| @@ -354,15 +357,32 @@ func colourFor(email string) string { |
| // welcome decks, and a failure to build one never stops a sign-in. |
| // |
| // sessions.Current(r).Subject, never sessions.UserID — see ensureUser. |
| -func (a *app) currentUser(r *http.Request) (User, bool) { |
| +// |
| +// IT IS ALSO THE ADMISSION RECHECK (spec §6.1, codex 6). The allowlist |
| +// used to gate only the minting of a session: past that, a session was |
| +// good until it expired, and rastrillo's RequireSession asks nothing |
| +// about admission. So taking an address out of |
| +// SLIDES_ALLOWED_ADDRESSES and restarting left every browser that |
| +// person was already signed in on working exactly as before — the one |
| +// control this instance has for saying "not any more" did not say it. |
| +// |
| +// It takes the ResponseWriter for that reason: the honest answer to a |
| +// session whose address is no longer admitted is to end it, here, |
| +// rather than to let the request through or to fail it as an internal |
| +// error. offboard does the rest. |
| +func (a *app) currentUser(w http.ResponseWriter, r *http.Request) (User, sessionOutcome) { |
| s, ok := sessions.Current(r) |
| if !ok || s.Subject == "" { |
| - return User{}, false |
| + return User{}, sessionNone |
| + } |
| + if !a.allowed.allows(s.Subject) { |
| + a.offboard(w, r, s.Subject) |
| + return User{}, sessionOffboarded |
| } |
| u, created, err := ensureUser(r.Context(), a.db.G, s.Subject) |
| if err != nil { |
| a.logger.Error("slides: resolve current user", "err", err) |
| - return User{}, false |
| + return User{}, sessionNone |
| } |
| if created { |
| if has, err := hasDeck(r.Context(), a.db.G, u); err != nil { |
| @@ -371,7 +391,120 @@ func (a *app) currentUser(r *http.Request) (User, bool) { |
| a.seedSampleDeckFor(r.Context(), u) |
| } |
| } |
| - return u, true |
| + return u, sessionOK |
| +} |
| + |
| +// sessionOutcome is what currentUser concluded about a request, and it |
| +// is three answers rather than two because one of them means "this |
| +// request has already been answered". |
| +type sessionOutcome int |
| + |
| +const ( |
| + // sessionOK: there is a person, and the second return of |
| + // currentUser is their row. |
| + sessionOK sessionOutcome = iota |
| + // sessionNone: no session, or the row could not be read. Never |
| + // reached past RequireSession, and a bug if it is — the caller |
| + // renders its own 500. |
| + sessionNone |
| + // sessionOffboarded: there was a session, and its address is no |
| + // longer on the allowlist. offboard has ended it and written the |
| + // answer; the caller must write nothing more. |
| + sessionOffboarded |
| +) |
| + |
| +// offboard is what happens to somebody whose address is no longer on |
| +// the allowlist and who still has a live session — spec §6.1's |
| +// offboarding rule (codex 6). |
| +// |
| +// Three things, in this order, and the order is the point: |
| +// |
| +// - Every session for that address is DELETED, not just this |
| +// request's, and not just the cookie. A cleared cookie leaves the |
| +// token in the sessions table and valid, so a browser holding a |
| +// copy — a second tab restored from history, a phone — would carry |
| +// on. Real revocation, and for every device at once, because |
| +// offboarding is about the person and not about the browser they |
| +// happen to be using. |
| +// - Every presentation they had running is ENDED. A live session is |
| +// a public URL that reads the deck out, so leaving one up would |
| +// leave a removed person's talk readable by whoever has the |
| +// address. Their share links are NOT revoked: a share link belongs |
| +// to the deck, the deck belongs to the team, and revoking the |
| +// team's links because one member left would be an outage the |
| +// departure did not call for. The share page is where a link goes |
| +// away, deliberately by somebody who still has access. |
| +// - The cookie is cleared and the person is told, in words, on the |
| +// sign-in page. `?err=removed` rather than a silent redirect, |
| +// because "your access to this instance was removed" is a |
| +// different fact from "you are signed out" and a person who is not |
| +// told the first will simply ask for another link. |
| +// |
| +// A page request gets the redirect; anything else gets a 403, which is |
| +// rauth's own split (refuseSession) and the only one that makes sense |
| +// for a fetch or a form POST. |
| +func (a *app) offboard(w http.ResponseWriter, r *http.Request, address string) { |
| + email := normalizeAddress(address) |
| + a.logger.Warn("slides: a signed-in address is no longer admitted; ending its sessions", |
| + "address", email) |
| + |
| + // rastrillo/sessions owns this table and offers no "revoke every |
| + // session for a subject", so the statement is written here. The |
| + // subject IS the address under rastrillo/auth (SKILL.md §5), which |
| + // is what makes one statement enough. |
| + if err := a.db.G.WithContext(r.Context()). |
| + Exec("DELETE FROM sessions WHERE subject = ?", email).Error; err != nil { |
| + a.logger.Error("slides: revoke the sessions of a removed address", "err", err) |
| + } |
| + |
| + var u User |
| + if err := a.db.G.WithContext(r.Context()). |
| + Where("email = ?", email).Take(&u).Error; err == nil { |
| + a.endPresentationsOf(r, actorFor(u)) |
| + } else if !errors.Is(err, gorm.ErrRecordNotFound) { |
| + a.logger.Error("slides: load a removed address's user row", "err", err) |
| + } |
| + |
| + if a.auth != nil { |
| + http.SetCookie(w, &http.Cookie{ |
| + Name: a.auth.SessionCookie(), Value: "", Path: "/", |
| + MaxAge: -1, HttpOnly: true, |
| + SameSite: http.SameSiteLaxMode, |
| + Secure: strings.HasPrefix(a.cfg.Origin, "https://"), |
| + }) |
| + } |
| + if r.Method == http.MethodGet || r.Method == http.MethodHead { |
| + http.Redirect(w, r, signinPath+"?err=removed", http.StatusSeeOther) |
| + return |
| + } |
| + http.Error(w, "this address is no longer admitted here", http.StatusForbidden) |
| +} |
| + |
| +// endPresentationsOf closes every live talk one actor still has open. |
| +// Best effort and logged: it is part of offboarding, and a failure to |
| +// end a talk must not stop the sign-out that is the main event. |
| +func (a *app) endPresentationsOf(r *http.Request, actor string) { |
| + var live []Presentation |
| + if err := a.db.G.WithContext(r.Context()). |
| + Where("actor = ? AND ended_at IS NULL", actor).Find(&live).Error; err != nil { |
| + a.logger.Error("slides: list a removed actor's presentations", "actor", actor, "err", err) |
| + return |
| + } |
| + if len(live) == 0 { |
| + return |
| + } |
| + now := time.Now().UTC() |
| + if err := a.db.G.WithContext(r.Context()).Model(&Presentation{}). |
| + Where("actor = ? AND ended_at IS NULL", actor). |
| + Update("ended_at", now).Error; err != nil { |
| + a.logger.Error("slides: end a removed actor's presentations", "actor", actor, "err", err) |
| + return |
| + } |
| + // Every projector, phone and follower watching one of them, told at |
| + // once — the same event the End button publishes. |
| + for _, p := range live { |
| + a.hub.Publish(p.Sqid, present.Event{Ended: true}) |
| + } |
| } |
| // signinPage is GET /signin's view data. rauth's own handlers never |
| @@ -415,6 +548,11 @@ func (a *app) signin(w http.ResponseWriter, r *http.Request) { |
| data.ErrorMessage = rastrillo.T(r, "slides.signin.error.address") |
| case "expired": |
| data.ErrorMessage = rastrillo.T(r, "slides.signin.error.expired") |
| + case "removed": |
| + // Not "sign-in did not complete": this address had access and |
| + // no longer does (offboard). Asking for another link will not |
| + // help, and the sentence says so. |
| + data.ErrorMessage = rastrillo.T(r, "slides.signin.error.removed") |
| default: |
| data.ErrorMessage = rastrillo.T(r, "slides.signin.error.unknown") |
| } |
diff --git a/internal/slides/decks.go b/internal/slides/decks.go| index 33c3a26..901e255 100644 |
| --- a/internal/slides/decks.go |
| +++ b/internal/slides/decks.go |
| @@ -408,12 +408,20 @@ func (a *app) createDeckRow(ctx context.Context, owner User, title, theme string |
| // not that nobody is signed in, so it is an error page and not a |
| // redirect. |
| func (a *app) viewer(w http.ResponseWriter, r *http.Request) (User, bool) { |
| - u, ok := a.currentUser(r) |
| - if !ok { |
| + u, outcome := a.currentUser(w, r) |
| + switch outcome { |
| + case sessionOK: |
| + return u, true |
| + case sessionOffboarded: |
| + // currentUser answered for itself: the address is no longer |
| + // admitted, so the person has been signed out and told why |
| + // (auth.go's offboard). Not an internal error, and not a |
| + // second answer written over the one already sent. |
| + return User{}, false |
| + default: |
| a.fail(w, r, "no current user past the session gate", nil) |
| return User{}, false |
| } |
| - return u, true |
| } |
| // fail logs why with a ref and renders the app's own 500. |
| @@ -1044,6 +1052,12 @@ func (a *app) deleteDeck(w http.ResponseWriter, r *http.Request) { |
| if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckAsset{}).Error; err != nil { |
| return err |
| } |
| + // And the upload receipts (models.go's AssetUpload): a receipt |
| + // is registrable only into the deck it was staged for, so one |
| + // whose deck is gone is a row nothing can ever spend. |
| + if err := tx.Where("deck_id = ?", d.ID).Delete(&AssetUpload{}).Error; err != nil { |
| + return err |
| + } |
| if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckGrant{}).Error; err != nil { |
| return err |
| } |
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml| index 8bf3ba8..1911882 100644 |
| --- a/internal/slides/locales/en.toml |
| +++ b/internal/slides/locales/en.toml |
| @@ -26,6 +26,7 @@ slides.signin.error.rate = "Too many attempts. Wait a few minutes and try again. |
| slides.signin.error.address = "That doesn't look like a valid email address." |
| slides.signin.error.expired = "That link has expired or was already used. Enter your email below for a new one." |
| slides.signin.error.unknown = "We couldn't sign you in. Enter your email below for a new link." |
| +slides.signin.error.removed = "Your access to this instance has been removed, so you've been signed out. A new link won't help — ask whoever runs it if this is wrong." |
| # slides.decks.* — GET /, the deck list, and GET /decks/new. Title is |
| # the screen's noun, per page-header's own contract ("Decks", not |
diff --git a/internal/slides/migrations/0008_create_asset_uploads.sql b/internal/slides/migrations/0008_create_asset_uploads.sql| new file mode 100644 |
| index 0000000..93131a6 |
| --- /dev/null |
| +++ b/internal/slides/migrations/0008_create_asset_uploads.sql |
| @@ -0,0 +1,6 @@ |
| +CREATE TABLE `asset_uploads` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`actor` text,`staging_key` text,`object_key` text,`declared_hash` text,`content_type` text,`size` integer,`created_at` datetime,`consumed_at` datetime); |
| + |
| +CREATE UNIQUE INDEX `idx_asset_uploads_staging_key` ON `asset_uploads`(`staging_key`); |
| + |
| +CREATE INDEX `idx_asset_uploads_deck_id` ON `asset_uploads`(`deck_id`); |
| + |
diff --git a/internal/slides/migrations/schema.sql b/internal/slides/migrations/schema.sql| index 75ccfdf..be36895 100644 |
| --- a/internal/slides/migrations/schema.sql |
| +++ b/internal/slides/migrations/schema.sql |
| @@ -3,6 +3,8 @@ |
| CREATE TABLE `agents` (`id` integer PRIMARY KEY AUTOINCREMENT,`name` text,`operator_id` integer,`token_hash` text,`created_at` datetime,`revoked_at` datetime); |
| +CREATE TABLE `asset_uploads` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`actor` text,`staging_key` text,`object_key` text,`declared_hash` text,`content_type` text,`size` integer,`created_at` datetime,`consumed_at` datetime); |
| + |
| CREATE TABLE `deck_assets` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`ref` text,`created_at` datetime); |
| CREATE TABLE `deck_grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`user_id` integer,`actor` text,`created_at` datetime); |
| @@ -23,6 +25,10 @@ CREATE INDEX `idx_agents_operator_id` ON `agents`(`operator_id`); |
| CREATE UNIQUE INDEX `idx_agents_token_hash` ON `agents`(`token_hash`); |
| +CREATE INDEX `idx_asset_uploads_deck_id` ON `asset_uploads`(`deck_id`); |
| + |
| +CREATE UNIQUE INDEX `idx_asset_uploads_staging_key` ON `asset_uploads`(`staging_key`); |
| + |
| CREATE UNIQUE INDEX `idx_deck_assets_deck_ref` ON `deck_assets`(`deck_id`,`ref`); |
| CREATE INDEX `idx_deck_grants_deck_id` ON `deck_grants`(`deck_id`); |
diff --git a/internal/slides/models.go b/internal/slides/models.go| index b8893ea..3a85126 100644 |
| --- a/internal/slides/models.go |
| +++ b/internal/slides/models.go |
| @@ -13,6 +13,7 @@ var Models = []any{ |
| &DeckUpdate{}, |
| &DeckSnapshot{}, |
| &DeckAsset{}, |
| + &AssetUpload{}, |
| &DeckGrant{}, |
| &ShareLink{}, |
| &Presentation{}, |
| @@ -179,6 +180,65 @@ type DeckAsset struct { |
| CreatedAt time.Time |
| } |
| +// AssetUpload is the RECEIPT for one upload this app itself signed — |
| +// spec §2.7 as amended by codex 1 and codex 2. It is what makes an |
| +// upload a thing that happened rather than a ref somebody knows. |
| +// |
| +// Before it existed, `POST /d/{sqid}/uploads` signed a PUT for whatever |
| +// address the browser named and `POST /d/{sqid}/assets` verified |
| +// whatever address the browser named. Two consequences, each a hole: |
| +// a member could sign a PUT for an address another deck's picture was |
| +// already served from and replace the bytes under it (codex 1), and a |
| +// member who merely KNEW another deck's ref could register it into a |
| +// deck of their own and pass both of serveAsset's checks (codex 2) — |
| +// which defeated the whole "a content address is not an authorisation" |
| +// rule the asset routes are built on. |
| +// |
| +// So the browser never names an address again. signUpload mints a |
| +// STAGING key — a hash of this deck, this actor and 32 fresh random |
| +// bytes, so the address is a function of the principal and is not |
| +// guessable or reachable from outside — writes this row, and signs a PUT |
| +// for that key alone. Registration will only look at bytes a row here |
| +// says THIS principal staged for THIS deck; it then reads them, hashes |
| +// what it actually read, and promotes them to a final object keyed by |
| +// that hash. Nothing ever signs a PUT for a final address, so a |
| +// verified object is immutable by construction rather than by nobody |
| +// having tried. |
| +// |
| +// StagingKey carries the unique index because it is what a PUT and a |
| +// registration are both resolved by. ObjectKey is where the bytes |
| +// actually are: the staging key itself on a presigning store (the |
| +// browser wrote them there), or the content address blobs.Dir chose in |
| +// development, where the PUT comes through this app and the store is |
| +// content-addressed with no way to write a caller's key. ConsumedAt |
| +// makes a receipt single-use, so one signed upload is one registration. |
| +type AssetUpload struct { |
| + ID int64 |
| + DeckID int64 `gorm:"index"` |
| + // Actor is who asked for the URL, in DeckUpdate.Actor's form. Half |
| + // of the pair registration matches on: an upload one member staged |
| + // is not another member's to register, and an upload staged for one |
| + // deck is not registrable into another. |
| + Actor string |
| + // StagingKey is the address this app told the browser to write to, |
| + // and the only address it will ever sign a PUT for. |
| + StagingKey string `gorm:"uniqueIndex"` |
| + // ObjectKey is where the staged bytes really are once the PUT has |
| + // landed. Empty until then on the development path. |
| + ObjectKey string |
| + // DeclaredHash is the SHA-256 the browser said the file has. Never |
| + // trusted for anything — the final address comes from bytes this |
| + // app read — but checked against what landed on the development |
| + // path, where this app does the hashing anyway. |
| + DeclaredHash string |
| + // ContentType is what the browser said it was uploading, kept so |
| + // the PUT can be signed with the header the bucket will store. |
| + ContentType string |
| + Size int64 |
| + CreatedAt time.Time |
| + ConsumedAt *time.Time |
| +} |
| + |
| // DeckGrant names one person who may open one deck — spec §5's |
| // `granted` rung. |
| // |
diff --git a/internal/slides/presentation.go b/internal/slides/presentation.go| index c9ba6f0..ed8ef5a 100644 |
| --- a/internal/slides/presentation.go |
| +++ b/internal/slides/presentation.go |
| @@ -29,6 +29,16 @@ |
| // The speaker notes follow the strongest of the three: the presenter's |
| // own session, or the remote token. Never the sqid, because the sqid |
| // is what goes on a projector. |
| +// |
| +// AN AUDIENCE NEVER LEARNS THE SQID (codex 5). Somebody following |
| +// along arrived with a share link and stays behind it: the page, the |
| +// slide fragments, the stream and the pictures are all `/s/{token}` |
| +// routes, each of which resolves the token again, so revoking the link |
| +// stops them mid-talk. Before the codex review, following handed the |
| +// session sqid to the browser and every later read went to `/p/…`, |
| +// which asks only for a live session — so a revoked link kept working |
| +// until the talk ended. The sqid is a projector address, and a |
| +// projector address is not something to give an audience. |
| package slides |
| import ( |
| @@ -397,8 +407,19 @@ type controls struct { |
| // audience's follow view, which is the same page read-only. |
| type surfacePage struct { |
| presentShell |
| - Title string |
| + Title string |
| + // Session is the presentation's sqid, and is EMPTY on the |
| + // audience's follow view (codex 5): a share-link holder must not |
| + // learn the address the projector is on, because that address is a |
| + // capability their own revocation cannot reach. |
| Session string |
| + // StreamHref and FragmentHref are the two URLs static/present.js |
| + // uses, handed down rather than built in the browser — so which |
| + // capability a surface reads under is decided here, once, by the |
| + // handler that knows. FragmentHref carries {n} where the slide |
| + // number goes. |
| + StreamHref string |
| + FragmentHref string |
| // Token is present only for a request entitled to drive, and it is |
| // what the script reads to send a keystroke. A page without it is |
| // read-only in the markup AND in the script. |
| @@ -549,6 +570,13 @@ func (a *app) presentSurface(w http.ResponseWriter, r *http.Request) { |
| } |
| a.renderSurface(w, r, d, p, surfaceOptions{ |
| token: r.URL.Query().Get("t"), |
| + // The projector's own address, which is the address it is |
| + // already on: nothing is disclosed by saying so. |
| + base: "/p/" + p.Sqid, |
| + session: p.Sqid, |
| + stream: "/p/" + p.Sqid + "/stream", |
| + //nolint:goconst // one URL, spelled where it is decided |
| + fragment: "/p/" + p.Sqid + "?slide={n}&fragment=1", |
| }) |
| } |
| @@ -558,6 +586,20 @@ type surfaceOptions struct { |
| // token, when it is the session's, makes the pager a pair of |
| // command forms rather than a pair of local links. |
| token string |
| + // base is the capability this surface reads under: "/p/{sqid}" for |
| + // a projector, "/s/{token}" for somebody following a share link. |
| + // The pictures hang off it (assetBaseFor), so an <img> on this page |
| + // is fetchable by exactly whoever is looking at it and by nobody |
| + // else. |
| + base string |
| + // session is the sqid, and is deliberately not derived from p: |
| + // putting it on the page is a disclosure, and the follow view does |
| + // not make it (codex 5). |
| + session string |
| + // stream and fragment are the two URLs the script uses. fragment |
| + // carries {n} where the slide number goes. |
| + stream string |
| + fragment string |
| // follow marks the audience's copy: a meta refresh, a way back to |
| // browsing, and never a control. |
| follow bool |
| @@ -577,7 +619,9 @@ func (a *app) renderSurface(w http.ResponseWriter, r *http.Request, d Deck, p Pr |
| page := surfacePage{ |
| presentShell: presentShellFor(r), |
| Title: view.Meta.Title, |
| - Session: p.Sqid, |
| + Session: opt.session, |
| + StreamHref: opt.stream, |
| + FragmentHref: opt.fragment, |
| } |
| if len(view.Slides) == 0 { |
| page.EmptyTitle = rastrillo.T(r, "slides.viewer.empty.title") |
| @@ -600,10 +644,12 @@ func (a *app) renderSurface(w http.ResponseWriter, r *http.Request, d Deck, p Pr |
| index, live = n, false |
| } |
| - // The projector and the audience have no session, so the pictures |
| - // go under the presentation's own capability (assets.go). |
| + // Neither the projector nor the audience has a session, so the |
| + // pictures go under whichever capability this surface IS |
| + // (assets.go): the presentation's for a projector, the share |
| + // link's for a follower. |
| html, err := slidehtml.SlideWith(view, index, slidehtml.Desktop, |
| - slidehtml.Options{AssetBase: assetBaseFor("/p/" + p.Sqid)}) |
| + slidehtml.Options{AssetBase: assetBaseFor(opt.base)}) |
| if err != nil { |
| a.fail(w, r, "render slide", err) |
| return |
| @@ -657,10 +703,10 @@ func (a *app) renderSurface(w http.ResponseWriter, r *http.Request, d Deck, p Pr |
| // write, and the next event puts them back on the presenter's |
| // slide. |
| if index > 0 { |
| - page.PrevHref = "/p/" + p.Sqid + "?slide=" + strconv.Itoa(index) |
| + page.PrevHref = opt.base + "?slide=" + strconv.Itoa(index) |
| } |
| if index+1 < len(view.Slides) { |
| - page.NextHref = "/p/" + p.Sqid + "?slide=" + strconv.Itoa(index+2) |
| + page.NextHref = opt.base + "?slide=" + strconv.Itoa(index+2) |
| } |
| } |
| render(w, "present", page) |
| @@ -1031,6 +1077,17 @@ func (a *app) presentStream(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| + a.streamPresentation(w, r, p, nil) |
| +} |
| + |
| +// streamPresentation is the SSE body, shared by the session's own |
| +// stream and the share link's (codex 5). `alive`, when it is not nil, |
| +// is re-asked before every write: it is how a follower's stream ends |
| +// the moment the link they arrived with is revoked, rather than |
| +// carrying on to the end of the talk because the session is still |
| +// live. One indexed read per event and per 15-second ping, which is |
| +// the price of a revocation that means something. |
| +func (a *app) streamPresentation(w http.ResponseWriter, r *http.Request, p Presentation, alive func() bool) { |
| fl, ok := w.(http.Flusher) |
| if !ok { |
| a.fail(w, r, "stream: the ResponseWriter does not flush", nil) |
| @@ -1058,6 +1115,9 @@ func (a *app) presentStream(w http.ResponseWriter, r *http.Request) { |
| fl.Flush() |
| write := func(text string) bool { |
| + if alive != nil && !alive() { |
| + return false |
| + } |
| // The deadline error is ignored deliberately: a |
| // ResponseWriter that cannot set deadlines still streams, |
| // unbounded. A write error ends the handler, because it means |
| @@ -1216,8 +1276,18 @@ func (a *app) presentCommand(w http.ResponseWriter, r *http.Request) { |
| // Everybody watching, at once: the presenter's laptop, the phone, |
| // and every follower. |
| a.hub.Publish(p.Sqid, eventFor(updated)) |
| + // The actor is the CAPABILITY, not the person who started the talk |
| + // (codex 5's sibling, codex 15). A remote token is transferable by |
| + // design — that is the feature: it goes on a phone, and the phone |
| + // may be somebody else's. So this write cannot name a person, and |
| + // naming p.Actor anyway made §6.2's "every write names its real |
| + // actor" false for exactly the writes nobody could vouch for. |
| + // `remote:<session>` says what actually authenticated, and the |
| + // issuer rides beside it so the trail still reaches somebody |
| + // answerable. |
| a.logger.Info("slides: presentation command", |
| - "session", p.Sqid, "op", op, "slide", updated.Slide, "actor", p.Actor) |
| + "session", p.Sqid, "op", op, "slide", updated.Slide, |
| + "actor", remoteActor(p), "issuer", p.Actor) |
| // A browser form goes back to the page it came from — same-origin |
| // only, checked against the origin the app resolved, so this can |
| @@ -1230,6 +1300,20 @@ func (a *app) presentCommand(w http.ResponseWriter, r *http.Request) { |
| w.WriteHeader(http.StatusNoContent) |
| } |
| +// remoteActor is spec §6.2's actor string for a presentation remote — |
| +// the third kind, beside decks.go's `person:<id>` and agents.go's |
| +// `agent:<id>`, and the only one that names no principal at all. |
| +// |
| +// It is deliberately anonymous (§6.2, codex 15). The remote token is a |
| +// transferable capability: the presenter shows a QR to the room, or |
| +// hands their phone to a colleague, and the server has no way to say |
| +// who is holding it. Writing `person:<the presenter>` on those commands |
| +// was a claim about identity the app could not support. This one is |
| +// honest — "whoever held the remote for this session" — and the |
| +// session's own Actor is logged beside it, so the person who issued the |
| +// capability is still named. |
| +func remoteActor(p Presentation) string { return "remote:" + p.Sqid } |
| + |
| // errBadCommand is an op, or a goto's slide, that is not one this |
| // session can do. |
| var errBadCommand = errors.New("slides: not a command") |
| @@ -1308,9 +1392,18 @@ func (a *app) liveSessionFor(r *http.Request, deckID int64) (Presentation, bool) |
| // reaches this page, so its script has nothing to send and its markup |
| // has no form. An audience that could drive the talk would not be an |
| // audience. |
| +// |
| +// And every URL on it is `base`, which is the share link (codex 5): |
| +// the pictures, the fragments the script swaps in and the stream it |
| +// subscribes to. The session sqid appears nowhere, so there is no |
| +// address here that outlives the capability it came with. |
| func (a *app) followPresenter(w http.ResponseWriter, r *http.Request, d Deck, p Presentation, base string) { |
| a.renderSurface(w, r, d, p, surfaceOptions{ |
| - follow: true, |
| + follow: true, |
| + base: base, |
| + stream: base + "/stream", |
| + fragment: base + "?follow=1&slide={n}&fragment=1", |
| + // No session: see surfacePage.Session. |
| backHref: base, |
| backLabel: rastrillo.T(r, "slides.follow.browse"), |
| }) |
diff --git a/internal/slides/share.go b/internal/slides/share.go| index c8ba998..34bb8dc 100644 |
| --- a/internal/slides/share.go |
| +++ b/internal/slides/share.go |
| @@ -23,8 +23,10 @@ import ( |
| "crypto/sha256" |
| "encoding/base64" |
| "encoding/hex" |
| + "encoding/json" |
| "errors" |
| "fmt" |
| + "io" |
| "net/http" |
| "strconv" |
| "time" |
| @@ -33,6 +35,8 @@ import ( |
| "gorm.io/gorm" |
| "amadan.net/rastrillo/rastrillo" |
| + |
| + "amadan.net/oficina/slides/internal/slides/present" |
| ) |
| // shareTokenBytes is how much randomness a share link carries. Thirty |
| @@ -331,9 +335,12 @@ func (a *app) sharedDeck(w http.ResponseWriter, r *http.Request) { |
| } |
| // Following the presenter (spec §3.4). The share link is the whole |
| - // capability here too: the audience never learns the session's own |
| - // address, never mind the remote's, so ?follow=1 is a flag on the |
| - // link they already hold rather than a second URL to hand out. |
| + // capability here too — really, and not only for this one request |
| + // (codex 5): the audience never learns the session's own address, |
| + // never mind the remote's, so ?follow=1 is a flag on the link they |
| + // already hold rather than a second URL to hand out, and every |
| + // later read they make comes back through here or through |
| + // /s/{token}/stream, both of which resolve the token again. |
| // |
| // Nothing to follow is a redirect back to the deck, not an error: |
| // the talk finishing while somebody was watching is the ordinary |
| @@ -351,6 +358,81 @@ func (a *app) sharedDeck(w http.ResponseWriter, r *http.Request) { |
| a.renderViewer(w, r, d, modeLink, "/s/"+token, shell{}) |
| } |
| +// --- GET /s/{token}/stream --- |
| + |
| +// sharedStream is the audience's SSE feed for whatever talk is live on |
| +// this deck — the presentation stream, behind the share link instead of |
| +// behind the session sqid (codex 5). |
| +// |
| +// It exists so that a follower's stream is a thing their own link can |
| +// be revoked out of. The events are the session's own, unchanged; what |
| +// differs is who is allowed to hear them and for how long. The link is |
| +// re-resolved before every write, so revoking it ends the stream within |
| +// one event or one heartbeat rather than at the end of the talk. |
| +// |
| +// Nothing to follow is not an error. A talk that finished while |
| +// somebody was watching gets one `ended` event, which is exactly what |
| +// the script does with a talk that ends any other way: reload, and |
| +// land on the deck they can still read. |
| +func (a *app) sharedStream(w http.ResponseWriter, r *http.Request) { |
| + token := chi.URLParam(r, "token") |
| + d, _, ok := a.deckForShareLink(w, r, token) |
| + if !ok { |
| + return |
| + } |
| + p, live := a.liveSessionFor(r, d.ID) |
| + if !live { |
| + a.endedStream(w, r) |
| + return |
| + } |
| + a.streamPresentation(w, r, p, func() bool { |
| + return a.shareLinkLive(r, token) |
| + }) |
| +} |
| + |
| +// shareLinkLive re-asks the one question deckForShareLink asks: is |
| +// this token still a link nobody has revoked. One indexed read, and no |
| +// answer written — the caller is mid-stream and has nothing to say to |
| +// a client but "goodbye". |
| +// |
| +// A database error reads as "not live", because failing closed on a |
| +// capability check is the only direction that is ever safe: the cost is |
| +// a follower whose page reconnects, and the alternative is a revoked |
| +// link that keeps working through a blip. |
| +func (a *app) shareLinkLive(r *http.Request, token string) bool { |
| + if token == "" { |
| + return false |
| + } |
| + var n int64 |
| + if err := a.db.G.WithContext(r.Context()).Model(&ShareLink{}). |
| + Where("token_hash = ? AND revoked_at IS NULL", hashShareToken(token)). |
| + Count(&n).Error; err != nil { |
| + a.logger.Error("slides: re-check a share link mid-stream", "err", err) |
| + return false |
| + } |
| + return n > 0 |
| +} |
| + |
| +// endedStream is one SSE event saying the talk is over, and then the |
| +// end of the response. The same shape presentStream's own connect |
| +// event has, so the script has one thing to understand rather than two. |
| +func (a *app) endedStream(w http.ResponseWriter, r *http.Request) { |
| + fl, ok := w.(http.Flusher) |
| + if !ok { |
| + a.fail(w, r, "stream: the ResponseWriter does not flush", nil) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/event-stream") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.WriteHeader(http.StatusOK) |
| + body, err := json.Marshal(present.Event{Ended: true}) |
| + if err != nil { |
| + return |
| + } |
| + _, _ = io.WriteString(w, "event: slide\ndata: "+string(body)+"\n\n") |
| + fl.Flush() |
| +} |
| + |
| // deckForShareLink resolves a share token to the deck it opens and the |
| // link row itself, answering 404 for a token that is missing, revoked |
| // or malformed. |
diff --git a/internal/slides/static/present.js b/internal/slides/static/present.js| index 9df0e17..8d46682 100644 |
| --- a/internal/slides/static/present.js |
| +++ b/internal/slides/static/present.js |
| @@ -10,16 +10,31 @@ |
| // /p/{session}/command), so there is one protocol rather than two, and |
| // only on a page rendered for whoever holds the remote: the token |
| // arrives as data-remote-token, and a page without one cannot send. |
| +// |
| +// Every URL it reads comes down as an attribute (codex 5). The |
| +// audience's follow view is served from a share link and stays there — |
| +// its stream and its slide fragments are /s/{token} URLs, so revoking |
| +// the link stops it — and it carries no session sqid at all, which is |
| +// why nothing in here builds a /p/… address out of one. |
| (function () { |
| - var root = document.querySelector('[data-session]'); |
| + var root = document.querySelector('[data-session], [data-stream]'); |
| if (!root) return; |
| - var session = root.getAttribute('data-session'); |
| + // Empty on a page that holds no session capability of its own. |
| + var session = root.getAttribute('data-session') || ''; |
| var token = root.getAttribute('data-remote-token') || ''; |
| + // The presenter view and the remote are always the session's own, so |
| + // they may fall back to its address; the surface says which it is. |
| + var streamHref = root.getAttribute('data-stream') || |
| + (session ? '/p/' + session + '/stream' : ''); |
| + // {n} is where the slide number goes. Absent on the pages that have |
| + // no stage to swap — they reload instead. |
| + var fragmentHref = root.getAttribute('data-fragment') || ''; |
| var stage = document.querySelector('[slides-stage]'); |
| var note = document.querySelector('[slides-blackout]'); |
| + if (!streamHref) return; |
| function command(op, slide) { |
| - if (!token) return; |
| + if (!token || !session) return; |
| var body = new URLSearchParams({ op: op, t: token }); |
| if (slide) body.set('slide', slide); |
| fetch('/p/' + session + '/command', { |
| @@ -69,6 +84,53 @@ |
| command(dx < 0 ? 'next' : 'prev'); |
| }, { passive: true }); |
| + // What is on the screen, and what a fetch is trying to put there. |
| + // TWO facts, not one (codex 13). They were one — the data-slide |
| + // attribute, written the moment an event arrived — and that is a |
| + // projector left permanently behind: with the record already saying |
| + // 3, a late response for 2 overwrote the stage and the event that |
| + // would have corrected it was suppressed as a slide the page was |
| + // already on. |
| + // |
| + // So: displayed only ever moves after an install succeeds, requested |
| + // rolls back to displayed when a fetch fails, and every response |
| + // carries the id of the request that asked for it — anything but the |
| + // newest is dropped on the floor, however late it turns up. |
| + var displayed = Number(root.getAttribute('data-slide')) || 0; |
| + var requested = displayed; |
| + var newest = 0; |
| + |
| + function show(slide) { |
| + // Slide numbers on the wire are 1-based; anything else is not an |
| + // event this page understands and is left alone rather than fetched. |
| + if (!(slide > 0) || slide === requested) return; |
| + requested = slide; |
| + if (!stage || !fragmentHref) { |
| + // The presenter view and the remote show two slides and the |
| + // notes; re-rendering all that in here would be a second |
| + // renderer to keep in step with the Go one. |
| + window.location.reload(); |
| + return; |
| + } |
| + var mine = ++newest; |
| + var back = function () { |
| + // Only this request's own failure rolls the record back, and |
| + // only while it is still the newest: otherwise the next event |
| + // for this slide would be suppressed as one already asked for. |
| + if (mine === newest && requested === slide) requested = displayed; |
| + }; |
| + fetch(fragmentHref.replace('{n}', String(slide)), { credentials: 'same-origin' }) |
| + .then(function (r) { return r.ok ? r.text() : null; }) |
| + .then(function (html) { |
| + if (mine !== newest) return; // superseded; this slide is old news |
| + if (html === null) { back(); return; } |
| + stage.innerHTML = html; |
| + displayed = slide; |
| + root.setAttribute('data-slide', String(slide)); |
| + }) |
| + .catch(back); |
| + } |
| + |
| // apply is the whole of what an event does. The state is complete — |
| // never a delta — so applying one is exactly right rather than |
| // approximately, and a page that missed some is right again on the |
| @@ -77,22 +139,10 @@ |
| if (state.ended) { window.location.reload(); return; } |
| root.setAttribute('data-blackout', state.blackout ? 'true' : 'false'); |
| if (note) note.hidden = !state.blackout; |
| - if (String(state.slide) === root.getAttribute('data-slide')) return; |
| - root.setAttribute('data-slide', String(state.slide)); |
| - if (!stage) { |
| - // The presenter view and the remote show two slides and the |
| - // notes; re-rendering all that in here would be a second |
| - // renderer to keep in step with the Go one. |
| - window.location.reload(); |
| - return; |
| - } |
| - fetch('/p/' + session + '?slide=' + state.slide + '&fragment=1', { credentials: 'same-origin' }) |
| - .then(function (r) { return r.ok ? r.text() : null; }) |
| - .then(function (html) { if (html !== null) stage.innerHTML = html; }) |
| - .catch(function () {}); |
| + show(Number(state.slide)); |
| } |
| - var events = new EventSource('/p/' + session + '/stream'); |
| + var events = new EventSource(streamHref); |
| events.addEventListener('slide', function (e) { |
| try { apply(JSON.parse(e.data)); } catch (err) { /* not ours */ } |
| }); |
diff --git a/internal/slides/templates/present.html b/internal/slides/templates/present.html| index ddad6a5..b2c89fc 100644 |
| --- a/internal/slides/templates/present.html |
| +++ b/internal/slides/templates/present.html |
| @@ -60,8 +60,14 @@ |
| } |
| </style>{{end}} |
| {{define "content"}} |
| +{{/* The two URLs the script reads come DOWN rather than being built up |
| + there: which capability this surface reads under is the handler's |
| + decision (presentation.go's surfaceOptions), and the audience's |
| + follow view deliberately carries no session sqid at all. */}} |
| <div slides-surface |
| - data-session="{{.Session}}" |
| + {{- if .Session}} data-session="{{.Session}}"{{end}} |
| + {{- if .StreamHref}} data-stream="{{.StreamHref}}"{{end}} |
| + {{- if .FragmentHref}} data-fragment="{{.FragmentHref}}"{{end}} |
| data-slide="{{.Now}}" |
| data-blackout="{{if .Blackout}}true{{else}}false{{end}}" |
| {{- if .Token}} data-remote-token="{{.Token}}"{{end}} |
diff --git a/internal/slidestest/assets_test.go b/internal/slidestest/assets_test.go| index 06e3a99..3fcab8b 100644 |
| --- a/internal/slidestest/assets_test.go |
| +++ b/internal/slidestest/assets_test.go |
| @@ -19,9 +19,11 @@ import ( |
| "encoding/hex" |
| "encoding/json" |
| "fmt" |
| + "io" |
| "net/http" |
| "net/http/httptest" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| @@ -49,25 +51,76 @@ func hashOf(b []byte) string { |
| return hex.EncodeToString(sum[:]) |
| } |
| -// presigningStore is blobs.Dir with the two methods a real bucket has. |
| -// The URLs it mints are not real — nothing fetches them — which is |
| -// exactly the point: what is under test is that the app hands the |
| -// browser a URL instead of the bytes. |
| +// presigningStore is blobs.Dir with the two methods a real bucket has, |
| +// plus the one thing blobs.Dir cannot do: hold bytes at an address it |
| +// did not choose. The URLs it mints are not real — nothing fetches |
| +// them — which is exactly the point: what is under test is that the app |
| +// hands the browser a URL instead of the bytes. |
| +// |
| +// putDirect is what a browser does with a presigned PUT, and the tests |
| +// need it because a staged upload lands at the staging key the app |
| +// minted rather than at its own content hash (assets.go, codex 1). |
| type presigningStore struct { |
| blobs.Store |
| + mu sync.Mutex |
| minted []string |
| + direct map[string][]byte |
| } |
| func (s *presigningStore) PresignPut(hash string, _ time.Duration) (string, error) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| s.minted = append(s.minted, "PUT "+hash) |
| return "https://bucket.test/" + hash + "?X-Amz-Signature=put", nil |
| } |
| func (s *presigningStore) PresignGet(hash string, _ time.Duration) (string, error) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| s.minted = append(s.minted, "GET "+hash) |
| return "https://bucket.test/" + hash + "?X-Amz-Signature=get", nil |
| } |
| +// putDirect writes body at key, the way a presigned PUT does. |
| +func (s *presigningStore) putDirect(key string, body []byte) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + if s.direct == nil { |
| + s.direct = map[string][]byte{} |
| + } |
| + s.direct[key] = body |
| +} |
| + |
| +func (s *presigningStore) Get(ctx context.Context, hash string) (io.ReadCloser, error) { |
| + s.mu.Lock() |
| + body, ok := s.direct[hash] |
| + s.mu.Unlock() |
| + if ok { |
| + return io.NopCloser(bytes.NewReader(body)), nil |
| + } |
| + return s.Store.Get(ctx, hash) |
| +} |
| + |
| +func (s *presigningStore) Delete(ctx context.Context, hash string) error { |
| + s.mu.Lock() |
| + delete(s.direct, hash) |
| + s.mu.Unlock() |
| + return s.Store.Delete(ctx, hash) |
| +} |
| + |
| +// mintedPuts is every address this store was asked to sign a PUT for. |
| +func (s *presigningStore) mintedPuts() []string { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + out := []string{} |
| + for _, m := range s.minted { |
| + if strings.HasPrefix(m, "PUT ") { |
| + out = append(out, strings.TrimPrefix(m, "PUT ")) |
| + } |
| + } |
| + return out |
| +} |
| + |
| // newAppWithStore is newApp with an object store bound — the shape |
| // cmd/slides/main.go builds in development, or on an instance the |
| // platform granted a bucket. |
| @@ -119,23 +172,35 @@ func jsonBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { |
| return out |
| } |
| -// upload runs the whole browser flow for one file and returns the |
| -// record the server validated: sign, PUT, register. |
| -func upload(t *testing.T, a *testApp, deck string, cookies []*http.Cookie, name, kind string, body []byte, alt string) map[string]any { |
| +// stageUpload is the first half of the browser flow: sign, then PUT. |
| +// It returns the staging key the app minted, which is what registration |
| +// takes — never a hash the caller chose (assets.go, codex 1). |
| +func stageUpload(t *testing.T, a *testApp, deck string, cookies []*http.Cookie, name, kind string, body []byte) string { |
| t.Helper() |
| - hash := hashOf(body) |
| signed := postJSON(t, a, deck+"/uploads", |
| - fmt.Sprintf(`{"name":%q,"size":%d,"type":%q,"hash":%q}`, name, len(body), kind, hash), cookies) |
| + fmt.Sprintf(`{"name":%q,"size":%d,"type":%q,"hash":%q}`, name, len(body), kind, hashOf(body)), cookies) |
| if signed.Code != http.StatusOK { |
| t.Fatalf("POST %s/uploads: status %d, want 200\n%s", deck, signed.Code, signed.Body) |
| } |
| out := jsonBody(t, signed) |
| + key, _ := out["ref"].(string) |
| + if key == "" { |
| + t.Fatalf("POST %s/uploads answered no staging address: %v", deck, out) |
| + } |
| target, _ := out["url"].(string) |
| if put := putBytes(t, a, target, kind, body, cookies); put.Code != http.StatusNoContent { |
| t.Fatalf("PUT %s: status %d, want 204\n%s", target, put.Code, put.Body) |
| } |
| + return key |
| +} |
| + |
| +// upload runs the whole browser flow for one file and returns the |
| +// record the server validated: sign, PUT, register. |
| +func upload(t *testing.T, a *testApp, deck string, cookies []*http.Cookie, name, kind string, body []byte, alt string) map[string]any { |
| + t.Helper() |
| + key := stageUpload(t, a, deck, cookies, name, kind, body) |
| reg := postJSON(t, a, deck+"/assets", |
| - fmt.Sprintf(`{"ref":%q,"name":%q,"width":10,"height":20,"alt":%q}`, out["ref"], name, alt), cookies) |
| + fmt.Sprintf(`{"ref":%q,"name":%q,"width":10,"height":20,"alt":%q}`, key, name, alt), cookies) |
| if reg.Code != http.StatusOK { |
| t.Fatalf("POST %s/assets: status %d, want 200\n%s", deck, reg.Code, reg.Body) |
| } |
| @@ -279,15 +344,9 @@ func TestUploadLimits(t *testing.T) { |
| // A file that says PNG and is not one is caught at registration, |
| // which is the only place the bytes are ever looked at. |
| notAnImage := []byte("<!doctype html><script>alert(1)</script>") |
| - signed := postJSON(t, a, deck+"/uploads", |
| - fmt.Sprintf(`{"name":"x.png","size":%d,"type":"image/png","hash":%q}`, len(notAnImage), hashOf(notAnImage)), cookies) |
| - if signed.Code != http.StatusOK { |
| - t.Fatalf("sign: status %d\n%s", signed.Code, signed.Body) |
| - } |
| - target, _ := jsonBody(t, signed)["url"].(string) |
| - putBytes(t, a, target, "image/png", notAnImage, cookies) |
| + fake := stageUpload(t, a, deck, cookies, "x.png", "image/png", notAnImage) |
| reg := postJSON(t, a, deck+"/assets", |
| - fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"a"}`, hashOf(notAnImage)), cookies) |
| + fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"a"}`, fake), cookies) |
| if reg.Code != http.StatusUnprocessableEntity { |
| t.Fatalf("registering a fake PNG: status %d, want 422\n%s", reg.Code, reg.Body) |
| } |
| @@ -297,17 +356,15 @@ func TestUploadLimits(t *testing.T) { |
| // Alt text is required unless the image is marked decorative |
| // (§2.4), and the endpoint is the gate rather than the inspector. |
| - pngHash := hashOf(onePixelPNG) |
| - s2 := postJSON(t, a, deck+"/uploads", |
| - fmt.Sprintf(`{"name":"x.png","size":%d,"type":"image/png","hash":%q}`, len(onePixelPNG), pngHash), cookies) |
| - t2, _ := jsonBody(t, s2)["url"].(string) |
| - putBytes(t, a, t2, "image/png", onePixelPNG, cookies) |
| - bare := postJSON(t, a, deck+"/assets", fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":""}`, pngHash), cookies) |
| + staged := stageUpload(t, a, deck, cookies, "x.png", "image/png", onePixelPNG) |
| + bare := postJSON(t, a, deck+"/assets", fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":""}`, staged), cookies) |
| if bare.Code != http.StatusUnprocessableEntity { |
| t.Errorf("registering with no alt: status %d, want 422\n%s", bare.Code, bare.Body) |
| } |
| + // The refusal above spent nothing: the receipt is still there for |
| + // the registration that gets it right. |
| decorative := postJSON(t, a, deck+"/assets", |
| - fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"","decorative":true}`, pngHash), cookies) |
| + fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"","decorative":true}`, staged), cookies) |
| if decorative.Code != http.StatusOK { |
| t.Errorf("registering a decorative image: status %d, want 200\n%s", decorative.Code, decorative.Body) |
| } |
| @@ -359,12 +416,9 @@ func TestSvgUploadIsSanitisedBeforeItIsServed(t *testing.T) { |
| // An SVG that cannot be made safe is refused outright rather than |
| // registered half-cleaned. |
| external := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><image href="https://elsewhere.test/x.png"/></svg>`) |
| - signed := postJSON(t, a, deck+"/uploads", |
| - fmt.Sprintf(`{"name":"e.svg","size":%d,"type":"image/svg+xml","hash":%q}`, len(external), hashOf(external)), cookies) |
| - target, _ := jsonBody(t, signed)["url"].(string) |
| - putBytes(t, a, target, "image/svg+xml", external, cookies) |
| + staged := stageUpload(t, a, deck, cookies, "e.svg", "image/svg+xml", external) |
| reg := postJSON(t, a, deck+"/assets", |
| - fmt.Sprintf(`{"ref":%q,"name":"e.svg","width":1,"height":1,"alt":"a"}`, hashOf(external)), cookies) |
| + fmt.Sprintf(`{"ref":%q,"name":"e.svg","width":1,"height":1,"alt":"a"}`, staged), cookies) |
| if reg.Code != http.StatusUnprocessableEntity { |
| t.Fatalf("registering an SVG that points elsewhere: status %d, want 422\n%s", reg.Code, reg.Body) |
| } |
| @@ -382,18 +436,23 @@ func TestAssetServeRedirects(t *testing.T) { |
| token := mintAgent(t, a, cookies, "claude") |
| sqid := apiDeck(t, a, token, "Bucket deck") |
| deck := "/d/" + sqid |
| - ref := hashOf(onePixelPNG) |
| - // Signing hands back the bucket's own URL, not one of ours. |
| + // Signing hands back the bucket's own URL, not one of ours — and |
| + // at an address of the app's choosing, never the content hash the |
| + // browser sent (codex 1). |
| signed := postJSON(t, a, deck+"/uploads", |
| - fmt.Sprintf(`{"name":"x.png","size":%d,"type":"image/png","hash":%q}`, len(onePixelPNG), ref), cookies) |
| + fmt.Sprintf(`{"name":"x.png","size":%d,"type":"image/png","hash":%q}`, len(onePixelPNG), hashOf(onePixelPNG)), cookies) |
| if signed.Code != http.StatusOK { |
| t.Fatalf("sign: status %d\n%s", signed.Code, signed.Body) |
| } |
| out := jsonBody(t, signed) |
| + staged, _ := out["ref"].(string) |
| + if staged == hashOf(onePixelPNG) { |
| + t.Fatal("the app signed a PUT for the address the browser named") |
| + } |
| target, _ := out["url"].(string) |
| - if !strings.HasPrefix(target, "https://bucket.test/") { |
| - t.Errorf("url = %q, want the bucket's own presigned URL", target) |
| + if !strings.HasPrefix(target, "https://bucket.test/"+staged) { |
| + t.Errorf("url = %q, want the bucket's own presigned URL for %s", target, staged) |
| } |
| headers, _ := out["headers"].(map[string]any) |
| if headers["Content-Type"] != "image/png" { |
| @@ -402,19 +461,30 @@ func TestAssetServeRedirects(t *testing.T) { |
| // And the app refuses to be a proxy for one: the development PUT |
| // does not exist on a store that can presign. |
| - if rec := putBytes(t, a, deck+"/uploads/"+ref, "image/png", onePixelPNG, cookies); rec.Code != http.StatusNotFound { |
| + if rec := putBytes(t, a, deck+"/uploads/"+staged, "image/png", onePixelPNG, cookies); rec.Code != http.StatusNotFound { |
| t.Errorf("PUT through the app with a presigning store: status %d, want 404", rec.Code) |
| } |
| - // Put the bytes where the bucket would have (this store IS a |
| - // directory underneath), register, and fetch. |
| - if _, err := store.Put(context.Background(), bytes.NewReader(onePixelPNG), "image/png"); err != nil { |
| - t.Fatalf("seed the store: %v", err) |
| - } |
| + // Put the bytes where the bucket would have — at the staging |
| + // address the browser was handed — then register, and fetch. |
| + store.putDirect(staged, onePixelPNG) |
| record := jsonBody(t, postJSON(t, a, deck+"/assets", |
| - fmt.Sprintf(`{"ref":%q,"name":"x.png","width":10,"height":20,"alt":"A dot"}`, ref), cookies)) |
| + fmt.Sprintf(`{"ref":%q,"name":"x.png","width":10,"height":20,"alt":"A dot"}`, staged), cookies)) |
| + ref, _ := record["ref"].(string) |
| + if ref != hashOf(onePixelPNG) { |
| + t.Fatalf("ref = %q, want the hash of the bytes the app read (%s)", ref, hashOf(onePixelPNG)) |
| + } |
| registerInDeck(t, a, token, sqid, record) |
| + // Nothing was ever handed a PUT for the address the picture is |
| + // served from, which is what makes a verified object immutable |
| + // (codex 1). |
| + for _, put := range store.mintedPuts() { |
| + if put == ref { |
| + t.Errorf("a PUT was signed for the verified address %s", ref) |
| + } |
| + } |
| + |
| rec := get(t, a, deck+"/assets/"+ref, cookies...) |
| if rec.Code != http.StatusFound { |
| t.Fatalf("GET %s/assets/%s: status %d, want 302\n%s", deck, ref, rec.Code, rec.Body) |
| @@ -472,20 +542,13 @@ func TestUnverifiedRefIsNotServed(t *testing.T) { |
| sqid := apiDeck(t, a, token, "Unverified") |
| deck := "/d/" + sqid |
| - // An SVG with a script in it, PUT straight to the store under its |
| - // own hash and never registered: what a member who skipped the |
| - // verification step would hold. |
| + // An SVG with a script in it, staged and never registered: what a |
| + // member who skipped the verification step would hold. blobs.Dir is |
| + // content-addressed, so the staged bytes are sitting under their |
| + // own hash, which is the ref such a member would name in an op. |
| body := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`) |
| hash := hashOf(body) |
| - signed := postJSON(t, a, deck+"/uploads", |
| - fmt.Sprintf(`{"name":"x.svg","size":%d,"type":"image/svg+xml","hash":%q}`, len(body), hash), cookies) |
| - if signed.Code != http.StatusOK { |
| - t.Fatalf("POST %s/uploads: status %d\n%s", deck, signed.Code, signed.Body) |
| - } |
| - target, _ := jsonBody(t, signed)["url"].(string) |
| - if put := putBytes(t, a, target, "image/svg+xml", body, cookies); put.Code != http.StatusNoContent { |
| - t.Fatalf("PUT %s: status %d\n%s", target, put.Code, put.Body) |
| - } |
| + staged := stageUpload(t, a, deck, cookies, "x.svg", "image/svg+xml", body) |
| // The deck now refers to it, by an op nothing looked at. |
| apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, |
| @@ -504,7 +567,7 @@ func TestUnverifiedRefIsNotServed(t *testing.T) { |
| // registration answers with, which is what the row records. The |
| // row was what was missing, not the bytes. |
| reg := postJSON(t, a, deck+"/assets", |
| - fmt.Sprintf(`{"ref":%q,"name":"x.svg","width":1,"height":1,"alt":"x"}`, hash), cookies) |
| + fmt.Sprintf(`{"ref":%q,"name":"x.svg","width":1,"height":1,"alt":"x"}`, staged), cookies) |
| if reg.Code != http.StatusOK { |
| t.Fatalf("POST %s/assets: status %d\n%s", deck, reg.Code, reg.Body) |
| } |
diff --git a/internal/slidestest/capabilities_test.go b/internal/slidestest/capabilities_test.go| new file mode 100644 |
| index 0000000..76dd319 |
| --- /dev/null |
| +++ b/internal/slidestest/capabilities_test.go |
| @@ -0,0 +1,399 @@ |
| +package slidestest |
| + |
| +// Who may read or write what, asked of the six holes the GPT-6 (codex) |
| +// review of 2026-09-08 found in it. Each test names the finding it |
| +// pins, and each one failed before the fix in the same commit. |
| +// |
| +// They live together rather than beside their subjects because they are |
| +// one subject: a capability in this app is supposed to be a thing you |
| +// were given, not a string you can name. Every finding here was a place |
| +// where naming was enough. |
| + |
| +import ( |
| + "bufio" |
| + "bytes" |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo/db" |
| + |
| + slides "amadan.net/oficina/slides/internal/slides" |
| +) |
| + |
| +// --- codex 1: an uploaded asset stops being writable once it is verified --- |
| + |
| +// TestVerifiedAssetCannotBeOverwritten is the finding itself: a member |
| +// asked for a PUT URL naming an address that was already registered and |
| +// verified, and the store signed an unconditional PUT for it — so |
| +// replacement bytes changed what every surface served, in this deck and |
| +// in any other holding the same ref, with no document update anywhere. |
| +// |
| +// Two halves, because the two stores take different code: the |
| +// development PUT comes through this app, and a real bucket's does not. |
| +func TestVerifiedAssetCannotBeOverwritten(t *testing.T) { |
| + a := newAppWithStore(t, dirStore(t)) |
| + cookies := signedIn(t, a, allowedAddress) |
| + token := mintAgent(t, a, cookies, "claude") |
| + sqid := apiDeck(t, a, token, "Immutable") |
| + deck := "/d/" + sqid |
| + |
| + record := upload(t, a, deck, cookies, "chart.png", "image/png", onePixelPNG, "A chart") |
| + registerInDeck(t, a, token, sqid, record) |
| + ref := record["ref"].(string) |
| + mustGet(t, a, deck+"/assets/"+ref, http.StatusOK, cookies) |
| + |
| + // Asking for a URL to the verified address does not get one: what |
| + // comes back is a staging address of the app's own, and it is not |
| + // the ref. |
| + signed := postJSON(t, a, deck+"/uploads", |
| + fmt.Sprintf(`{"name":"chart.png","size":%d,"type":"image/png","hash":%q}`, len(onePixelPNG), ref), cookies) |
| + if signed.Code != http.StatusOK { |
| + t.Fatalf("sign: status %d\n%s", signed.Code, signed.Body) |
| + } |
| + if got, _ := jsonBody(t, signed)["ref"].(string); got == ref { |
| + t.Fatalf("signing answered the verified address %s", ref) |
| + } |
| + |
| + // And the PUT this app receives in development refuses an address |
| + // no receipt of its own covers, which the verified one never can be. |
| + evil := []byte("\x89PNG\r\n\x1a\nnot the chart you registered") |
| + if rec := putBytes(t, a, deck+"/uploads/"+ref, "image/png", evil, cookies); rec.Code != http.StatusNotFound { |
| + t.Errorf("PUT to a verified address: status %d, want 404\n%s", rec.Code, rec.Body) |
| + } |
| + |
| + // The picture is still the picture. |
| + rec := mustGet(t, a, deck+"/assets/"+ref, http.StatusOK, cookies) |
| + if !bytes.Equal(rec.Body.Bytes(), onePixelPNG) { |
| + t.Errorf("the verified asset changed under it: %d bytes, want the %d that went up", |
| + rec.Body.Len(), len(onePixelPNG)) |
| + } |
| +} |
| + |
| +// TestStagingAddressIsNeverTheClientsChoice: two signings of the same |
| +// file get two different addresses, and neither is the content hash the |
| +// browser sent. That is what makes "an address a browser can name is an |
| +// address a browser can overwrite" stop being a sentence about this app. |
| +func TestStagingAddressIsNeverTheClientsChoice(t *testing.T) { |
| + a := newAppWithStore(t, dirStore(t)) |
| + cookies := signedIn(t, a, allowedAddress) |
| + deck := createDeck(t, a, cookies, "Staging") |
| + hash := hashOf(onePixelPNG) |
| + |
| + seen := map[string]bool{} |
| + for i := 0; i < 2; i++ { |
| + signed := postJSON(t, a, deck+"/uploads", |
| + fmt.Sprintf(`{"name":"x.png","size":%d,"type":"image/png","hash":%q}`, len(onePixelPNG), hash), cookies) |
| + if signed.Code != http.StatusOK { |
| + t.Fatalf("sign %d: status %d\n%s", i, signed.Code, signed.Body) |
| + } |
| + key, _ := jsonBody(t, signed)["ref"].(string) |
| + if key == hash { |
| + t.Fatalf("sign %d answered the address the browser named", i) |
| + } |
| + if seen[key] { |
| + t.Fatalf("two signings answered the same staging address %s", key) |
| + } |
| + seen[key] = true |
| + } |
| +} |
| + |
| +// --- codex 2: knowing a ref is not permission to read it --- |
| + |
| +// TestRegisteringAnotherDecksRefIsRefused is the finding: registration |
| +// checked access to the DESTINATION deck and then fetched whatever ref |
| +// it was handed, writing that deck's verification row. So a member who |
| +// knew another deck's content address — and a ref is a content address, |
| +// which is the whole reason serveAsset checks two things — could |
| +// register it into a deck of their own and pass both checks. |
| +// |
| +// Now registration only ever looks at a staged upload of this |
| +// principal's, for this deck. A ref alone reaches nothing. |
| +func TestRegisteringAnotherDecksRefIsRefused(t *testing.T) { |
| + a := newAppWithStore(t, dirStore(t)) |
| + cookies := signedIn(t, a, allowedAddress) |
| + token := mintAgent(t, a, cookies, "claude") |
| + |
| + theirs := apiDeck(t, a, token, "Theirs") |
| + mine := apiDeck(t, a, token, "Mine") |
| + |
| + record := upload(t, a, "/d/"+theirs, cookies, "secret.png", "image/png", onePixelPNG, "A chart") |
| + registerInDeck(t, a, token, theirs, record) |
| + ref := record["ref"].(string) |
| + mustGet(t, a, "/d/"+theirs+"/assets/"+ref, http.StatusOK, cookies) |
| + |
| + // The other deck's ref, named at the other deck's owner's own |
| + // second deck. Not a 500, and not a verification: nothing was |
| + // staged here. |
| + reg := postJSON(t, a, "/d/"+mine+"/assets", |
| + fmt.Sprintf(`{"ref":%q,"name":"secret.png","width":1,"height":1,"alt":"a"}`, ref), cookies) |
| + if reg.Code != http.StatusNotFound { |
| + t.Fatalf("registering another deck's ref: status %d, want 404\n%s", reg.Code, reg.Body) |
| + } |
| + |
| + // Even with the document made to refer to it, which is an op |
| + // nothing checks, the bytes stay out of reach. |
| + apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+mine+"/ops", token, |
| + fmt.Sprintf(`{"ops":[{"op":"register_asset","id":"borrowed1234","ref":%q,"name":"x.png","width":1,"height":1,"alt":"x"}]}`, ref), |
| + http.StatusOK) |
| + if rec := get(t, a, "/d/"+mine+"/assets/"+ref, cookies...); rec.Code != http.StatusNotFound { |
| + t.Errorf("a borrowed ref was served: status %d, want 404", rec.Code) |
| + } |
| + share := mintShareLink(t, a, "/d/"+mine, cookies) |
| + if rec := get(t, a, "/s/"+share+"/assets/"+ref); rec.Code != http.StatusNotFound { |
| + t.Errorf("a borrowed ref was served over a share link: status %d, want 404", rec.Code) |
| + } |
| +} |
| + |
| +// TestAStagedUploadBelongsToOneDeck: a receipt is not transferable |
| +// either. An upload staged for one deck cannot be registered into |
| +// another, however firmly the person holds both. |
| +func TestAStagedUploadBelongsToOneDeck(t *testing.T) { |
| + a := newAppWithStore(t, dirStore(t)) |
| + cookies := signedIn(t, a, allowedAddress) |
| + one := createDeck(t, a, cookies, "One") |
| + two := createDeck(t, a, cookies, "Two") |
| + |
| + staged := stageUpload(t, a, one, cookies, "x.png", "image/png", onePixelPNG) |
| + reg := postJSON(t, a, two+"/assets", |
| + fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"a"}`, staged), cookies) |
| + if reg.Code != http.StatusNotFound { |
| + t.Errorf("registering deck one's staged upload into deck two: status %d, want 404\n%s", reg.Code, reg.Body) |
| + } |
| + // And it is single use: registered once in its own deck, the same |
| + // receipt is spent. |
| + first := postJSON(t, a, one+"/assets", |
| + fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"a"}`, staged), cookies) |
| + if first.Code != http.StatusOK { |
| + t.Fatalf("registering in its own deck: status %d, want 200\n%s", first.Code, first.Body) |
| + } |
| + again := postJSON(t, a, one+"/assets", |
| + fmt.Sprintf(`{"ref":%q,"name":"x.png","width":1,"height":1,"alt":"a"}`, staged), cookies) |
| + if again.Code != http.StatusNotFound { |
| + t.Errorf("registering a spent receipt: status %d, want 404\n%s", again.Code, again.Body) |
| + } |
| +} |
| + |
| +// --- codex 5: following a presentation stays behind the share link --- |
| + |
| +// TestFollowStaysBehindTheShareLink is the finding: following handed |
| +// the audience the presentation session sqid, and every later read went |
| +// to /p/{session}, which asks only for a live presentation. So revoking |
| +// the share link left the recipient reading fresh deck content until |
| +// the talk ended — a capability the person who granted it could not |
| +// take back. |
| +func TestFollowStaysBehindTheShareLink(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + path := sampleDeck(t, a, cookies) |
| + link := mintShareLink(t, a, path, cookies) |
| + share := "/s/" + link |
| + |
| + session, remote := startPresenting(t, a, path, cookies) |
| + command(t, a, session, "next", remote, "", nil) |
| + |
| + follow := mustGet(t, a, share+"?follow=1", http.StatusOK, nil).Body.String() |
| + if strings.Contains(follow, session) { |
| + t.Errorf("the follow surface carries the session sqid %s:\n%s", session, follow) |
| + } |
| + if strings.Contains(follow, "/p/") { |
| + t.Errorf("the follow surface carries a /p/ URL:\n%s", follow) |
| + } |
| + // The two URLs its script uses, both the share link's own. |
| + if !strings.Contains(follow, `data-stream="`+share+`/stream"`) { |
| + t.Errorf("the follow surface does not stream from the share link:\n%s", follow) |
| + } |
| + if !strings.Contains(follow, `data-fragment="`+share+`?follow=1&slide={n}&fragment=1"`) { |
| + t.Errorf("the follow surface does not fetch fragments from the share link:\n%s", follow) |
| + } |
| + |
| + // And they work: the fragment is a bare slide, the stream speaks. |
| + fragment := mustGet(t, a, share+"?follow=1&slide=3&fragment=1", http.StatusOK, nil).Body.String() |
| + if !strings.HasPrefix(strings.TrimSpace(fragment), "<section slides-slide") { |
| + t.Errorf("the follow fragment is not a bare slide:\n%s", fragment) |
| + } |
| + if strings.Contains(fragment, "<html") { |
| + t.Errorf("the follow fragment carries the whole page:\n%s", fragment) |
| + } |
| + |
| + 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+share+"/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 share link's stream: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("GET %s/stream: status %d, want 200", share, resp.StatusCode) |
| + } |
| + if got := resp.Header.Get("Content-Type"); got != "text/event-stream" { |
| + t.Fatalf("the share link's stream is %q, want text/event-stream", got) |
| + } |
| + if data := nextSlideEvent(t, bufio.NewReader(resp.Body)); !strings.Contains(data, `"slide":2`) { |
| + t.Errorf("the connect event is %q, want the presenter's slide 2", data) |
| + } |
| + resp.Body.Close() |
| + |
| + // Revoked, mid-talk, and every one of those doors shuts — which is |
| + // the whole point of the finding. |
| + revokeShareLink(t, a, path, cookies) |
| + for _, target := range []string{ |
| + share, |
| + share + "?follow=1", |
| + share + "?follow=1&slide=3&fragment=1", |
| + share + "/stream", |
| + } { |
| + if rec := get(t, a, target); rec.Code != http.StatusNotFound { |
| + t.Errorf("GET %s after revoking: status %d, want 404", target, rec.Code) |
| + } |
| + } |
| + // The talk is still live for the projector, which is the point of |
| + // the projector's own capability being a different thing. |
| + mustGet(t, a, "/p/"+session, http.StatusOK, nil) |
| +} |
| + |
| +// TestFollowingWithNothingLiveEndsTheStream: the share link's stream |
| +// answers even when there is no talk, with the one event that tells a |
| +// following page to go back to reading the deck. Nothing to follow is |
| +// the ordinary end of a session, not an error. |
| +func TestFollowingWithNothingLiveEndsTheStream(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + path := sampleDeck(t, a, cookies) |
| + link := mintShareLink(t, a, path, cookies) |
| + |
| + rec := mustGet(t, a, "/s/"+link+"/stream", http.StatusOK, nil) |
| + if got := rec.Header().Get("Content-Type"); got != "text/event-stream" { |
| + t.Fatalf("Content-Type = %q, want text/event-stream", got) |
| + } |
| + if !strings.Contains(rec.Body.String(), `"ended":true`) { |
| + t.Errorf("the stream with nothing live said %q, want an ended event", rec.Body) |
| + } |
| + // An unknown token is a 404 here like everywhere else. |
| + if rec := get(t, a, "/s/nosuchtoken/stream"); rec.Code != http.StatusNotFound { |
| + t.Errorf("an unknown share token's stream: status %d, want 404", rec.Code) |
| + } |
| +} |
| + |
| +// revokeShareLink turns off the newest link on a deck, through the |
| +// screen a person uses. share_test.go finds the form the same way; this |
| +// is that walk as a helper, because two tests here need it. |
| +func revokeShareLink(t *testing.T, a *testApp, deckPath string, cookies []*http.Cookie) { |
| + t.Helper() |
| + page := mustGet(t, a, deckPath+"/share", http.StatusOK, cookies).Body.String() |
| + m := regexp.MustCompile(`/share/(\d+)/revoke`).FindStringSubmatch(page) |
| + if m == nil { |
| + t.Fatalf("the share page carries no revoke form:\n%s", page) |
| + } |
| + if rec := post(t, a, deckPath+"/share/"+m[1]+"/revoke", nil, cookies...); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST revoke: status %d, want 303\n%s", rec.Code, rec.Body) |
| + } |
| +} |
| + |
| +// --- codex 6: removing an address ends the authority it had --- |
| + |
| +// TestRemovingAnAddressEndsItsSessionsAndItsAgents is the finding: the |
| +// allowlist gated the minting of a session and nothing else. An |
| +// existing session resolved its user row without asking whether the |
| +// address was still admitted, rastrillo's RequireSession asks nothing, |
| +// and an agent needed only an unrevoked token and an operator row. So |
| +// taking somebody off SLIDES_ALLOWED_ADDRESSES and restarting removed |
| +// nothing they already had, including an agent's writes to team decks. |
| +// |
| +// The restart is real: a second app is built over the SAME database |
| +// file with the address gone from the list, which is exactly what |
| +// `carlos env` plus a recycle does. |
| +func TestRemovingAnAddressEndsItsSessionsAndItsAgents(t *testing.T) { |
| + cfg, mailer := newConfig(t) |
| + d := openTestDB(t, cfg) |
| + |
| + before := newAppOver(t, d, cfg, mailer) |
| + cookies := signedIn(t, before, allowedAddress) |
| + token := mintAgent(t, before, cookies, "claude") |
| + sqid := apiDeck(t, before, token, "Leaving") |
| + deck := "/d/" + sqid |
| + // Both credentials work while the address is on the list. |
| + mustGet(t, before, deck, http.StatusOK, cookies) |
| + apiJSON(t, before, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK) |
| + |
| + // Somebody presenting on the way out, so the lifecycle question the |
| + // finding asks — what happens to outstanding capabilities — has an |
| + // answer under test rather than only in the spec. |
| + session, _ := startPresenting(t, before, deck, cookies) |
| + link := mintShareLink(t, before, deck, cookies) |
| + mustGet(t, before, "/p/"+session, http.StatusOK, nil) |
| + |
| + // The offboarding: the address comes off the list, and the app |
| + // restarts over the same data. |
| + gone, _ := newConfig(t) |
| + gone.AllowedAddresses = []string{"someone.else@example.test"} |
| + after := newAppOver(t, d, gone, mailer) |
| + |
| + // The session cookie is the same cookie, and it reaches nothing. |
| + rec := get(t, after, deck, cookies...) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("a removed address's session: status %d, want 303 to the sign-in page\n%s", rec.Code, rec.Body) |
| + } |
| + if loc := rec.Header().Get("Location"); loc != "/signin?err=removed" { |
| + t.Errorf("Location = %q, want /signin?err=removed", loc) |
| + } |
| + // And it is told so in words from the catalog, not left to guess. |
| + page := mustGet(t, after, "/signin?err=removed", http.StatusOK, nil).Body.String() |
| + if !strings.Contains(page, "has been removed") { |
| + t.Errorf("the sign-in page does not say the access was removed:\n%s", page) |
| + } |
| + // A write is a 403 rather than a redirect: there is no page to send |
| + // a fetch or a form POST to. |
| + if rec := post(t, after, deck+"/settings", url.Values{"title": {"Renamed"}}, cookies...); rec.Code != http.StatusForbidden { |
| + t.Errorf("a removed address's POST: status %d, want 403", rec.Code) |
| + } |
| + // Really revoked, not merely un-cookied: signing in again is the |
| + // only way back, and the allowlist refuses that too. |
| + if rec := get(t, after, "/", cookies...); rec.Code != http.StatusSeeOther { |
| + t.Errorf("a removed address's home: status %d, want 303", rec.Code) |
| + } |
| + |
| + // The agent token, which is the path that survives a browser being |
| + // signed out, fails closed on the operator rather than on itself. |
| + apiJSON(t, after, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusUnauthorized) |
| + apiJSON(t, after, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, |
| + `{"ops":[{"op":"set_meta","title":"Written by a removed operator"}]}`, http.StatusUnauthorized) |
| + |
| + // Their live talk is over: a presentation is a public URL that |
| + // reads the deck out, and a removed person does not get to leave |
| + // one running. |
| + if rec := get(t, after, "/p/"+session); rec.Code != http.StatusGone { |
| + t.Errorf("a removed presenter's session: status %d, want 410", rec.Code) |
| + } |
| + // Their share links are NOT revoked: a link belongs to the deck, |
| + // which belongs to the team, and one member leaving is not an |
| + // outage for everybody holding a link to a team deck. Spec §6.1 |
| + // says so out loud. |
| + mustGet(t, after, "/s/"+link, http.StatusOK, nil) |
| +} |
| + |
| +// newAppOver is newAppWith over a database that already exists — the |
| +// closest thing to a restart there is. The harness's own newAppWith |
| +// opens a fresh file per app, which is what every other test wants and |
| +// exactly what an offboarding test cannot have. |
| +func newAppOver(t *testing.T, d *db.DB, cfg slides.Config, mailer *fakeMailer) *testApp { |
| + t.Helper() |
| + mux, a, err := slides.New(d, cfg) |
| + if err != nil { |
| + t.Fatalf("slides.New: %v", err) |
| + } |
| + t.Cleanup(func() { a.Close() }) |
| + return &testApp{Handler: localised(t, mux), mailer: mailer, db: d, app: a} |
| +} |
diff --git a/internal/slidestest/present_test.go b/internal/slidestest/present_test.go| index 0230c95..b482905 100644 |
| --- a/internal/slidestest/present_test.go |
| +++ b/internal/slidestest/present_test.go |
| @@ -246,6 +246,17 @@ func TestRemoteCommandAdvancesSlideAndPublishes(t *testing.T) { |
| t.Errorf("the blackout is carried by colour alone, with no words:\n%s", surface) |
| } |
| + // The two URLs the script uses come down as attributes rather than |
| + // being built in the browser (codex 5): on the projector they are |
| + // the session's own, and static/present.js reads them instead of |
| + // assembling a /p/ path out of a sqid it may not have. |
| + if !strings.Contains(surface, `data-stream="/p/`+session+`/stream"`) { |
| + t.Errorf("the surface does not name its own stream:\n%s", surface) |
| + } |
| + if !strings.Contains(surface, `data-fragment="/p/`+session+`?slide={n}&fragment=1"`) { |
| + t.Errorf("the surface does not name its own fragment URL:\n%s", surface) |
| + } |
| + |
| // The fragment is the same slide with no page around it — what the |
| // script swaps in, and never a nested layout. |
| fragment := mustGet(t, a, "/p/"+session+"?slide=2&fragment=1", http.StatusOK, nil).Body.String() |