PROJECTTINKER

lab · api

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.
accountA signed-in member. Returns 401 auth_required otherwise.
authorWhoever wrote the row. Returns 403 not_yours otherwise — and the owner is not an exception.
ownerThe Founder role. Returns 403 forbidden otherwise.

Endpoints [ 17 ]

GET /api/me none

Current user and which storage bindings exist.

debugset to 1 to probe every binding by calling it
response
{ "user": { "username": "...", "role": "member", "bio": "", "tech": "", "pfp": null } | null,
  "storage": { "db": true, "r2": false } }
POST /api/auth none

Register, sign in, or sign out. Sets the cx_user cookie.

request body
{ "action": "register" | "login" | "logout",
  "username": "...", "password": "...", "ownerKey": "..." }
response
{ "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.

POST /api/me

Update your own profile.

request body
{ "bio": "<=400 chars", "tech": "comma,separated", "pfp": "<media key you own>" }
response
{ "ok": true, "user": { ... } }        400 bad_pfp if the key is not yours
GET /api/posts none

Posts in one thread, newest first, with media and comment counts.

threadrequired, e.g. cube:bom or hub:general
limitdefault 50, max 100
response
{ "posts": [ { "id", "title", "body", "created_at", "username", "role",
               "comment_count", "media": [ { "key", "mime", "name" } ] } ] }
POST /api/posts

Create a post, optionally attaching media you uploaded.

request body
{ "thread": "hub:general", "title": "", "body": "", "media": ["<key>", ...] }
response
{ "ok": true, "post": { ... } }        401 auth_required   400 bad_thread | empty_post

Media keys attach only if you uploaded them and have not already attached them elsewhere.

PATCH /api/posts author

Edit your own post.

request body
{ "id": "<post id>", "title": "", "body": "" }
response
{ "ok": true, "post": { "id", "title", "body", "edited_at" } }
403 not_yours   404 not_found   409 already_gone   400 empty_post

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.

DELETE /api/posts author

Remove your own post, and the comments on it.

request body
{ "id": "<post id>" }
response
{ "ok": true, "id": "<post id>" }
403 not_yours   404 not_found   409 already_gone

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.

GET /api/comments none

Comments on one post, oldest first.

postrequired post id
response
{ "comments": [ { "id", "body", "created_at", "username", "role", "pfp" } ] }
POST /api/comments

Comment on a post.

request body
{ "post": "<post id>", "body": "<=4000 chars" }
response
{ "ok": true, "comment": { ... } }     404 no_such_post
PATCH /api/comments author

Edit your own comment.

request body
{ "id": "<comment id>", "body": "<=4000 chars" }
response
{ "ok": true, "comment": { "id", "body", "edited_at" } }
403 not_yours   404 not_found   409 already_gone   400 empty_comment
DELETE /api/comments author

Remove your own comment.

request body
{ "id": "<comment id>" }
response
{ "ok": true, "id": "<comment id>" }
403 not_yours   404 not_found   409 already_gone
POST /api/media

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/activity none

Merged feed of posts and comments for the live terminal.

limitdefault 60, max 200
response
{ "events": [ { "at": 1789699104414, "kind": "post", "who": "name", "what": "(thread) text" } ] }
GET /api/deploy

The commit actually serving this page.

response
{ "commit", "short", "branch", "repo", "github", "pipeline": [...] }
GET /api/admin owner

Moderation queue, counts, or activity analytics.

viewqueue (default), stats, or analytics
response
queue      { "posts": [...], "comments": [...], "users": [...] }
stats      { "stats": { ... }, "storage": { "db", "r2" } }
analytics  { "window": { "days": 30, "tz": "UTC" },
             "daily": { "posts": [30], "comments": [30] },
             "hours": [24], "people": [...], "threads": [...],
             "media": [...], "totals": { ... } }
403 forbidden if not an owner

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.

POST /api/admin owner

Hide or restore content, or change a member role.

request body
{ "action": "hide" | "unhide", "kind": "post" | "comment", "id": "..." }
{ "action": "role", "handle": "name", "role": "owner" | "member" }
response
{ "ok": true, ... }                    409 last_owner if you would lock yourself out

Errors [ same shape everywhere ]

CodeBodyMeans
400bad_request, bad_thread, empty_postMalformed input; the message names the field.
401auth_required, rejected, owner_key_requiredNot signed in, wrong credentials, or an owner account without its second secret.
403forbidden, not_yoursSigned in, but not an owner — or not the author of that row.
404no_such_post, not_foundThe target does not exist.
409username_taken, last_owner, already_goneConflicts with existing state.
413too_largeUpload over 100 MB.
415bad_typeUnsupported file type.
500server_error + detailAn exception, with its real message rather than an opaque 1101.
502store_failedThe object store rejected the write. No media row is left behind.
503storage_unconfiguredD1 or the media store is not bound. The UI shows setup steps instead of breaking.