|
| 1 | +# AGENTS.md — Operating JustWiki from an AI agent |
| 2 | + |
| 3 | +This guide teaches an AI agent how to read and write JustWiki content via its |
| 4 | +REST API. Everything below is drawn from the live handlers under |
| 5 | +`backend/app/routers/` — if behaviour ever diverges from this doc, trust the |
| 6 | +code and update the doc. |
| 7 | + |
| 8 | +## Base URL and transport |
| 9 | + |
| 10 | +- Backend: `http://localhost:8000` in development; whatever host you deploy |
| 11 | + `uvicorn` on in production. |
| 12 | +- All endpoints are JSON and live under `/api/`. The Vite dev server proxies |
| 13 | + `/api` → `:8000`, so `http://localhost:5173/api/...` works too. |
| 14 | +- The server is a single-worker FastAPI app backed by SQLite in WAL mode. |
| 15 | + Assume low concurrency: you will not be fighting many writers. |
| 16 | + |
| 17 | +## Authentication |
| 18 | + |
| 19 | +JustWiki accepts two kinds of credentials on every authenticated endpoint: |
| 20 | + |
| 21 | +1. **Personal API token** (recommended for agents) — looks like |
| 22 | + `jwk_<random>`. Long-lived, revocable, created in the Profile page. |
| 23 | +2. **Session JWT** — issued by `/api/auth/login`, lives 24 hours, delivered |
| 24 | + as an httpOnly cookie or Bearer header. Good for short interactive jobs |
| 25 | + and the web UI. |
| 26 | + |
| 27 | +Both are sent the same way: `Authorization: Bearer <token>`. The server |
| 28 | +tells them apart by the `jwk_` prefix. |
| 29 | + |
| 30 | +### Create an API token (one-time human step) |
| 31 | + |
| 32 | +1. Log in to JustWiki with a password. |
| 33 | +2. Open **Profile → API Tokens**. |
| 34 | +3. Click **New Token**, name it (e.g. `ci-bot`), pick an expiry (default 30 |
| 35 | + days, max 365, or "Never"). |
| 36 | +4. Copy the token string that starts with `jwk_…`. **This is the only time |
| 37 | + it will be shown.** If you lose it, revoke and mint a new one. |
| 38 | + |
| 39 | +Prefer creating a dedicated `editor`-role account for agents so revocation |
| 40 | +is clean and audit logs are easy to read. |
| 41 | + |
| 42 | +Policy recap: |
| 43 | + |
| 44 | +- Only `editor`/`admin` roles can create tokens; `viewer` cannot. |
| 45 | +- A token **cannot** mint another token — stopping an attacker from using a |
| 46 | + stolen token to survive your revocation. |
| 47 | +- Revoking leaves the row in place so `last_used` / activity entries |
| 48 | + remain auditable. |
| 49 | +- No rate limit on token usage (only login is rate-limited). |
| 50 | + |
| 51 | +### Use the token |
| 52 | + |
| 53 | +```bash |
| 54 | +export JW_TOKEN="jwk_xxxxxxxxxxxx..." |
| 55 | + |
| 56 | +curl -sS -H "Authorization: Bearer $JW_TOKEN" \ |
| 57 | + http://localhost:8000/api/auth/me |
| 58 | +``` |
| 59 | + |
| 60 | +### Password login (alternative) |
| 61 | + |
| 62 | +```bash |
| 63 | +curl -sS -c cookies.txt -X POST http://localhost:8000/api/auth/login \ |
| 64 | + -H 'Content-Type: application/json' \ |
| 65 | + -d '{"username":"admin","password":"admin"}' |
| 66 | +curl -sS -b cookies.txt http://localhost:8000/api/auth/me |
| 67 | +``` |
| 68 | + |
| 69 | +Login is rate-limited to **5 attempts/IP/60s**. Back off on HTTP 429. |
| 70 | + |
| 71 | +### Manage your own tokens from the API |
| 72 | + |
| 73 | +| Method | Path | Purpose | |
| 74 | +| ------ | ---------------------------- | ------------------------------------- | |
| 75 | +| GET | `/api/auth/tokens` | List your tokens (plaintext not sent) | |
| 76 | +| POST | `/api/auth/tokens` | Create one (body: `{name, expires_in_days}`) — only over session login | |
| 77 | +| DELETE | `/api/auth/tokens/{id}` | Revoke | |
| 78 | + |
| 79 | +### Roles |
| 80 | + |
| 81 | +| role | can read | can write | can delete own | admin things | |
| 82 | +| ------ | -------- | --------- | -------------- | ------------ | |
| 83 | +| admin | ✅ | ✅ | any page | ✅ all | |
| 84 | +| editor | ✅ | ✅ | own pages | ❌ | |
| 85 | +| viewer | ✅ | ❌ | ❌ | ❌ | |
| 86 | + |
| 87 | +`viewer` is capped at read even if an ACL grants write. `admin` bypasses all |
| 88 | +ACL checks. The creator or an admin may delete a page. Only admins may purge |
| 89 | +(hard-delete) from the trash. |
| 90 | + |
| 91 | +## Resource reference |
| 92 | + |
| 93 | +All routes are authenticated unless noted. |
| 94 | + |
| 95 | +### Pages — `/api/pages` |
| 96 | + |
| 97 | +| Method | Path | Purpose | |
| 98 | +| ------ | ---------------------------- | ----------------------------------------- | |
| 99 | +| GET | `/api/pages` | Paginated list (filter by `parent_id`) | |
| 100 | +| GET | `/api/pages/tree` | Hierarchical tree | |
| 101 | +| GET | `/api/pages/graph` | Node/link graph for visualisation | |
| 102 | +| POST | `/api/pages` | Create a page | |
| 103 | +| GET | `/api/pages/{slug}` | Read one page (also bumps view count) | |
| 104 | +| PUT | `/api/pages/{slug}` | Update (requires `base_version` on edits) | |
| 105 | +| PATCH | `/api/pages/{slug}/move` | Change `parent_id` / `sort_order` | |
| 106 | +| DELETE | `/api/pages/{slug}` | Soft-delete (moves to trash) | |
| 107 | +| GET | `/api/pages/{slug}/children` | Direct children | |
| 108 | +| GET | `/api/pages/{slug}/backlinks`| Incoming wikilinks | |
| 109 | + |
| 110 | +### Versions — `/api/pages/{slug}/...` |
| 111 | + |
| 112 | +| Method | Path | Purpose | |
| 113 | +| ------ | --------------------------------------- | ----------------------- | |
| 114 | +| GET | `/api/pages/{slug}/versions` | List prior versions | |
| 115 | +| GET | `/api/pages/{slug}/versions/{num}` | Read a specific version | |
| 116 | +| GET | `/api/pages/{slug}/diff?v1=A&v2=B` | Unified diff | |
| 117 | +| POST | `/api/pages/{slug}/revert/{num}` | Revert to version `num` | |
| 118 | + |
| 119 | +### Search — `/api/search` |
| 120 | + |
| 121 | +`GET /api/search?q=...&tag=...&page=1&per_page=20`. FTS5 with trigram; queries |
| 122 | +with any word shorter than 3 chars fall back to LIKE. Snippets have matched |
| 123 | +terms wrapped in `<mark>...</mark>`. |
| 124 | + |
| 125 | +### Tags — `/api/tags`, `/api/pages/{slug}/tags` |
| 126 | + |
| 127 | +List all tags with page counts, add/remove tag on a page. |
| 128 | + |
| 129 | +### Media — `/api/media` |
| 130 | + |
| 131 | +| Method | Path | Purpose | |
| 132 | +| ------ | -------------------------- | --------------------------------- | |
| 133 | +| POST | `/api/media/upload` | Multipart upload (20 MB limit) | |
| 134 | +| GET | `/api/media` | List visible media | |
| 135 | +| GET | `/api/media/{filename}` | Fetch file (ACL-checked) | |
| 136 | +| DELETE | `/api/media/{media_id}` | Delete (admin, no live refs only) | |
| 137 | + |
| 138 | +Allowed types: PNG, JPEG, GIF, WebP, SVG, PDF, text/plain, text/markdown. |
| 139 | + |
| 140 | +### ACL — `/api/pages/{slug}/acl` |
| 141 | + |
| 142 | +| Method | Path | Purpose | |
| 143 | +| ------ | ------------------------------------- | --------------------------------- | |
| 144 | +| GET | `/api/pages/{slug}/acl` | Explicit + inherited rows | |
| 145 | +| PUT | `/api/pages/{slug}/acl` | Replace the explicit ACL set | |
| 146 | +| DELETE | `/api/pages/{slug}/acl` | Clear explicit rows (re-inherit) | |
| 147 | +| GET | `/api/pages/{slug}/my-permission` | Your resolved permission | |
| 148 | + |
| 149 | +ACL row shape: `{"principal_type":"user"|"group","principal_id":<int>,"permission":"read"|"write"}`. |
| 150 | + |
| 151 | +### Groups — `/api/groups` (admin-only for writes) |
| 152 | + |
| 153 | +`GET /api/groups`, `POST /api/groups`, `DELETE /api/groups/{id}`, |
| 154 | +`GET /api/groups/{id}/members`, `POST /api/groups/{id}/members`, |
| 155 | +`DELETE /api/groups/{id}/members/{user_id}`. |
| 156 | + |
| 157 | +### Comments — `/api/pages/{slug}/comments` |
| 158 | + |
| 159 | +Read requires page-read; writes require page-write. `POST`, `PUT /{id}`, |
| 160 | +`DELETE /{id}`. Non-admin authors can only edit or delete their own comments. |
| 161 | + |
| 162 | +### Trash — `/api/trash` |
| 163 | + |
| 164 | +`GET /api/trash` (your own items; admin sees all), `POST /api/trash/{slug}/restore`, |
| 165 | +`DELETE /api/trash/{slug}` (admin-only hard delete). |
| 166 | + |
| 167 | +### Templates — `/api/templates` |
| 168 | + |
| 169 | +`GET`, `POST`, `PUT /{id}`, `DELETE /{id}`. You can pass `template_id` on |
| 170 | +page creation and the template's `content_md` will seed the new page. |
| 171 | + |
| 172 | +### Users — `/api/users` (mostly admin) |
| 173 | + |
| 174 | +`GET /api/users/search?q=...&limit=...` is open to any authenticated user |
| 175 | +(for ACL pickers). All other user CRUD is admin-only. |
| 176 | + |
| 177 | +## Core write workflows |
| 178 | + |
| 179 | +### 1. Create a page |
| 180 | + |
| 181 | +```bash |
| 182 | +curl -sS -b cookies.txt -X POST http://localhost:8000/api/pages \ |
| 183 | + -H 'Content-Type: application/json' \ |
| 184 | + -d '{ |
| 185 | + "title": "Release checklist", |
| 186 | + "content_md": "# Release checklist\n\n- [ ] Bump version\n- [ ] Run tests\n", |
| 187 | + "parent_id": null, |
| 188 | + "sort_order": 0 |
| 189 | + }' |
| 190 | +``` |
| 191 | + |
| 192 | +Returns the created page, including `slug` (auto-generated from title; CJK |
| 193 | +preserved) and `version: 1`. To nest under a parent, include `parent_id`; you |
| 194 | +need write permission on the parent. |
| 195 | + |
| 196 | +Templates: pass `"template_id": <int>` instead of `content_md` to seed from a |
| 197 | +template. |
| 198 | + |
| 199 | +### 2. Edit a page (optimistic locking) |
| 200 | + |
| 201 | +`content_md` / `title` edits **require** `base_version` equal to the current |
| 202 | +`version` on disk; otherwise you get HTTP 400 (`base_version_required`) or |
| 203 | +409 (`conflict`). Metadata-only edits (`is_public`, `parent_id`, `sort_order`) |
| 204 | +do not need it. |
| 205 | + |
| 206 | +```bash |
| 207 | +# 1) Read the page to learn its current version |
| 208 | +curl -sS -b cookies.txt http://localhost:8000/api/pages/release-checklist \ |
| 209 | + | jq '{version, slug}' |
| 210 | +# → {"version": 3, "slug": "release-checklist"} |
| 211 | + |
| 212 | +# 2) Send the edit with base_version |
| 213 | +curl -sS -b cookies.txt -X PUT http://localhost:8000/api/pages/release-checklist \ |
| 214 | + -H 'Content-Type: application/json' \ |
| 215 | + -d '{ |
| 216 | + "content_md": "# Release checklist\n\n- [x] Bump version\n- [ ] Run tests\n", |
| 217 | + "base_version": 3 |
| 218 | + }' |
| 219 | +``` |
| 220 | + |
| 221 | +On conflict (409) the server returns the latest `current_version`; re-read, |
| 222 | +re-apply your change on top, and retry. |
| 223 | + |
| 224 | +### 3. Upload media and reference it |
| 225 | + |
| 226 | +```bash |
| 227 | +curl -sS -b cookies.txt -X POST http://localhost:8000/api/media/upload \ |
| 228 | + -F "file=@diagram.png" |
| 229 | +# → {"id": 42, "filename":"ab12...png", "url":"/api/media/ab12...png", ...} |
| 230 | +``` |
| 231 | + |
| 232 | +Then embed it in a page's `content_md`: |
| 233 | + |
| 234 | +```markdown |
| 235 | + |
| 236 | +``` |
| 237 | + |
| 238 | +The server scans page content on write and records each media reference in |
| 239 | +`media_references`. Referenced media survives media-list filtering and cannot |
| 240 | +be deleted until all referencing pages are purged. |
| 241 | + |
| 242 | +### 4. Tag a page |
| 243 | + |
| 244 | +```bash |
| 245 | +curl -sS -b cookies.txt -X POST \ |
| 246 | + http://localhost:8000/api/pages/release-checklist/tags \ |
| 247 | + -H 'Content-Type: application/json' \ |
| 248 | + -d '{"name":"ops"}' |
| 249 | +``` |
| 250 | + |
| 251 | +`POST` creates the tag if it doesn't exist. `DELETE /api/pages/{slug}/tags/{tag_name}` |
| 252 | +removes it; orphan tags are cleaned up automatically. |
| 253 | + |
| 254 | +### 5. Restrict a page with ACL |
| 255 | + |
| 256 | +```bash |
| 257 | +# Grant a group read-only, a user write. Any existing rows are replaced. |
| 258 | +curl -sS -b cookies.txt -X PUT \ |
| 259 | + http://localhost:8000/api/pages/release-checklist/acl \ |
| 260 | + -H 'Content-Type: application/json' \ |
| 261 | + -d '{ |
| 262 | + "rows": [ |
| 263 | + {"principal_type":"group","principal_id": 3, "permission":"read"}, |
| 264 | + {"principal_type":"user", "principal_id": 7, "permission":"write"} |
| 265 | + ] |
| 266 | + }' |
| 267 | +``` |
| 268 | + |
| 269 | +Resolution rule: walk the `parent_id` chain, find the shallowest ancestor |
| 270 | +with any ACL rows (the "anchor"), and take the most-permissive matching row |
| 271 | +for the caller. No anchor ⇒ default open (write for editor, read for viewer). |
| 272 | + |
| 273 | +Clear with `DELETE /api/pages/{slug}/acl` — the page then re-inherits. |
| 274 | + |
| 275 | +### 6. Move a page |
| 276 | + |
| 277 | +```bash |
| 278 | +curl -sS -b cookies.txt -X PATCH \ |
| 279 | + http://localhost:8000/api/pages/release-checklist/move \ |
| 280 | + -H 'Content-Type: application/json' \ |
| 281 | + -d '{"parent_id": 12, "sort_order": 5}' |
| 282 | +``` |
| 283 | + |
| 284 | +Refuses with 400 if it would create a cycle. Requires write on both source |
| 285 | +and destination parent. |
| 286 | + |
| 287 | +### 7. Soft-delete and restore |
| 288 | + |
| 289 | +```bash |
| 290 | +# Soft delete (moves to trash; slug stays reserved) |
| 291 | +curl -sS -b cookies.txt -X DELETE http://localhost:8000/api/pages/release-checklist |
| 292 | + |
| 293 | +# Restore |
| 294 | +curl -sS -b cookies.txt -X POST http://localhost:8000/api/trash/release-checklist/restore |
| 295 | +``` |
| 296 | + |
| 297 | +Admin-only hard delete: `DELETE /api/trash/{slug}`. |
| 298 | + |
| 299 | +### 8. Revert to an old version |
| 300 | + |
| 301 | +```bash |
| 302 | +curl -sS -b cookies.txt http://localhost:8000/api/pages/release-checklist/versions |
| 303 | +curl -sS -b cookies.txt -X POST \ |
| 304 | + http://localhost:8000/api/pages/release-checklist/revert/2 |
| 305 | +``` |
| 306 | + |
| 307 | +Reverting snapshots the current content into a new version first, then writes |
| 308 | +the old content back and bumps `version` — a subsequent concurrent editor |
| 309 | +will get a 409 as expected. |
| 310 | + |
| 311 | +## Markdown conventions |
| 312 | + |
| 313 | +Use stock GFM plus these JustWiki extensions. The backend parses them on |
| 314 | +write to keep backlinks and media refs in sync; the viewer renders them. |
| 315 | + |
| 316 | +- **Wikilinks** — `[[slug]]` or `[[slug|display text]]`. Creates a |
| 317 | + `backlinks` row and renders as a link to `/wiki/<slug>`. Auto-slug keeps |
| 318 | + CJK characters (e.g. `[[專案規劃]]`). |
| 319 | +- **Transclusion** — `![[slug]]` inlines another page's rendered content. |
| 320 | +- **Callouts** — fenced blocks: `:::info` / `:::tip` / `:::warning` / `:::danger` |
| 321 | + ending with `:::`. |
| 322 | +- **Math** — `$inline$` and `$$display$$` (KaTeX). |
| 323 | +- **Mermaid** — ```` ```mermaid ... ``` ```` fences. |
| 324 | +- **Draw.io** — `::drawio[diagram_id]` embeds. |
| 325 | + |
| 326 | +`content_md` is stored verbatim; the viewer sanitises on render with |
| 327 | +DOMPurify. You can assume raw HTML in markdown will be stripped. |
| 328 | + |
| 329 | +## Pitfalls an agent should know about |
| 330 | + |
| 331 | +1. **Always fetch before editing.** Skipping the read to get `version` is the |
| 332 | + fastest way to hit `base_version_required` or `conflict`. |
| 333 | +2. **Slugs are case-sensitive URL keys.** They're derived from the title; |
| 334 | + if you need a specific slug, pass it explicitly on `POST /api/pages`. |
| 335 | +3. **404 may mean "no permission".** The server returns 404 instead of 403 |
| 336 | + for pages the caller can't read, so treat 404 on known slugs as "maybe |
| 337 | + ACL-blocked." |
| 338 | +4. **Soft-delete keeps the slug reserved.** To reuse a slug, either restore |
| 339 | + the trashed page or admin-purge it first. |
| 340 | +5. **View counts bump on GET** — reading `/api/pages/{slug}` is a write in |
| 341 | + disguise. Dedup'd per (user, page) over `VIEW_DEDUP_MINUTES`, but still |
| 342 | + something to know if you're scraping. |
| 343 | +6. **Editor and Viewer are separate render paths.** The dual pipeline lives |
| 344 | + on the frontend; content written via the API goes through the viewer path |
| 345 | + on display. If you're testing a markdown feature end-to-end, also click |
| 346 | + through the Editor in the UI. |
| 347 | +7. **Rate limits.** Login: 5/min/IP. AI chat: `AI_RATE_LIMIT_PER_HOUR` per |
| 348 | + user. Public reads: 60/min/IP. No global write limit, but respect 429. |
| 349 | + |
| 350 | +## Quick session template |
| 351 | + |
| 352 | +```bash |
| 353 | +# 1. Authenticate once (Profile → API Tokens → Copy) |
| 354 | +export JW_TOKEN="jwk_xxxxxxxxxxxx..." |
| 355 | +AUTH=(-H "Authorization: Bearer $JW_TOKEN") |
| 356 | + |
| 357 | +# 2. Read current state |
| 358 | +curl -sS "${AUTH[@]}" http://localhost:8000/api/pages/my-page | jq '{slug,version}' |
| 359 | + |
| 360 | +# 3. Mutate with base_version |
| 361 | +curl -sS "${AUTH[@]}" -X PUT http://localhost:8000/api/pages/my-page \ |
| 362 | + -H 'Content-Type: application/json' \ |
| 363 | + -d '{"content_md":"…new content…","base_version":7}' |
| 364 | +``` |
| 365 | + |
| 366 | +## Further reading in this repo |
| 367 | + |
| 368 | +- `backend/app/routers/` — one file per domain, each handler is short; read |
| 369 | + the one matching your endpoint for the exact contract. |
| 370 | +- `backend/app/schemas.py` — Pydantic request/response models. |
| 371 | +- `backend/app/services/acl.py` — the single source of truth for permission |
| 372 | + resolution; every router calls these helpers. |
| 373 | +- `CLAUDE.md` — project-wide conventions and the dev workflow. |
0 commit comments