Base URL: /api (the frontend reads it from VITE_API_URL, default /api).
All responses are JSON. Unless stated otherwise, endpoints expect and return
application/json.
Interactive docs: an OpenAPI 3.0 spec is served at GET /api-docs.json and
browsable via Swagger UI at /api-docs. This document is the
human-readable summary; the spec is the machine-readable source of truth.
Every /api/* endpoint below is also mounted, identically, under /api/v1/*.
The unversioned prefix is the canonical one used by the frontend; external
integrations should pin /api/v1 so a future breaking change (shipped as
/api/v2) never affects them.
The session is carried by HttpOnly cookies set on login (frameset_access_token,
frameset_refresh_token); the browser sends them automatically with
credentials: 'include'. A Bearer token in the Authorization header is also
accepted on protected routes.
- Access token: short-lived (2h).
- Refresh token: longer-lived (7d), rotated on every
/auth/refreshand revocable server-side. Tokens issued before a password change are rejected. Its cookie is scoped topath=/api/auth, so the browser only attaches it to the auth endpoints that actually need it (/auth/refresh,/auth/logout) — never to the rest of the API.
Mutating requests (POST, PUT, PATCH, DELETE) use the double-submit
cookie pattern: send the value of the frameset_csrf_token cookie back in the
x-csrf-token header. Fetch a token first with GET /api/auth/csrf-token.
Safe methods (GET, HEAD, OPTIONS) are exempt.
Failures return { "error": "<message>" } (server errors also add a generic
message). Status codes used: 400 (validation), 401 (unauthenticated),
403 (forbidden / invalid token / CSRF), 404 (not found), 409/429 (rate
limited), 500 (server), 503 (dependency unavailable).
JSON body is capped at 10 kB. Sensitive endpoints are rate limited per IP
(or per user), e.g. login/register 5/min, code verification 10 / 10 min, code
resend 3 / 10 min, 2FA code entry (login and setup) 10 / 10 min, 2FA setup 5 /
10 min, 2FA disable 3 / 10 min, recovery-code regeneration 3 / 10 min,
project/norm creation (and duplication) 30/h,
palette saves 300/h, public share views 60/min. A 429 response includes a
Retry-After header (seconds).
POST /auth/demo-login opens a session on a single shared, read-only account
(no credentials needed). For that account, every mutating request
(POST/PUT/PATCH/DELETE) on any endpoint is rejected with 403 before
it reaches a service or the database — enforced centrally in
authenticateToken, so it can't be bypassed per-route. The frontend simulates
those mutations client-side instead, so the demo still feels fully
interactive; nothing it does ever persists.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health |
– | Liveness + DB ping. 200 { status, db, uptime }, 503 when the DB is unreachable. |
| Method | Path | Auth | Body | Success |
|---|---|---|---|---|
GET |
/auth/csrf-token |
– | – | { csrfToken } (also sets the CSRF cookie) |
POST |
/auth/register |
– | { name, email, password } |
{ success, id, name, email, avatarInitials, isVerified, passwordUpdatedAt } |
POST |
/auth/login |
– | { email, password } |
sets auth cookies, { success, ...user } — or, if the account has 2FA enabled, no cookies yet and { success: true, requiresTotp: true, challengeToken } |
POST |
/auth/login/totp |
– | { challengeToken, code } (code accepts a 6-digit TOTP code or a recovery code) |
sets auth cookies, { success, ...user } |
POST |
/auth/demo-login |
– | – | sets auth cookies for the shared read-only demo account, { success, ...user } (isDemo: true); 503 if no demo account is seeded |
POST |
/auth/google |
– | { credential } (Google ID token) |
sets auth cookies, { success, ...user } — signs in, links to an existing email/password account, or creates a new (passwordless) account; follows the same requiresTotp challenge flow as /auth/login when 2FA is enabled; 503 if Google sign-in isn't configured |
POST |
/auth/verify |
– | { email, code } |
{ success } |
POST |
/auth/resend-code |
– | { email } |
{ success } |
POST |
/auth/forgot-password |
– | { email } |
{ success } (identical whether or not the email exists) |
POST |
/auth/reset-password |
– | { email, code, newPassword } |
{ success } |
POST |
/auth/refresh |
refresh cookie | – | rotates tokens, { success } |
POST |
/auth/logout |
– | – | revokes tokens, clears cookies, { success } |
Password policy: min 8 chars, at least one lowercase, one uppercase and one digit.
challengeToken is a short-lived (5 min), purpose-scoped token — it only works
against /auth/login/totp and can't be used as a session credential elsewhere.
An accepted TOTP code is single-use: replaying it inside its own 30 s validity
window is rejected (RFC 6238 §5.2), as is reusing a spent recovery code.
The challenge token itself is deliberately not single-use (a stateless JWT,
no server-side ledger): within its 5 minutes it allows several code attempts,
which is what lets a user retry a mistyped code without redoing the password
step. Brute force through that window is capped by the /auth/login/totp rate
limit, and the token grants nothing on its own — it only ever converts a
valid code into a session.
| Method | Path | Auth | Body | Success |
|---|---|---|---|---|
GET |
/users/count |
– | – | { count } (public stat) |
GET |
/users/profile |
✓ | – | { id, name, email, avatarInitials, passwordUpdatedAt, pendingEmail, isDemo, totpEnabled } |
PUT |
/users |
✓ | { name, email } |
{ success, name, email, pendingEmail } — an email change is staged as pendingEmail until confirmed |
POST |
/users/password |
✓ | { currentPassword, newPassword } |
{ success, passwordUpdatedAt } (re-issues the session) |
POST |
/users/email/verify |
✓ | { email, code } |
{ success, user } |
POST |
/users/email/resend |
✓ | { email } |
{ success } |
POST |
/users/totp/setup |
✓ | – | { success, secret, otpauthUrl } — starts (or restarts) enrollment; nothing is enabled yet |
POST |
/users/totp/confirm |
✓ | { code } |
{ success, recoveryCodes } — verifies the first code and enables 2FA; codes are shown once |
POST |
/users/totp/disable |
✓ | { currentPassword } or Google reauth |
{ success } — requires re-authentication, like other critical account actions |
POST |
/users/totp/recovery-codes |
✓ | { currentPassword } or Google reauth |
{ success, recoveryCodes } — fresh set shown once; every previous code stops working immediately |
DELETE |
/users/me |
✓ | – | { success } (cascades to the user's projects) |
All routes require authentication and enforce ownership (a user can only read or mutate their own projects).
| Method | Path | Body | Success |
|---|---|---|---|
GET |
/projects?page=&pageSize=&search= |
– | { projects: [{ id, name, lastEdited, pinned, shareToken, brushNorms[], typographyNorms[], normsCount, palette[] }], pagination: { page, pageSize, total, totalPages } } — paginated (pageSize defaults to 12, capped at 50); pinned projects sort first, then newest-edited; search filters by name (case-insensitive substring) |
GET |
/projects/search?q= |
– | { projects[], colors[], brushNorms[], typographyNorms[] } — global search (the app's Ctrl+K): one term (1–100 chars, LIKE wildcards escaped) matched against project names, colour names/hex (# optional) and standards, capped at 5 matches per category; only the caller's live (non-trashed) content is searched |
GET |
/projects/:id |
– | one project, same shape as a list item — lets a deep link / hard reload resolve a project beyond the loaded pages; trashed/unknown/foreign ids are a plain 404 |
POST |
/projects |
{ name } (2–50 chars) |
the created project |
POST |
/projects/:id/duplicate |
– | the new project — copies the palette and standards (order preserved), named "<name> (copy)"; shares the project-creation rate limit |
PATCH |
/projects/:id |
{ name } |
{ success, name } |
DELETE |
/projects/:id |
– | { success } — soft delete, moves the project to the trash |
Applies uniformly to projects, palette colors, and brush/typography standards:
delete moves an item to the trash, where it's restorable for 30 days
before a scheduled purge deletes it permanently. Each resource has the same
three-endpoint shape (GET .../trash, POST .../:childId/restore,
DELETE .../:childId/permanent).
| Method | Path | Success |
|---|---|---|
GET |
/projects/trash |
{ projects: [{ id, name, deletedAt, daysLeft }] } |
POST |
/projects/:id/restore |
the restored project |
DELETE |
/projects/:id/permanent |
{ success } — irreversible |
GET |
/projects/:id/palette/trash |
{ colors: [{ id, name, hex, deletedAt, daysLeft }] } |
POST |
/projects/:id/palette/:colorId/restore |
{ success } |
DELETE |
/projects/:id/palette/:colorId/permanent |
{ success } — irreversible |
GET |
/projects/:projectId/brush-norms/trash |
{ norms: [{ id, name, ..., deletedAt, daysLeft }] } |
POST |
/projects/:projectId/brush-norms/:normId/restore |
{ success } |
DELETE |
/projects/:projectId/brush-norms/:normId/permanent |
{ success } — irreversible |
GET |
/projects/:projectId/typography-norms/trash |
{ norms: [{ id, fontFamily, ..., deletedAt, daysLeft }] } |
POST |
/projects/:projectId/typography-norms/:normId/restore |
{ success } |
DELETE |
/projects/:projectId/typography-norms/:normId/permanent |
{ success } — irreversible |
Pinned projects sort before unpinned ones on GET /projects.
| Method | Path | Body | Success |
|---|---|---|---|
POST |
/projects/:id/pin |
– | { success } — idempotent, appended after the user's other pinned projects |
DELETE |
/projects/:id/pin |
– | { success } |
POST |
/projects/pinned/reorder |
array of pinned project ids | { success } — reorders the user's pinned projects to match the given sequence |
| Method | Path | Auth | Success |
|---|---|---|---|
POST |
/projects/:id/share |
✓ | { shareToken } — mints (or returns the existing) public share token |
DELETE |
/projects/:id/share |
✓ | { success } — revokes the link immediately |
GET |
/share/:token |
– | the read-only reference sheet: { name, brushNorms[], typographyNorms[], palette[], ownerName } — public, rate limited per IP (60/min); 404 if the token is invalid, revoked, or the project is trashed |
GET |
/share/:token/preview.png |
– | a 1200×630 PNG of the project (name, owner credit, palette swatches) rendered server-side — the og:image behind share links; same rate limit and 404 contract |
GET |
/share/:token/embed |
– | minimal HTML carrying the Open Graph/Twitter tags for the share link — social crawlers are rewritten here by the frontend (they don't run the SPA); humans get redirected to the real page |
GET |
/share/:token/events |
– | Server-Sent Events stream: a bare changed event fires on every owner mutation (subscribers refetch the share endpoint); heartbeats keep proxies alive, and a full event ends the stream when the per-project viewer cap is reached |
ownerName is the owner's display name only — never their id or email — shown
as a "Made by …" credit on the public page and in the exported PDF.
| Method | Path | Body | Success |
|---|---|---|---|
POST |
/projects/:id/brush-norms |
{ name, value, unit?, brushName?, opacity? } |
{ success, id } |
PUT |
/projects/:projectId/brush-norms/:normId |
same as create | { success } |
DELETE |
/projects/:projectId/brush-norms/:normId |
– | { success } — soft delete |
POST |
/projects/:id/brush-norms/reorder |
array of brush-norm ids | { success } — reorders to match the given sequence; unrecognized ids are silently skipped |
POST |
/projects/:id/typography-norms |
{ fontFamily, fontWeight?, fontUsage?, fontStyle? } |
{ success, id } |
PUT |
/projects/:projectId/typography-norms/:normId |
same as create | { success } |
DELETE |
/projects/:projectId/typography-norms/:normId |
– | { success } — soft delete |
POST |
/projects/:id/typography-norms/reorder |
array of typography-norm ids | { success } — same contract as the brush-norm reorder |
Field notes: brush value is a positive number (≤ 1000), unit letters/%
only, opacity in 0..1. Text fields are trimmed and length-bounded.
| Method | Path | Body | Success |
|---|---|---|---|
POST |
/projects/:id/palette |
array of { id?, name?, hex } (≤ 50, hex = #RGB/#RRGGBB) |
{ success, palette } — atomically replaces the whole palette, order preserved; used for add/edit/delete/reorder |
DELETE |
/projects/:id/palette/:colorId |
– | { success } — soft delete of a single color (independent of the bulk replace above) |
| Method | Path | Auth | Success |
|---|---|---|---|
GET |
/fonts |
✓ | { items: [{ family, variants[] }] } — Google Fonts catalog, proxied server-side and cached so the API key never ships in the client bundle |