329 lines
13 KiB
Markdown
329 lines
13 KiB
Markdown
# API reference
|
||||
|
|
|
|||
|
|
Base path: `/api`. All request and response bodies are JSON; send
|
|||
|
|
`Content-Type: application/json`.
|
|||
|
|
|
|||
|
|
### `GET /api/health`
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{ "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](#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](#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.
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{ "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:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"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`:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"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](https://github.com/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()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create)
|
|||
|
|
/ [`.get()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get)
|
|||
|
|
(binary fields travel as base64url strings; the frontend converts them —
|
|||
|
|
see [web/README.md](../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/passkeys` body: `{ "challenge_id": 1, "credential": {…}, "label": "My laptop" }`.
|
|||
|
|
`credential` is `{ id, response: { clientDataJSON, attestationObject } }`
|
|||
|
|
(all base64url). `201` with the stored passkey
|
|||
|
|
(`{ id, label, created_at, last_used_at }` — never the credential id or
|
|||
|
|
public key) on success; `400` if the response doesn't check out, `409` if
|
|||
|
|
that credential is already registered.
|
|||
|
|
- `POST /api/auth/passkey/verify` body: `{ "challenge_id": 1, "credential": {…} }`,
|
|||
|
|
where `credential` also carries `authenticatorData`, `signature`, and
|
|||
|
|
`userHandle`. Success returns the same `{ user, token, expires_at }` envelope
|
|||
|
|
as `/api/auth/verify-email`. `401` if 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:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"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](#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:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{ "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:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"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) |
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"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:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{ "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):
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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"
|
|||
|
|
```
|