oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1# The Slides drive API
2
3This is the surface a program uses to build a deck, look at what it
4made, and put it on a projector. A person's browser never touches it β€”
5the screens are a separate thing, and neither one accepts the other's
6credential.
7
8They do write the same document, though. Since slice 2 there is a
9browser editor at `/d/{sqid}/edit`, and an op you post here and a
10keystroke somebody types there land in one Yjs document as one ordered
11log. Your `seq` moves when they type; theirs moves when you post. If you
12want to know how a person's tab keeps up, "The live-sync wire" below
13describes what the editor speaks.
14
15Everything below assumes the instance is at `https://slides.example`.
16Replace it with your own.
17
18## Getting a token
19
20A token belongs to an **agent**, and an agent belongs to a person β€” its
21operator. Sign in, open **Agents** from the account menu, give the
22agent a name, and press Create. The token is shown once, on the page
23Create answers with, and never again: only its fingerprint is stored,
24so there is nothing to show you a second time, and it is in no URL to
25go back to. Copy it before you leave that page; lose it and you revoke
26the agent and make another.
27
28A name is 2 to 32 characters of lowercase letters, digits and hyphens
29β€” `claude`, `codex`, `release-bot`. It has to be different from every
30other agent you have that is still live.
31
32The token looks like `sl_` followed by forty letters and digits. Send
33it on every request:
34
35```
36Authorization: Bearer sl_...
37```
38
39Two rules worth knowing before you build anything on this:
40
41- **The API never reads a session cookie**, and the screens never read
42 a bearer. A request to `/api/v1/...` with a perfectly good session
43 and no token is a 401.
44- **An agent sees exactly what its operator sees.** It can read and
45 write every deck that person can, and nothing else. It is not an
46 administrator and it cannot mint another token.
47
48Every change an agent makes is recorded against the agent, not the
49person: the actor is `agent:<id>`, and the deck list shows it as
50πŸ€– `claude~you@example.com`. Revoking the agent stops the token
51immediately; the history it already wrote keeps its name.
52
53A token also stops working when its **operator** does. If the person
54who minted it is taken off `SLIDES_ALLOWED_ADDRESSES`, every request
55this token makes is a `401` from then on β€” the same 401 a revoked token
56gets, and for the same reason: an agent sees exactly what its operator
57sees, and an operator who is no longer admitted here sees nothing. This
58is checked on every request rather than at minting, so an offboarding
59takes effect without anybody hunting down tokens.
60
61## Shapes
62
63Requests with a body send JSON and say so:
64`Content-Type: application/json`. Anything else is a 415. A body over
651 MiB is a 413.
66
67Every answer with a body is JSON. Every error is:
68
69```json
70{"error": "invalid", "message": "op 1: no slide \"abc\"", "op": 1}
71```
72
73`error` is a fixed English identifier β€” branch on it. `message` and
74`op` are only on some of them.
75
76| Status | `error` | What happened |
77|--------|--------------------------|---------------|
78| 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. |
79| 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. |
80| 405 | `method_not_allowed` | Right path, wrong verb. |
81| 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. |
82| 413 | `too_large` | The body is over 1 MiB. |
83| 415 | `unsupported_media_type` | The body is not `application/json`. |
84| 422 | `invalid` | The request was read and refused. `message` says why; `op` is the index of the operation at fault, when the failure was about one. A body carrying anything after its JSON value is refused here too, rather than half-read. |
85| 429 | `busy` | You already have four exports running. Wait for one to finish; nothing was started. |
86| 500 | `internal` | Our fault. The server log has a matching line. |
87
88---
89
90## Endpoints
91
92### `GET /api/v1/decks` β€” every deck you can open
93
94```sh
95curl -H "Authorization: Bearer $SLIDES_TOKEN" \
96 https://slides.example/api/v1/decks
97```
98
99```json
100{"decks": [
101 {"sqid": "k7m2xq9dwb", "title": "Q3 review", "slides": 12,
102 "rung": "private", "updated_at": "2026-09-07T09:41:02Z", "seq": 34}
103]}
104```
105
106Most recently edited first. `sqid` is the deck's public name and what
107every other endpoint takes. `rung` is who can open it: `private`,
108`granted` or `team`. `seq` is the deck's version β€” it moves on every
109write and on nothing else.
110
111### `POST /api/v1/decks` β€” make one
112
113```sh
114curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \
115 -H "Content-Type: application/json" \
116 -d '{"title": "Q3 review", "theme": "ink"}' \
117 https://slides.example/api/v1/decks
118```
119
120```json
121{"sqid": "k7m2xq9dwb"}
122```
123
124201. `title` is required; `theme` is one of `paper`, `ink`, `sunset`,
125`forest`, `slate`, `candy`, and defaults to `paper`. The deck is owned
126by your operator and starts on the `private` rung, exactly as one made
127from the screen does β€” and like one made from the screen, it opens
128with a title slide already on it.
129
130### `GET /api/v1/decks/{sqid}` β€” the whole document
131
132```sh
133curl -H "Authorization: Bearer $SLIDES_TOKEN" \
134 https://slides.example/api/v1/decks/k7m2xq9dwb
135```
136
137```json
138{"sqid": "k7m2xq9dwb", "seq": 34,
139 "meta": {"title": "Q3 review", "theme": "ink", "accent": null,
140 "fonts": null, "aspect": "16:9"},
141 "slides": [{"id": "hq3ktna7vc2m", "layout": "flow", "template": "title",
142 "background": {"kind": "theme", "value": ""},
143 "phone": "inherit", "transition": "none",
144 "notes": [], "blocks": [...]}],
145 "assets": {}}
146```
147
148The answer carries `ETag: "34"` β€” the seq, quoted. Send it back and an
149unchanged deck is a 304 with no body, which costs the server nothing:
150
151```sh
152curl -H "Authorization: Bearer $SLIDES_TOKEN" \
153 -H 'If-None-Match: "34"' \
154 https://slides.example/api/v1/decks/k7m2xq9dwb
155```
156
157Poll this rather than re-fetching. It is how you tell whether anybody β€”
158a person in the editor, another agent β€” has moved the deck since you
159last looked.
160
161### `POST /api/v1/decks/{sqid}/ops` β€” change it
162
163Everything that changes a deck goes through here, as a batch applied in
164order inside one transaction.
165
166```sh
167curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \
168 -H "Content-Type: application/json" \
169 -d '{"ops": [
170 {"op": "add_slide", "layout": "flow", "template": "section"},
171 {"op": "set_meta", "title": "Q3 review, final"}
172 ]}' \
173 https://slides.example/api/v1/decks/k7m2xq9dwb/ops
174```
175
176```json
177{"seq": 35, "ids": {"0": "hq3ktna7vc2m"}}
178```
179
180**You do not have to invent ids.** `add_slide`, `add_block` and
181`duplicate_slide` each need one, and if you leave it out the server
182mints it and hands it back keyed by the operation's index β€” `"0"` is
183the first operation you sent. Supply your own and it is used as given.
184For `duplicate_slide` the returned id is the new slide's; the copied
185blocks get ids too, and you read them back off the deck.
186
187**A refused batch changes nothing.** Not the good operations in front
188of the bad one, not the seq β€” the whole batch is checked before the
189first byte is written:
190
191```json
192{"error": "invalid", "op": 1, "message": "op 1: no slide \"nosuchslide\""}
193```
194
195If two writers land at once you may get a `409 conflict`. The batch
196applied nothing. Re-read the deck and send it again if it still makes
197sense.
198
199### `GET /api/v1/decks/{sqid}/slides/{id}.html` β€” look at a slide
200
201```sh
202curl -H "Authorization: Bearer $SLIDES_TOKEN" \
203 'https://slides.example/api/v1/decks/k7m2xq9dwb/slides/hq3ktna7vc2m.html?vp=phone'
204```
205
206One `<section>` of HTML β€” the same markup the viewer renders β€” and
207nothing around it. `vp=phone` renders the phone layout; leave it off
208for the desktop one. This is how you check what you built without a
209browser.
210
211### `GET /api/v1/decks/{sqid}/slides/{id}.svg` β€” look at it as a picture
212
213```sh
214curl -H "Authorization: Bearer $SLIDES_TOKEN" \
215 'https://slides.example/api/v1/decks/k7m2xq9dwb/slides/hq3ktna7vc2m.svg' \
216 -o slide.svg
217```
218
219The slide as one 1920 by 1080 SVG: the same paint list the PDF is made
220of, so what you see here is what the export will put on a page. Text is
221`<text>` with a `<tspan>` per run at its measured position, pictures are
222embedded as data URIs where they can be, and nothing in the file
223fetches anything.
224
225`?w=` is a rendering hint and is in the `ETag`; it changes nothing about
226the bytes today. The `ETag` is `(seq, slide, w)`, so send
227`If-None-Match` and you will get a `304` until somebody edits the deck.
228
229It is not a substitute for reading the document: a picture is for
230looking at what you made, and `GET /api/v1/decks/{sqid}` is for knowing
231what is in it.
232
233### `POST /api/v1/decks/{sqid}/export` β€” make a PDF
234
235```sh
236curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \
237 -H "Content-Type: application/json" -d '{"notes": true}' \
238 https://slides.example/api/v1/decks/k7m2xq9dwb/export
239```
240
241```json
242{"job": "9Qb1t0RmS3aXk7Zt2Fv8Lw",
243 "status_url": "/api/v1/decks/k7m2xq9dwb/export/9Qb1t0RmS3aXk7Zt2Fv8Lw",
244 "pdf_url": "/api/v1/decks/k7m2xq9dwb/export/9Qb1t0RmS3aXk7Zt2Fv8Lw.pdf"}
245```
246
247`202`, because nothing is ready yet: an export is a job, and what comes
248back is where to watch it and where the file will be. The body is
249optional; `{"notes": true}` adds the speaker notes as pages of their own
250after each slide.
251
252A deck over 200 slides is a `422` before anything starts. Four exports
253running at once for one operator is a `429` β€” wait for one to finish.
254
255### `GET /api/v1/decks/{sqid}/export/{job}` β€” how is it going
256
257```json
258{"state": "running", "progress": "slide 3 of 8"}
259```
260
261```json
262{"state": "done", "progress": "slide 8 of 8", "warnings": [
263 {"kind": "asset", "slide": "hq3ktna7vc2m",
264 "detail": "image \"a1b2c3d4e5f6\" is an SVG with a stylesheet, which cannot be nested into a page; left out of the PDF"}]}
265```
266
267`state` is `running`, `done` or `failed`, and it is the word to branch
268on. A `failed` job carries a `message` written for a person. A `done`
269one carries `warnings`: everything worth saying about the file β€” a block
270that ran past its box, a picture that could not go in, a character no
271embedded face can draw β€” each naming the slide it is about. An empty
272list means the PDF is the deck.
273
274Poll it, a second or two apart. `"expired": true` on a `done` job means
275the file is no longer there; make another. This endpoint answers `done`
276for as long as the file does β€” the whole hour β€” so it and the `.pdf`
277below never disagree about whether an export still exists.
278
279### `GET /api/v1/decks/{sqid}/export/{job}.pdf` β€” take the file
280
281```sh
282curl -L -H "Authorization: Bearer $SLIDES_TOKEN" \
283 https://slides.example/api/v1/decks/k7m2xq9dwb/export/9Qb1t0RmS3aXk7Zt2Fv8Lw.pdf \
284 -o deck.pdf
285```
286
287The bytes, as `application/pdf` β€” or a `302` to a presigned URL when the
288instance has an object store, which is why the `-L` is there.
289
290**The file lives for an hour.** With no bucket bound it is held in the
291instance's memory, which is also why it is capped: an export bigger than
292the instance will hold fails with a message saying so.
293
294An export belongs to the operator, not to the agent that started it β€” so
295a person can watch it from the screen and any of that person's agents can
296fetch it. Another operator's job is a `404`, exactly as another
297operator's deck is.
298
299### `POST /api/v1/decks/{sqid}/present` β€” start a talk
300
301```sh
302curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \
303 https://slides.example/api/v1/decks/k7m2xq9dwb/present
304```
305
306```json
307{"session": "p4wq7nzk2h", "code": "K7MQ2X",
308 "present_url": "https://slides.example/p/p4wq7nzk2h",
309 "notes_url": "https://slides.example/p/p4wq7nzk2h/notes?t=...",
310 "remote_url": "https://slides.example/r/K7MQ2X?t=..."}
311```
312
313Call it again on the same deck and you get the same session back, so a
314projector already showing `present_url` keeps showing the same talk.
315What you do get fresh each time is the remote token, which means the
316previous `notes_url` and `remote_url` stop working.
317
318Treat the three URLs differently. `present_url` is safe to put on a
319screen in front of a room β€” it only watches. `notes_url` and
320`remote_url` carry the token that *drives* the deck, so they are
321secrets: send them to the presenter, not to the audience.
322
323`present_url` is a *projector* address, though, and not an audience
324one. It lives as long as the talk and no revocation reaches it, so
325sending it to a room is sending something you cannot take back. For an
326audience, mint a share link and let them follow: `/s/{token}?follow=1`
327shows the presenter's own slide behind a capability that can be
328revoked, and everything that page then reads β€” its slide fragments, its
329stream at `/s/{token}/stream`, its pictures β€” resolves the token again
330on every request. Revoke the link mid-talk and it stops, immediately.
331
332Every command against a session is logged as `remote:{session}` rather
333than as whoever started it. The remote token is transferable by design
334β€” that is the feature β€” so the server cannot say who is holding it, and
335Β§6.2's "every write names its real actor" is kept by naming the
336capability and logging the issuer beside it.
337
338This JSON is the only place the pairing block exists for a session an
339agent started. Press Present in a browser and the presenter view shows
340the code and a QR to scan it with; that block belongs to whoever
341started the talk, and for your session that is the agent, not the
342person opening the page β€” so `notes_url` renders the talk and the
343controls, and no code and no QR. Pass on what you were given: the
344`code` for somebody to type into `/r/`, or `remote_url` outright. It is
345not recoverable afterwards, and calling present again mints a fresh
346token, which retires the URLs you are holding.
347
348---
349
350## The operations
351
352Every operation is an object with an `op` key. Ids are twelve lowercase
353base32 characters and are minted by the server when you omit them. The
354vocabularies:
355
356- **layout**: `flow`, `canvas`
357- **template** (flow slides): `title`, `title-body`, `two-column`,
358 `section`, `image-full`, `quote`, `blank`
359- **region** (blocks on a flow slide): `heading`, `body`, `aside`, `media`
360- **block type**: `heading`, `text`, `list`, `quote`, `image`,
361 `columns`, `code`, `shape`, `table`, `divider`, `spacer`
362- **background kind**: `theme`, `color`, `image`
363- **transition**: `none`, `fade` β€” **slide phone**: `inherit`, `reflow`
364- **size**: `s`, `m`, `l`
365
366`after` says where something goes: absent appends, `null` puts it
367first, an id puts it after that one. A `box` is `{x, y, w, h}` as
368fractions of the 16:9 stage. Text arrives as Markdown and is converted
369server-side.
370
371Each example below is one batch, so you can paste any of them straight
372into the `ops` body above. `$D` is the deck sqid, `$S` a slide id and
373`$B` a block id.
374
375### Deck
376
377**`set_meta`** β€” the deck's title, theme, accent and font pair. Send
378`null` for `accent` or `fonts` to clear one.
379
380```sh
381curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" -H "Content-Type: application/json" \
382 -d '{"ops":[{"op":"set_meta","title":"Q3 review","theme":"ink","accent":"#c0392b",
383 "fonts":{"heading":"Inter","body":"Inter"}}]}' \
384 https://slides.example/api/v1/decks/$D/ops
385```
386
387### Slides
388
389**`add_slide`** β€” a new slide. `id` optional (minted and returned).
390
391```sh
392-d '{"ops":[{"op":"add_slide","layout":"flow","template":"title-body","after":null}]}'
393```
394
395**`remove_slide`** β€” delete one, and everything on it.
396
397```sh
398-d '{"ops":[{"op":"remove_slide","id":"'"$S"'"}]}'
399```
400
401**`move_slide`** β€” reorder. `"after": null` moves it to the front. The
402view's `slides` come back in the new order; under the hood the slide
403keeps its identity and gets an `order` written on it, exactly as
404`move_block` does, so somebody typing on that slide at the same moment
405loses nothing (spec Β§2.1, Β§2.2).
406
407```sh
408-d '{"ops":[{"op":"move_slide","id":"'"$S"'","after":null}]}'
409```
410
411**`duplicate_slide`** β€” copy a slide and everything on it. `new_id` and
412`new_block_ids` are both optional; leave them out and the server mints
413as many block ids as the slide has.
414
415```sh
416-d '{"ops":[{"op":"duplicate_slide","id":"'"$S"'"}]}'
417```
418
419**`set_slide`** β€” a slide's own settings.
420
421```sh
422-d '{"ops":[{"op":"set_slide","id":"'"$S"'","layout":"flow","template":"quote",
423 "background":{"kind":"color","value":"#101418"},
424 "phone":"reflow","transition":"fade"}]}'
425```
426
427**`set_notes`** β€” the speaker notes, as Markdown.
428
429```sh
430-d '{"ops":[{"op":"set_notes","id":"'"$S"'","markdown":"Pause here. Ask about hiring."}]}'
431```
432
433### Blocks
434
435**`add_block`** β€” put something on a slide. `id` optional. On a flow
436slide give a `region`; on a canvas slide give a `box` and optionally
437`z`.
438
439```sh
440-d '{"ops":[{"op":"add_block","slide":"'"$S"'","type":"heading",
441 "region":"heading","props":{"level":1},"markdown":"Revenue"}]}'
442```
443
444```sh
445-d '{"ops":[{"op":"add_block","slide":"'"$S"'","type":"image",
446 "box":{"x":0.1,"y":0.2,"w":0.5,"h":0.4},"z":2,
447 "props":{"asset":"a1"}}]}'
448```
449
450**`remove_block`** β€” take one off.
451
452```sh
453-d '{"ops":[{"op":"remove_block","slide":"'"$S"'","id":"'"$B"'"}]}'
454```
455
456**`move_block`** β€” reorder within its slide. The view's `blocks` come
457back in the new order; under the hood the block keeps its identity and
458gets an `order` written on it, so somebody typing into it at the same
459moment loses nothing (spec Β§2.2).
460
461```sh
462-d '{"ops":[{"op":"move_block","slide":"'"$S"'","id":"'"$B"'","after":null}]}'
463```
464
465A `columns` block's `props.children` names blocks on the same slide,
466each at most once and each in at most one `children` list on the slide,
467and never a block the columns block already sits under (spec Β§2.4). An
468op that would share a child or close a cycle is refused with a message
469saying which id.
470
471**`set_block`** β€” its props, region, box or z. `null` for `region` or
472`box` clears that one.
473
474```sh
475-d '{"ops":[{"op":"set_block","slide":"'"$S"'","id":"'"$B"'",
476 "props":{"level":2},"region":"body","box":null,"z":1}]}'
477```
478
479**`set_text`** β€” replace the words in a text-bearing block (`heading`,
480`text`, `list`, `quote`, `code`, `shape`).
481
482```sh
483-d '{"ops":[{"op":"set_text","slide":"'"$S"'","id":"'"$B"'",
484 "markdown":"- Up 14%\n- Two new regions"}]}'
485```
486
487**`set_phone`** β€” a block's phone layer: additions to the desktop
488layout, never a replacement for it.
489
490```sh
491-d '{"ops":[{"op":"set_phone","slide":"'"$S"'","id":"'"$B"'",
492 "phone":{"hidden":false,"order":0,"size":"l"}}]}'
493```
494
495### Assets
496
497**`register_asset`** β€” name an image the deck can use. The bytes go up
498separately (see "Getting the bytes there" below); this records the
499result. `ref` is the object store's address for those bytes β€” a hex
500SHA-256, because the store is content-addressed.
501
502```sh
503-d '{"ops":[{"op":"register_asset","id":"a1",
504 "ref":"9f2c1e...ab","name":"roadmap.png","width":1600,"height":900,
505 "alt":"Three phases, each with a date"}]}'
506```
507
508**`remove_asset`** β€” drop the registration. The bytes stay in the store,
509which is content-addressed and shared.
510
511```sh
512-d '{"ops":[{"op":"remove_asset","id":"a1"}]}'
513```
514
515---
516
517## Getting the bytes there
518
519Images never travel through this API. They go browser-to-bucket, and
520three endpoints on the *signed-in* surface arrange it. They take a
521session cookie rather than a bearer token, because they are what the
522editor's own upload uses.
523
524**An agent cannot add a picture today.** It can name a ref in a
525`register_asset` op, and the document will hold it, but no surface will
526serve those bytes: serving requires a verification row, and a
527verification row is only ever written by the registration endpoint
528below, for an upload it signed itself. Naming an address is not a way
529to publish one. A bearer-authenticated upload is the obvious next
530thing to build and is not built.
531
532Limits, on all three: 20 MiB per image, and PNG, JPEG, WebP or SVG.
533
534### `POST /d/{sqid}/uploads` β€” where do these bytes go
535
536```json
537{"name": "roadmap.png", "size": 184320, "type": "image/png", "hash": "9f2c1e...ab"}
538```
539
540`hash` is the file's SHA-256, computed by the client. Nothing is
541addressed by it. It is recorded, and on the development path β€” where
542this app receives the PUT and hashes the bytes anyway β€” a disagreement
543is reported as `hash_mismatch`; on a real bucket it is not consulted at
544all. The address the bytes go to is the app's own, always.
545
546The answer is where to PUT and what header to send:
547
548```json
549{"ref": "4d0a77...e1",
550 "url": "https://bucket.example/4d0a77...e1?X-Amz-Signature=...",
551 "headers": {"Content-Type": "image/png"}}
552```
553
554**That `ref` is a staging address, not the address the picture will be
555served from.** It is minted here, once, for this deck and this
556principal, and it is the only address this app will ever sign a PUT
557for. Two things follow, and both are the point:
558
559- A caller cannot ask for a PUT to an address something is already
560 served from, in this deck or any other. A presigned PUT is
561 unconditional, so an address a browser can name is an address a
562 browser can overwrite β€” which is how, before this, replacement bytes
563 could change a registered picture with no document update anywhere.
564- Registration will only look at bytes staged this way. Knowing a ref
565 is not a way to get it registered; see the next endpoint.
566
567Sign twice for the same file and you get two staging addresses. Ask for
568one and never use it and nothing happens: a staged upload nobody
569registered within two hours stops being registrable.
570
571With no object store bound it answers `503` and
572`{"error": "no_store", "message": "…"}` instead. That is the honest
573answer, not a fallback β€” there is nowhere for the bytes to go.
574
575In development the bound store is a directory (`blobs.Dir`), which has
576nothing to presign, so `url` comes back as a same-origin
577`/d/{sqid}/uploads/{ref}` and this app receives the PUT itself. That
578path resolves the same receipt before it reads a byte, so it is not a
579way round the staging rule either. On a real bucket it answers `404`:
58020 MiB does not go through an app process that has one small instance's
581memory.
582
583### `POST /d/{sqid}/assets` β€” what actually landed
584
585```json
586{"ref": "4d0a77...e1", "name": "roadmap.png", "width": 1600, "height": 900,
587 "alt": "Three phases, each with a date", "decorative": false}
588```
589
590`ref` here is the **staging address** the sign endpoint answered with,
591and nothing else is accepted. This endpoint resolves it to the upload
592receipt this app wrote when it signed the PUT: same deck, same
593principal, not already registered, not older than two hours. Anything
594else β€” a ref from another deck, a ref you happen to know, a receipt
595somebody else staged, a receipt already spent β€” is `404`
596`not_found`, the same answer an address holding nothing gets.
597
598That matters more than it looks. A ref is a content address, so knowing
599one is knowing the SHA-256 of somebody's file, and this endpoint writes
600the verification row that `GET /d/{sqid}/assets/{ref}` requires. If a
601ref alone were enough, knowing one would be permission to read it.
602
603The server then reads the staged object, sniffs the bytes (a file that
604*says* PNG and is not one is refused here, which is the only place the
605bytes are ever looked at), sanitises an SVG β€” scripts, event handlers
606and foreign HTML stripped; anything still pointing outside the document
607refused β€” and **puts what it read back as a final object addressed by
608its own SHA-256**. That final address is what the deck refers to, and
609no browser has ever been handed a PUT for it. Alternative text is
610required unless `decorative` is true (Β§2.4).
611
612It answers `200` with the record to register:
613
614```json
615{"id": "a1b2c3d4e5f6", "ref": "9f2c1e...ab", "name": "roadmap.png",
616 "width": 1600, "height": 900, "alt": "Three phases, each with a date"}
617```
618
619**The `ref` that comes back is never the one that went in.** The one
620that went in was a staging address; this one is the hash of the bytes
621the server read, which for a sanitised SVG is not even the hash of what
622was uploaded. Register what came back.
623
624It does not write the document. The client applies `register_asset`
625with this record itself, so an upload reaches the deck through the same
626op path, the same update log and the same undo step as every other
627change.
628
629Refusals are `{"error": "...", "message": "..."}` β€” `no_store`,
630`too_large`, `not_an_image`, `unsafe_svg`, `alt_required`, `invalid`,
631`not_found`. (`unsupported_type` and `already_verified` are the sign
632endpoint's own and `hash_mismatch` is the development PUT's;
633`unsupported_type` is a *declared* type outside the four, where here
634the bytes are what is judged and a file of the wrong kind is
635`not_an_image`.) The `error` word is fixed
636English for a program to branch on; the `message` is for a person and
637follows `Accept-Language`.
638
639Registering also records that the ref was verified for this deck, and
640`GET /d/{sqid}/assets/{ref}` requires that record as well as the deck's
641own `assets` map: a `register_asset` op naming a ref that never went
642through this endpoint is accepted by the document and the picture is
643never served.
644
645### `GET /d/{sqid}/assets/{ref}` β€” the bytes back
646
647A `302` to a presigned GET (one hour), or the file itself where the
648store cannot presign. Two more routes serve the same bytes to the
649surfaces that have no session: `/s/{token}/assets/{ref}` for a share
650link and `/p/{session}/assets/{ref}` while presenting. The renderer
651writes whichever base belongs to the page it is drawing.
652
653Every one of them checks the ref against **that deck's** `assets` map
654before serving a byte. A ref is a content address, so knowing one is
655knowing the SHA-256 of somebody's file; without that check any
656capability for any deck would fetch any object in the bucket.
657
658### For the operator: CORS on the bucket
659
660The presigned PUT is made by the browser, cross-origin, straight to the
661object store. That only works if the bucket allows it, and nothing in
662this app can arrange it for you. Configure the store's CORS policy to
663allow `PUT` from this instance's origin, with the `Content-Type` header,
664and expose `ETag`. Without it uploads fail in the browser with no useful
665error β€” the request never reaches this app, so there is nothing here to
666log.
667
668Development needs none of this: `blobs.Dir` has no presigning, so the
669PUT is same-origin.
670
671---
672
673## The live-sync wire
674
675Four more routes on the signed-in surface, which is what the browser
676editor is built on. You do not need them β€” `/api/v1/decks/{sqid}/ops`
677is the supported way for a program to write, and polling the deck with
678`If-None-Match` is the supported way to watch. They are documented
679because they are how the same deck is edited by a person while your
680agent is holding it, and because the framing is easy to get subtly
681wrong if you ever do speak it. They take a session cookie, not a bearer,
682and the two POSTs among them sit behind the same origin check every
683other screen does β€” see "A note on CSRF" at the foot of this file.
684
685The document underneath is a Yjs document. `POST /ops` is a batch of
686operations the server turns into one Yjs update; these routes carry
687Yjs updates directly. Both end up as rows in the same log, in one
688order, each stamped with the actor that wrote it.
689
690### `GET /d/{sqid}/updates?since=N` β€” the bytes after seq N
691
692`application/octet-stream`. Every update after seq `N`, in seq order,
693each one framed as a **4-byte big-endian length followed by that many
694bytes**. There is no other structure β€” no header, no trailer, no count.
695
696`Slides-Seq` on the response is the highest seq in the body, or `N`
697itself when the body is empty. Take your cursor from that header and
698never from a separate query: it is derived from the bytes you were
699actually handed, so a cursor read from it can never be ahead of what
700you received.
701
702At `since=0` the answer may begin from a periodic snapshot rather than
703from the first update ever written, in which case `Slides-Snapshot:
704<seq>` says which seq that first frame brings you to. A snapshot is
705derived data β€” it is a shortcut, never the truth β€” so a deck opens
706identically with it and without it.
707
708A `since` that is missing, negative or not a number is a `400`.
709
710### `POST /d/{sqid}/updates` β€” one update
711
712The body is one Yjs v1 update, 1 MiB at most. The answer is `204` with
713`Slides-Seq` for the row it wrote. Then it is broadcast, so every other
714open tab learns about it.
715
716A `422` here means the core read your bytes and refused them, which is
717an ordinary answer rather than a server fault: these arrive from
718browsers. A `413` is the size cap. A `409` means two writers landed at
719once, twice β€” reread and decide what your update now means.
720
721### `GET /d/{sqid}/stream` β€” server-sent events
722
723```
724event: update
725data: 41
726
727event: presence
728data: [{"client":"…","name":"Ana","colour":"#6b4","slide":"…","block":"…"}]
729
730: ping
731```
732
733One of each on connect, a `: ping` every 15 seconds, and the connection
734closes after five minutes for you to reopen.
735
736**`update` carries a seq and no bytes.** It is advisory β€” "there is
737something at least this new" β€” and the bytes come from `GET /updates`.
738`presence` carries the whole set every time, never a delta, so a
739subscriber that falls behind is dropped and reconnects rather than
740having events silently skipped.
741
742### `POST /d/{sqid}/presence` β€” where you are
743
744```json
745{"client": "9f3c…", "slide": "hq3ktna7vc2m", "block": "blkheading22"}
746```
747
748`client` identifies the tab, not the person, and is required. Post it
749every 20 seconds and whenever the person moves; two missed heartbeats
750and the server drops you from the set.
751
752The name and the colour are **stamped by the server** from your user
753row. Sending your own does nothing β€” otherwise presence would be a
754place to write somebody else's name onto somebody else's screen.
755Presence is never document state: it lives in memory, reaches no log,
756is in no snapshot, and is gone when the process restarts.
757
758### One note if you write Yjs updates by hand
759
760Text is an `XmlFragment` of `<paragraph>` elements whose formatting
761attributes are the marks, and the marks are named after the attributes:
762`b`, `i`, `c`, `h`, `href`. **`href` is stored as the one-key map
763`{"href": "https://…"}`, not as a plain string** (spec Β§1 ruling 12).
764That shape is y-prosemirror's, and it is not cosmetic: a plain string
765throws inside its reader, and it deletes the text node when a read
766throws β€” so the wrong shape loses the words, not just the link.
767
768Readers on both sides are tolerant where writers are exact. `b`, `i`,
769`c` and `h` are written `true` and read as set on any truthy value; an
770`href` is read as either the map or a plain string. The deck's JSON
771view β€” what `GET /api/v1/decks/{sqid}` hands you β€” flattens all of that
772into runs with a plain `href` string, so none of this reaches you
773unless you are writing updates yourself.
774
775The gate on `POST /d/{sqid}/updates` checks the text you leave behind,
776not the view of it (spec Β§2.6): a formatting attribute outside those
777five, an element other than `<paragraph>` at the top of a text, or an
778element or fragment inside a paragraph is a `422`, and nothing is
779stored. The view would have read all of those; the editor would have
780deleted the text on binding, which is why the check is on the
781document.
782
783Block order is a key, not the array. A slide's `blocks` array is the
784set of its blocks; `order` on each block map (a number, array index
785when absent) is the reading order, and the view sorts by it (spec
786Β§2.2). Reorder by writing `order`, never by deleting and re-inserting a
787block map β€” the second loses whatever another peer typed into it in the
788meantime.
789
790---
791
792## A worked example
793
794Build a two-slide deck from nothing:
795
796```sh
797export SLIDES_TOKEN=sl_...
798export API=https://slides.example/api/v1
799
800D=$(curl -sX POST -H "Authorization: Bearer $SLIDES_TOKEN" \
801 -H "Content-Type: application/json" \
802 -d '{"title":"Q3 review","theme":"ink"}' $API/decks | jq -r .sqid)
803
804S=$(curl -sX POST -H "Authorization: Bearer $SLIDES_TOKEN" \
805 -H "Content-Type: application/json" \
806 -d '{"ops":[{"op":"add_slide","layout":"flow","template":"title-body"}]}' \
807 $API/decks/$D/ops | jq -r '.ids["0"]')
808
809curl -sX POST -H "Authorization: Bearer $SLIDES_TOKEN" \
810 -H "Content-Type: application/json" \
811 -d '{"ops":[
812 {"op":"add_block","slide":"'"$S"'","type":"heading","region":"heading",
813 "props":{"level":1},"markdown":"Revenue"},
814 {"op":"add_block","slide":"'"$S"'","type":"list","region":"body",
815 "markdown":"- Up 14%\n- Two new regions"},
816 {"op":"set_notes","id":"'"$S"'","markdown":"Pause on the second bullet."}
817 ]}' $API/decks/$D/ops
818
819# Look at what you made.
820curl -s -H "Authorization: Bearer $SLIDES_TOKEN" "$API/decks/$D/slides/$S.html"
821```
822
823---
824
825## A note on CSRF
826
827The screens are protected by an origin check on every state-changing
828request (`Sec-Fetch-Site`, `Origin`, `Referer`). A program has none of
829those to send, and the check refuses a request that carries none of the
830three β€” so `/api/v1` is mounted outside it and gated by the bearer
831alone.
832
833That is not a hole. A browser never attaches an `Authorization` header
834by itself, so a page on some other origin cannot make a request this
835API will authenticate, whatever it does with forms or credentials.
836Cross-site request forgery is an attack on ambient credentials β€” a
837cookie the browser sends for you β€” and there is no ambient credential
838here.
839