Reference
Whiteboard
6 routes, generated from Morse’s own OpenAPI document. Every path below hangs off the base URL, and every one needs the bearer header.
The examples are built from each route’s schema, so the shapes and types are exactly what the API declares. The values are illustrative, and no one has run them.
What a late joiner fetches before applying live deltas.
/meetings/{meeting_id}/whiteboardThe client must subscribe to the data channel and buffer *before* calling this, then replay the buffer over the result. Fetching first and subscribing after loses every delta broadcast during the round trip, permanently — there is no resync to repair it. Replay is free because reconciliation is version-based and idempotent, so over-applying costs nothing.
GET/meetings/{meeting_id}/whiteboard
curl "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/whiteboard" \
-H "Authorization: Bearer $MORSE_TOKEN"{
"open": true,
"scene": {},
"seq": 1
}The periodic snapshot, written as a **compare-and-swap**.
/meetings/{meeting_id}/whiteboardThe caller echoes the seq it last saw and the UPDATE is guarded on it
(ADR-006: guarded writes, never read-then-write). A stale writer's echo is
behind, so its write is refused and it learns the current value from the
409 — which is the whole point. The designated-snapshotter election reduces
write volume; *this* is what makes a stale scene unable to overwrite a
fresh one, under both races the election cannot solve:
- the departing snapshotter's last POST arriving after the new one's first
- two clients briefly both believing they hold the role
A timestamp guard catches neither; both writes are recent.
POST/meetings/{meeting_id}/whiteboard
curl -X POST "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/whiteboard" \
-H "Authorization: Bearer $MORSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "scene": {}, "seq": 1 }'{
"open": true,
"scene": {},
"seq": 1
}Which images this board already holds, by id and nothing else.
/meetings/{meeting_id}/whiteboard/filesA client compares this against the ids its own scene references and fetches only what it is missing, which is almost always nothing. Ids rather than bytes keeps the poll cheap enough to run whenever a delta mentions a file.
GET/meetings/{meeting_id}/whiteboard/files
curl "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/whiteboard/files" \
-H "Authorization: Bearer $MORSE_TOKEN"{
"file_ids": [
"3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60"
]
}Keep an image someone put on the board. The first write of an id wins, and the index is what decides who was first.
/meetings/{meeting_id}/whiteboard/filesExcalidraw derives file_id from the content, so a second write of an id
*should* be the same bytes — but that derivation happens on the client and
this side cannot verify it, which makes it the wrong thing to rest on.
s3.put is a plain PUT with no If-None-Match, so it would replace the
object, while the insert's ON CONFLICT DO NOTHING would keep the original
row: mime, size and created_by would go on describing the file that
used to be there, and read_file would serve the new bytes under the old
content type. Reading the row first makes "a second write of the same id is
the same file" true by construction rather than by trust.
Two people pasting the same screenshot still collapse into one object and
one row: both find no row, both write identical bytes to the same key, and
DO NOTHING settles whichever insert arrives second. The object goes up
before the row because an object with no row is swept, while a row with no
object is a placeholder nobody can explain.
POST/meetings/{meeting_id}/whiteboard/files
curl -X POST "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/whiteboard/files" \
-H "Authorization: Bearer $MORSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "data": "…", "file_id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60", "mime": "…" }'The bytes, as the image they are.
/meetings/{meeting_id}/whiteboard/files/{file_id}Proxied rather than redirected to a presigned URL: the board is read by
members, guests and the archive under three different credentials, and
handing out a URL that outlives the check is a wider door than this needs.
An image is a few hundred kilobytes and is cached for a day by its hash.
require_attendee rather than an in-room check, for the same reason read
uses it: the archive of a finished meeting is read through here too, and
ending a meeting flips every participant to left.
GET/meetings/{meeting_id}/whiteboard/files/{file_id}
curl "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/whiteboard/files/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60" \
-H "Authorization: Bearer $MORSE_TOKEN"Opening the board puts it on everyone's screen.
/meetings/{meeting_id}/whiteboard/openNot host-only, and not the caller's alone: the same reasoning as admitting (Phase 2) — a shared surface among colleagues, where restricting it buys nothing and makes everyone wait on one person. Closing never clears the scene, so it is non-destructive and safe to hand to anyone.
POST/meetings/{meeting_id}/whiteboard/open
curl -X POST "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/whiteboard/open" \
-H "Authorization: Bearer $MORSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "open": true }'{
"open": true
}