Removed docs/stage-1-auth-api.md -- an early planning doc, badly out of date (predates passwordless auth, statuses, the inbox, and everything after). README.md is now just: what this is, a pointer to docs/, an end-user getting-started guide (Docker up, first-time login via the bundled Mailpit catcher, adding a passkey), and provenance -- everything else it used to carry moved out: - docs/api.md -- the full REST API reference (auth, passkeys, projects, cards, statuses, error shape) + the curl walkthrough. - docs/setup.md -- running without Docker, every environment variable, the test suite. - docs/architecture.md -- backend file layout; points to web/README.md for the frontend, which already documented itself in enough depth to stand alone. - docs/history.md -- the stage-by-stage feature log, with a new row for this session's refactoring work (which hadn't been logged yet). - docs/README.md -- an index tying the above together. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
13 KiB
API reference
Base path: /api. All request and response bodies are JSON; send
Content-Type: application/json.
GET /api/health
{ "status": "ok" }
Auth
There is no password and no separate registration endpoint. Entering an email address and opening the link sent to it is the entire flow, for a brand-new address and a returning one alike. A user can also register one or more passkeys and use one instead, once signed in at least once.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/api/auth/magic-link |
— | email a one-time sign-in link, creating the account first if the address is new |
POST |
/api/auth/verify-email |
— | consume the token: sign in, and (the first time) mark the address verified, or apply a pending email change |
POST |
/api/auth/passkey/options |
— | a challenge for signing in with a passkey (see Passkeys) |
POST |
/api/auth/passkey/verify |
— | verify a passkey response and sign in |
GET |
/api/me |
✔ | the current user |
POST |
/api/email/change |
✔ | request a deferred email change |
POST /api/auth/magic-link
Request: { "email": "ada@example.com" }.
Emails a one-time sign-in link (<APP_URL>/verify-email?token=…, 15-minute
expiry) and always returns 202 with the same message. If the address has no
account yet, one is created (unverified) right here — that's the only "sign
up" there is — unless APP_ALLOW_REGISTRATION=false, in which case an unknown
address is silently ignored (still 202, nothing sent) and only an address
that already has an account can sign in. A link is only actually (re-)sent if
this address hasn't been emailed one in the last 60 seconds. 422 if the
address is malformed.
{ "message": "Check your email for a link to sign in." }
POST /api/auth/verify-email
Body: { "token": "..." }. A missing/invalid, already-used, or expired token is
400 (distinct messages). Success signs the caller in:
{
"user": {
"id": 1,
"email": "ada@example.com",
"email_verified": true,
"email_verified_at": "2026-09-03T12:00:00Z",
"pending_email": null,
"has_passkey": false,
"created_at": "2026-09-03T12:00:00Z"
},
"token": "<jwt>",
"expires_at": "2026-09-04T12:00:00+00:00"
}
Opening a link is the only way to obtain a session, so an authenticated request
is always for a verified address — email_verified is true from the first
token a user's browser ever holds.
GET /api/me
Requires Authorization: Bearer <jwt>. 200 OK: the same user object shown
above. pending_email is the address a still-valid email-change link is
waiting on, or null. 401 if the header is missing, malformed, or the token
is invalid/expired.
POST /api/email/change
Requires Authorization: Bearer <jwt>. Body: { "email": "new@example.com" }.
The address must be free (409) and different from the current one (422).
Throttled to once per 60 seconds (shared with /api/auth/magic-link's
resend window, per user) — 429 with error.details.retry_after when too
soon. On success, 202 with retry_after and pending_email:
{
"message": "Confirmation email sent to the new address.",
"pending_email": "new@example.com",
"retry_after": 60
}
The change is not applied until the magic link sent to the new address is
opened — until then GET /api/me still shows the old address, with
pending_email set. Opening that link both changes the address and re-verifies
it, via the same /api/auth/verify-email.
Passkeys
WebAuthn, via lbuchs/webauthn. A passkey
is always registered as a discoverable, user-verified credential, which is
what makes login usernameless: the browser prompts the signed-in device for
whichever passkey it has for this site, with no email typed first. There's no
attestation/provenance check ('none' format) — this only confirms "the same
device that registered", the standard trust model for a public site's own
users, not a fleet of company-issued security keys.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/api/passkeys |
✔ | list the caller's passkeys |
POST |
/api/passkeys/options |
✔ | a registration challenge |
POST |
/api/passkeys |
✔ | verify the browser's response and store the credential |
DELETE |
/api/passkeys/{id} |
✔ | remove a passkey (204) |
POST |
/api/auth/passkey/options |
— | a login challenge (no email — discoverable) |
POST |
/api/auth/passkey/verify |
— | verify and sign in |
Both .../options endpoints return { "challenge_id": 1, "options": { "publicKey": {…} } }
— options.publicKey is passed more or less directly to
navigator.credentials.create()
/ .get()
(binary fields travel as base64url strings; the frontend converts them —
see web/README.md). challenge_id identifies a single-use
challenge, good for 5 minutes, and must be sent back with the browser's
response:
POST /api/passkeysbody:{ "challenge_id": 1, "credential": {…}, "label": "My laptop" }.credentialis{ id, response: { clientDataJSON, attestationObject } }(all base64url).201with the stored passkey ({ id, label, created_at, last_used_at }— never the credential id or public key) on success;400if the response doesn't check out,409if that credential is already registered.POST /api/auth/passkey/verifybody:{ "challenge_id": 1, "credential": {…} }, wherecredentialalso carriesauthenticatorData,signature, anduserHandle. Success returns the same{ user, token, expires_at }envelope as/api/auth/verify-email.401if the credential isn't recognised or the signature doesn't check out.
user.has_passkey (on every user object) is true once at least one is
registered — that's what the frontend's "add a passkey" notice keys off.
Projects
All routes below require Authorization: Bearer <jwt>. A project belongs to one
owner (the creator); another user's project — or a missing one — always responds
404.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/projects |
the caller's projects, sorted A→Z by title |
POST |
/api/projects |
create a project |
GET |
/api/projects/{id} |
one project |
PATCH |
/api/projects/{id} |
rename the project (title) |
DELETE |
/api/projects/{id} |
delete the project and its cards (204) |
GET /api/projects is always ordered alphabetically (case-insensitive) by
title; there is no other sort option. A user may own at most 100 projects —
creating one beyond that responds 409.
Create/update body: title (required, 1–255 chars).
Project representation:
{
"project": {
"id": 1,
"title": "Website relaunch",
"owner_id": 1,
"card_count": 3,
"completed_count": 1,
"created_at": "2026-09-03T12:00:00Z",
"updated_at": "2026-09-03T12:00:00Z"
}
}
GET /api/projects returns { "projects": [ … ] }.
Creating a project also seeds it with three statuses — "To do", "Doing", "Done" (see Statuses).
Cards
A card either sits in its owner's inbox (project_id and status_id both
null) or belongs to exactly one of their projects with a status in it (both
set) — enforced by a database CHECK constraint, never one without the other.
The inbox is global to the user, not per-project. Because a card may have no
project, single-card and ordering routes are addressed globally, by the card's
own id, rather than nested under a project:
| Method | Path | Purpose |
|---|---|---|
GET |
/api/projects/{id}/cards |
a project's cards, grouped by status then position |
POST |
/api/projects/{id}/cards |
add a card directly to the project |
GET |
/api/inbox/cards |
the caller's inbox |
POST |
/api/inbox/cards |
add a card to the inbox |
GET | PATCH | DELETE |
/api/cards/{cardId} |
one card, owner-scoped (404 otherwise) |
PUT |
/api/cards/order |
set the order/contents of one column |
Create body (either creation route): text (required, 1–1000 chars),
complete (optional bool, default false). The project route also takes an
optional status_id, appending the card to the end of that status (must
belong to the project, else 422) — omitted, it goes in the project's first
status instead. PATCH accepts text and/or complete only — moving a card
is done via the order route below, not PATCH.
Ordering. position is a dense 0..n-1 rank within a column — the cards
that share an (owner, project, status). The inbox is its own column, per
owner. PUT /api/cards/order sets one column's contents and order:
{ "project_id": 5, "status_id": 12, "card_ids": [3, 1, 2] }
project_id/status_id are both null for the inbox, or both set to a
project owned by the caller and one of its statuses (404/422 otherwise).
card_ids must be distinct cards owned by the caller and must include every
card already in the target column (422 otherwise); it rewrites positions to
0..n-1. Any card in the list that wasn't already in that column is
re-parented into it — moving it from another project's status, or the inbox,
or vice versa — and the column it left is re-packed, all in one transaction.
Returns { "cards": [ … ] } for the new column. This is what dragging a card
in the kanban board (or the sidebar's inbox) calls on every drop; moving a
card from one project to another is just two calls, via the inbox in between.
Card representation:
{
"card": {
"id": 10,
"project_id": 1,
"text": "Design homepage",
"complete": false,
"position": 0,
"status_id": 2,
"status": { "id": 2, "name": "Doing" },
"created_at": "2026-09-03T12:00:00Z",
"updated_at": "2026-09-03T12:00:00Z"
}
}
project_id and status_id are null together for an inbox card. status is
the embedded { id, name } of the linked status, or null. GET …/cards
returns { "cards": [ … ] }.
Statuses
Every project has an ordered set of card statuses, created with the project:
"To do", "Doing", "Done". They are project-specific — each project owns its own
rows, managed from the project's configuration view (create, reorder,
delete). A project always keeps at least one status, since a project card must
have one; deleting the last one is rejected (409).
| Method | Path | Purpose |
|---|---|---|
GET |
/api/projects/{id}/statuses |
the project's statuses, ordered by position |
POST |
/api/projects/{id}/statuses |
add one at the end — { "name": "Blocked" } |
PUT |
/api/projects/{id}/statuses/order |
reorder — { "status_ids": [3, 1, 2] }, every status once |
DELETE |
/api/projects/{id}/statuses/{statusId} |
delete (see below) |
{
"statuses": [
{ "id": 1, "project_id": 1, "name": "To do", "position": 0 },
{ "id": 2, "project_id": 1, "name": "Doing", "position": 1 },
{ "id": 3, "project_id": 1, "name": "Done", "position": 2 }
]
}
Requires Authorization: Bearer <jwt>; a project that is missing or not owned by
the caller responds 404.
Deleting a status that still has cards fails with 409 and
error.details.card_count set, rather than silently orphaning them (a
referenced status can't be deleted at the database level either — the FK is
ON DELETE RESTRICT). Retry with { "reassign_to": <another status id> } in
the same project; those cards are moved there and the status deleted, in one
transaction.
Error shape
Every error response looks like:
{ "error": { "message": "The submitted data was invalid.", "details": { "email": ["Email must be a valid address."] } } }
details is present only when relevant (e.g. validation).
Try it
Signing in needs the link the API emails, so this pulls it back out of the
bundled Mailpit catcher (adjust if you've pointed MAIL_TRANSPORT elsewhere).
The API pretty-prints its JSON, so responses are piped through tr -d ' \n'
before grep (Mailpit's own JSON doesn't need that):
BASE=http://localhost:8080
curl -s -X POST $BASE/api/auth/magic-link \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com"}'
MSG_ID=$(curl -s "http://localhost:8025/api/v1/messages?limit=1" | grep -o '"ID":"[^"]*"' | head -1 | cut -d'"' -f4)
LINK_TOKEN=$(curl -s "http://localhost:8025/api/v1/message/$MSG_ID" | grep -o 'token=[a-f0-9]*' | head -1 | cut -d= -f2)
TOKEN=$(curl -s -X POST $BASE/api/auth/verify-email \
-H 'Content-Type: application/json' \
-d "{\"token\":\"$LINK_TOKEN\"}" | tr -d ' \n' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
curl -s $BASE/api/me -H "Authorization: Bearer $TOKEN"
PROJECT=$(curl -s -X POST $BASE/api/projects \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Website relaunch"}' \
| tr -d ' \n' | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
curl -s $BASE/api/projects/$PROJECT/statuses -H "Authorization: Bearer $TOKEN"
curl -s -X POST $BASE/api/projects/$PROJECT/cards \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"Design homepage"}'
curl -s $BASE/api/projects/$PROJECT/cards -H "Authorization: Bearer $TOKEN"