Files
project-manager/docs/api.md
T
aneurinandClaude Sonnet 5 8732e0e5f5
Build / build-and-push (push) Successful in 14s
Add APP_EMAIL_ALLOWLIST gate on account creation
An optional comma-separated list of glob patterns restricting which
addresses may register, applied on top of APP_ALLOW_REGISTRATION. A
non-matching new address is silently ignored exactly like registration
being off; an address that already has an account can still sign in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:26:33 +01:00

241 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`, or `APP_EMAIL_ALLOWLIST` is set and the address doesn't match one of its comma-separated glob patterns. In either 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 `MAX_PROJECTS_PER_OWNER` projects (default: unlimited) — creating one beyond that responds `409`.
Create/update body: `title` (required, 1255 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, 11000 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"
```