Three things hold across the whole API. Authentication is a signed cx_user cookie, so there is no token to paste. Every database query binds its parameters — none are built by string concatenation. And every write checks its permission on the server; a hidden button is never the control.
Auth levels [ what each one means ]
noneAny visitor past the door code. Reading is open.
{ "ok": true, "user": { ... } } 401 on a bad password
409 username_taken 400 bad_username | weak_password
Passwords are PBKDF2-SHA256, 100k iterations, per-user salt. A wrong password and an unknown user take the same time and return the same shape. ownerKey is a second secret that ONLY owner accounts are asked for, and only after their password has already been accepted - so it can never be used to work out who owns the hub. It is compared in constant time. If the OWNER_KEY variable is not set the check passes, because locking the owner out of their own hub over an unset variable would be worse; the owner console says so.
Ownership is part of the WHERE clause, not a check before it, so a request for somebody else’s row updates nothing rather than racing a lookup. Not even an owner can rewrite another member’s words — moderation hides, it does not edit.
A soft delete: the row is marked 2, meaning removed by its author, kept distinct from 1, meaning hidden by the owner. That is what stops the moderation queue offering to restore something its author chose to remove.
Upload an image or video. multipart/form-data with a file field.
response
{ "ok": true, "key": "<userId>/<rand>.jpg", "mime": "image/jpeg",
"size": 12345, "store": "kv" }
413 too_large (the error carries the real limit) 415 bad_type
502 store_failed 503 storage_unconfigured
Capped at 20 MB per file: a Pages Function holds the whole body in memory and a KV value cannot exceed 25 MiB. The browser resizes photos to 2048 px before sending, so an ordinary phone picture arrives far under that. jpeg/png/gif/webp/avif and mp4/webm/mov only - svg is refused because it is a script container.
GET/api/m/<key>none
Serve an uploaded file. Honours Range so video can seek, and 304s on a matching ETag.
response
the file, with Content-Security-Policy: default-src none; sandbox
206 + Content-Range for a range request 416 if the range is past the end
304 if If-None-Match matches 400 bad_key 404 not_found
Bytes live in a Workers KV namespace. R2 is preferred automatically if it is ever bound, but it needs a payment method on the account even inside its free tier, so KV is what this hub uses. KV returns whole values, so the range is applied in the handler rather than by the store.
GET/api/activitynone
Merged feed of posts and comments for the live terminal.
Analytics counts rows already in the database - posts, comments, uploads. Nothing is tracked per visit and no visitor is profiled. Hidden posts and comments are excluded from every count except the moderation queue, which has to show them so they can be restored. Hours are UTC.