Add the admin page (phase 3)

services/admin is a client of the content service's HTTP API, served on
the tailnet only. It renders on the server with plain forms and no client
scripts, and holds the admin token so the browser never sees it.

- Review queue: reasons, line diff against the live version, accept,
  reject, and reject with the live version put back into Obsidian
- Notes and drafts: search, preview, fields, backlinks, publish toggle
- History with compare and roll back
- Quick capture for a status or a Library item (optionally both)
- Store: orders open or closed per category, open slots and stock
- Overview: counts, last scan and error, scan now, sync job health
- Same-origin check on every post, strict CSP, sanitized previews

The content service gains /api/status, sync run reports (/api/syncs) and
lookup of a note by name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 19:59:21 +00:00
parent 3f646048a2
commit 32cf13a5cb
23 changed files with 1474 additions and 6 deletions
+49
View File
@@ -0,0 +1,49 @@
# Admin page
`services/admin` is where Greg decides what the rules held, puts back an
earlier version, captures a status or a Library item, opens and closes store
orders, previews drafts, and checks that the content service and the sync
jobs are running.
It is one more client of the content service's HTTP API, like the MCP server
and any editing tool built later. It writes nothing itself: every action is a
call to the write API, so the same rules apply, and changes are written into
the vault so Obsidian shows them too. Its changes are recorded with the author
`admin` ("Admin page" in history).
## Pages
| Page | What it does |
| --- | --- |
| Overview | Live and total notes, what waits for review, folder problems, the service's last scan and last error, a Scan now button, and each sync job's last run and last success. |
| Review | Held changes with the rules' reasons and a line diff against the live version. Accept puts it live (and into the vault, for API changes). Reject keeps the live version; for an edit made in Obsidian, "Reject and put the live version back" also rewrites the file. Earlier decisions are listed by status. |
| Notes, Drafts | Browse and search every note, or only the unpublished ones. |
| A note | A preview of the body, its fields, what links to it, Publish or Unpublish, and its history. Any earlier version can be compared with the live one and rolled back. |
| Capture | A status for the Now log, or a Library item with the option to post a status about it in the same step. |
| Store | Orders open or closed per category, with the reopen date and turnaround, and open slots or stock per product line. |
| Problems | Whole-folder problems, like links to notes that do not exist. |
## Safety
- **Tailnet only.** Publish it behind the "Tailnet only" access list; it has no
login of its own.
- **The token stays on the server.** The admin page holds the content service's
admin token and calls the API itself; the browser never sees it.
- **No posts from other sites.** Every form post must carry `Sec-Fetch-Site:
same-origin` (or a matching `Origin`), so a page elsewhere cannot make the
browser act on the admin page.
- **Drafts cannot run code.** Previews show raw HTML as text and drop links that
are not http, https, mailto or relative. The page sends a content security
policy that allows no scripts at all; it works entirely with plain forms.
## Running it
| Variable | Default | |
| --- | --- | --- |
| `CONTENT_URL` | `http://localhost:4310` | The content service. |
| `ADMIN_TOKEN` | required | The content service's admin token. |
| `PORT`, `HOST` | `4320`, `0.0.0.0` | |
```sh
ADMIN_TOKEN=… npm run admin
```
+4 -1
View File
@@ -79,13 +79,16 @@ roles in `CONTENT_TOKENS`.
| `GET /health` | anyone | Note, published and held counts. |
| `GET /api/schema` | all | Every note type with its JSON Schema. |
| `GET /api/notes?type=&q=&limit=&offset=&full=` | all | List or search. Readers see published notes only. |
| `GET /api/note?path=` or `?type=&slug=` | all | One note with its backlinks. An old slug answers with `{ redirect }`. |
| `GET /api/note?path=`, `?name=` or `?type=&slug=` | all | One note with its backlinks. An old slug answers with `{ redirect }`. |
| `GET /api/backlinks?name=` | all | Notes linking to a note. |
| `GET /api/redirects` | all | Every redirect. |
| `GET /attachments/<file>` | all | An image from `_attachments`. |
| `GET /api/note/history?path=`, `/api/note/revision?path=&rev=` | agent, editor, admin | History and old versions. |
| `GET /api/problems` | agent, editor, admin | Broken links and other whole-folder problems. |
| `GET /api/held`, `/api/held/<id>` | agent, editor, admin | The review queue; one entry comes with the current text for comparison. |
| `GET /api/status` | agent, editor, admin | When the service started, its last full scan and last error, counts, and each sync job's latest run. |
| `GET /api/syncs`, `?job=` | agent, editor, admin | Each sync job's latest run, or one job's recent runs. |
| `POST /api/syncs` | agent, editor, admin | A sync job reports a run: `{ job, ok, message?, counts? }`. The last 50 runs per job are kept. |
| `POST /api/changes` | agent, editor, admin | One change. 422 with reasons when turned down. |
| `POST /api/held/<id>/accept`, `/reject` | editor, admin | Decide a held change. `{ "restore": true }` on reject puts the live version back into the vault. |
| `POST /api/note/rollback` | admin | `{ path, rev }` |