# 04 — API Surface ## Conventions - [ ] Base path `/api/v1`; JSON in/out; UTF-8 - [ ] Auth: session cookie (scs) **or** `Authorization: Bearer solopm_…` PAT — same middleware resolves both ([01-architecture.md](01-architecture.md)) - [ ] Pagination: `?page=1&per_page=50` (max 100); list responses `{items: [...], page, per_page, total}` - [ ] Filtering: documented per endpoint below; multiple values comma-separated (`status=backlog,planned`) - [ ] Sorting: `?sort=field` / `?sort=-field` (descending) - [ ] Errors: `{"error": {"code", "message", "fields?"}}`; non-member project access → **404** - [ ] Issues are addressed by `project_issue_id` (`{num}`) within their project, never by global id, in all `/projects/{id}/issues/{num}` routes - [ ] Mutating endpoints emit domain events per [03-domain-events.md](03-domain-events.md) ## Auth & account - [ ] `POST /api/v1/auth/register` — email, username, name, password; first user becomes admin - [ ] `POST /api/v1/auth/login` — email + password → session cookie - [ ] `POST /api/v1/auth/logout` — destroy session - [ ] `GET /api/v1/auth/oauth/{provider}` — redirect to GitHub/Gitea authorize (`provider ∈ github|gitea`) - [ ] `GET /api/v1/auth/oauth/{provider}/callback` — code exchange → upsert oauth_accounts → session cookie → redirect to SPA - [ ] `GET /api/v1/me` — current user + memberships summary + unread notification count - [ ] `PATCH /api/v1/me` — name, avatar_url, email_mode - [ ] `PUT /api/v1/me/password` — requires current password - [ ] `GET /api/v1/me/tokens` — list PATs (prefix, name, last_used_at; never the token) - [ ] `POST /api/v1/me/tokens` — create PAT; plaintext token returned exactly once - [ ] `DELETE /api/v1/me/tokens/{id}` — revoke - [ ] `GET /api/v1/users` — pickable users for member/assignee selectors (excludes system, zombie, deleted) ## Admin (RequireAdmin) - [ ] `GET /api/v1/admin/users` — all users incl. soft-deleted - [ ] `PATCH /api/v1/admin/users/{id}` — toggle is_admin - [ ] `DELETE /api/v1/admin/users/{id}` — soft-delete user (blocked with `conflict` if sole owner of any project — transfer first) - [ ] `POST /api/v1/admin/cull?dry_run=true|false` — run retention job; dry-run returns would-delete report ([12-retention.md](12-retention.md)) - [ ] `POST /api/v1/admin/search/reindex` — full Meilisearch rebuild - [ ] `GET /api/v1/admin/jobs?status=dead` — inspect job queue ## Projects - [ ] `GET /api/v1/projects` — my projects, favorites first then recent activity; filters: `status`, `q` - [ ] `POST /api/v1/projects` — creates project + owner membership + seeded labels - [ ] `GET /api/v1/projects/{id}` — detail incl. members, labels, dependencies, my-favorite flag - [ ] `PATCH /api/v1/projects/{id}` — fields incl. status (sets/clears completed_at) - [ ] `DELETE /api/v1/projects/{id}` — soft delete (owner only) - [ ] `PUT /api/v1/projects/{id}/owner` — transfer ownership to another member (owner only) - [ ] `PUT /api/v1/projects/{id}/favorite` / `DELETE …/favorite` - [ ] `GET /api/v1/projects/{id}/members` / `POST` (owner only; body: user_id, role) / `PATCH …/members/{userId}` (role) / `DELETE …/members/{userId}` (owner only; owner cannot remove self without transfer) - [ ] `GET /api/v1/projects/{id}/dependencies` / `POST` (409 on cycle) / `DELETE …/dependencies/{depId}` - [ ] `GET /api/v1/projects/{id}/labels` / `POST` / `PATCH …/labels/{labelId}` / `DELETE …/labels/{labelId}` (soft) - [ ] `GET /api/v1/projects/{id}/activity` — paged audit feed for the project - [ ] `GET /api/v1/dashboard/activity` — cross-project feed over my projects - [ ] `GET /api/v1/projects/{id}/export?format=json|csv` — streaming download ([11-integrations.md](11-integrations.md)) ## Epics - [ ] `GET /api/v1/projects/{id}/epics` — list with progress (issue counts by status + story-point totals); filter `status` - [ ] `POST /api/v1/projects/{id}/epics` - [ ] `GET /api/v1/projects/{id}/epics/{epicId}` — detail incl. ordered issues - [ ] `PATCH /api/v1/projects/{id}/epics/{epicId}` / `DELETE` (soft; issues keep existing but epic_id set NULL at cull time, not delete time) - [ ] `PUT /api/v1/projects/{id}/epics/{epicId}/issues/{num}` — attach/move issue into epic (body: `after_rank` for position) - [ ] `DELETE /api/v1/projects/{id}/epics/{epicId}/issues/{num}` — detach ## Issues - [ ] `GET /api/v1/projects/{id}/issues` — filters: `status`, `assignee`, `label`, `priority`, `epic`, `parent`, `q` (title substring); sort: any column; `?view=board` returns grouped-by-status arrays ordered by board_rank - [ ] `POST /api/v1/projects/{id}/issues` — mints `project_issue_id`; optional epic_id, parent, assignee, labels - [ ] `GET /api/v1/projects/{id}/issues/{num}` — full detail (labels, watchers, blockers, sub-issues, epic, attachment/link lists) - [ ] `PATCH /api/v1/projects/{id}/issues/{num}` — any field; status→done sets completed_at; board moves send `{status, after_rank}` - [ ] `DELETE /api/v1/projects/{id}/issues/{num}` — soft delete - [ ] `GET /api/v1/projects/{id}/issues/{num}/activity` — audit entries ∪ comments, ascending - [ ] `GET /api/v1/projects/{id}/issues/{num}/blockers` / `POST` (409 on cycle) / `DELETE …/blockers/{blockerId}` - [ ] `PUT /api/v1/projects/{id}/issues/{num}/labels/{labelId}` / `DELETE` - [ ] `PUT /api/v1/projects/{id}/issues/{num}/watch` — watch (clears muted) / `DELETE` — unwatch (sets muted) - [ ] `GET /api/v1/projects/{id}/issues/{num}/comments` / `POST` - [ ] `PATCH /api/v1/comments/{commentId}` (author only) / `DELETE` (soft; author or project owner) - [ ] `PUT /api/v1/comments/{commentId}/resolve` / `DELETE …/resolve` ## Links & attachments (project | issue | comment) - [ ] `POST /api/v1/projects/{id}/links` · `POST /api/v1/projects/{id}/issues/{num}/links` · `POST /api/v1/comments/{commentId}/links` - [ ] `DELETE /api/v1/links/{linkId}` (membership checked via owning resource) - [ ] `POST /api/v1/projects/{id}/attachments` · `POST /api/v1/projects/{id}/issues/{num}/attachments` · `POST /api/v1/comments/{commentId}/attachments` — multipart, size ≤ `MAX_UPLOAD_MB` - [ ] `DELETE /api/v1/attachments/{attachmentId}` (soft; file removed at cull) - [ ] `GET /files/{path}` — auth-checked attachment serving (verify requester is member of the owning project; no directory listing; `Content-Disposition` from original_filename) ## Wiki - [ ] `GET /api/v1/projects/{id}/wiki` — page list (title, slug, updated_by, updated_at) - [ ] `POST /api/v1/projects/{id}/wiki` — create (slug from title; 409 on duplicate) - [ ] `GET /api/v1/projects/{id}/wiki/{slug}` / `PATCH` / `DELETE` (soft) ## Notifications & real-time - [ ] `GET /api/v1/notifications?unread=true` — paged inbox - [ ] `GET /api/v1/notifications/unread_count` - [ ] `PUT /api/v1/notifications/{id}/read` / `PUT /api/v1/notifications/read_all` - [ ] `GET /api/v1/stream` — SSE; frames `notification`, `data`, `ping` ([09-notifications-realtime.md](09-notifications-realtime.md)) ## Search - [ ] `GET /api/v1/search?q=&types=issue,epic,project,comment,wiki&project_id=` — Meilisearch proxy, results filtered to caller's memberships ([10-search.md](10-search.md)); 503 when Meilisearch unconfigured ## Webhooks ([11-integrations.md](11-integrations.md)) - [ ] `POST /webhooks/incoming/{provider}` — unauthenticated endpoint, HMAC-SHA256 verified, writes receipt, processes (outside `/api/v1`) - [ ] `GET /api/v1/projects/{id}/webhook_receipts` — paged receipts - [ ] `POST /api/v1/projects/{id}/webhook_receipts/{rid}/replay` - [ ] `GET /api/v1/projects/{id}/webhooks` / `POST` / `PATCH …/webhooks/{whId}` / `DELETE` (all owner only) - [ ] `POST /api/v1/projects/{id}/webhooks/{whId}/test` — send a signed test payload now - [ ] `GET /api/v1/projects/{id}/webhooks/{whId}/deliveries` — delivery log ## Ops - [ ] `GET /healthz` — 200 with `{db: ok, meilisearch: ok|absent, smtp: configured|absent}` - [ ] All non-`/api`, non-`/webhooks`, non-`/files` GETs → embedded SPA `index.html`