From 32cf13a5cb809e38396efa0b4eab6cd1ccefe06e Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 11 Oct 2026 19:59:21 +0000
Subject: [PATCH] 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)
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
---
README.md | 6 +-
docs/admin.md | 49 ++++
docs/content-service.md | 5 +-
package-lock.json | 25 ++
package.json | 5 +-
services/admin/package.json | 16 ++
services/admin/public/admin.css | 79 +++++++
services/admin/src/client.ts | 136 +++++++++++
services/admin/src/diff.ts | 60 +++++
services/admin/src/html.ts | 46 ++++
services/admin/src/layout.ts | 49 ++++
services/admin/src/main.ts | 19 ++
services/admin/src/markdown.ts | 28 +++
services/admin/src/pages.ts | 289 +++++++++++++++++++++++
services/admin/src/server.ts | 357 +++++++++++++++++++++++++++++
services/admin/test/admin.test.ts | 152 ++++++++++++
services/admin/test/units.test.ts | 50 ++++
services/admin/tsconfig.json | 4 +
services/content/src/http.ts | 30 ++-
services/content/src/main.ts | 5 +-
services/content/src/service.ts | 12 +
services/content/src/store.ts | 47 ++++
services/content/test/http.test.ts | 11 +
23 files changed, 1474 insertions(+), 6 deletions(-)
create mode 100644 docs/admin.md
create mode 100644 services/admin/package.json
create mode 100644 services/admin/public/admin.css
create mode 100644 services/admin/src/client.ts
create mode 100644 services/admin/src/diff.ts
create mode 100644 services/admin/src/html.ts
create mode 100644 services/admin/src/layout.ts
create mode 100644 services/admin/src/main.ts
create mode 100644 services/admin/src/markdown.ts
create mode 100644 services/admin/src/pages.ts
create mode 100644 services/admin/src/server.ts
create mode 100644 services/admin/test/admin.test.ts
create mode 100644 services/admin/test/units.test.ts
create mode 100644 services/admin/tsconfig.json
diff --git a/README.md b/README.md
index ced09bb..167333b 100644
--- a/README.md
+++ b/README.md
@@ -13,11 +13,12 @@ with the decisions behind it, is the
| `packages/schema` | The one definition of every note type: fields, folders, file names, and the checks for one note and for the whole folder. The migration, the content service, the MCP server, the site and any later editing tool all use it. |
| `vault-kit` | What goes into the vault: the Obsidian templates (generated from the schema) and the Site guide. |
| `services/content` | The content service: watches the Site folder, runs the rules, keeps the last good version and its history, and serves the one write API. The MCP server for agents is part of it. See [docs/content-service.md](docs/content-service.md). |
+| `services/admin` | The admin page: the review queue with diffs, history and rollback, quick capture, store orders, draft previews and sync health. A client of the content service, on the tailnet only. See [docs/admin.md](docs/admin.md). |
| `tools/migrate` | The one-time migration from the old site's data into Site folder notes. |
| `docs` | How the pieces fit. Start with [docs/content-model.md](docs/content-model.md). |
| `legacy` | The old site, kept for reference only. Nothing outside it imports from it except the migration. |
-Coming in later phases: the admin page, the component library, and the new site.
+Coming in later phases: the component library and the new site.
## Commands
@@ -33,6 +34,9 @@ npm run schema:json # writes a JSON Schema per note type to packages/schema/jso
# the content service, against a copy of the Site folder
SITE_DIR=out/24.01_PersonalWebsite/Site DATA_DIR=data \
CONTENT_TOKENS="reader:<24+ chars>,admin:<24+ chars>" npm run content
+
+# the admin page, in a second terminal
+ADMIN_TOKEN= npm run admin # http://localhost:4320
```
## The old site
diff --git a/docs/admin.md b/docs/admin.md
new file mode 100644
index 0000000..71d3a43
--- /dev/null
+++ b/docs/admin.md
@@ -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
+```
diff --git a/docs/content-service.md b/docs/content-service.md
index be91a21..f6512fd 100644
--- a/docs/content-service.md
+++ b/docs/content-service.md
@@ -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/` | 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/` | 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//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 }` |
diff --git a/package-lock.json b/package-lock.json
index 7506337..44fe4d5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -436,6 +436,10 @@
"node": ">=18"
}
},
+ "node_modules/@gk/admin": {
+ "resolved": "services/admin",
+ "link": true
+ },
"node_modules/@gk/content": {
"resolved": "services/content",
"link": true
@@ -1937,6 +1941,18 @@
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
+ "node_modules/marked": {
+ "version": "18.0.14",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.14.tgz",
+ "integrity": "sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2863,6 +2879,15 @@
"zod": "^4.1.0"
}
},
+ "services/admin": {
+ "name": "@gk/admin",
+ "version": "0.1.0",
+ "dependencies": {
+ "@gk/schema": "*",
+ "marked": "18.0.14",
+ "tsx": "^4.19.0"
+ }
+ },
"services/content": {
"name": "@gk/content",
"version": "0.1.0",
diff --git a/package.json b/package.json
index 707ec47..295eaa8 100644
--- a/package.json
+++ b/package.json
@@ -12,13 +12,14 @@
"node": ">=22"
},
"scripts": {
- "typecheck": "tsc -p packages/schema && tsc -p tools/migrate && tsc -p services/content",
+ "typecheck": "tsc -p packages/schema && tsc -p tools/migrate && tsc -p services/content && tsc -p services/admin",
"test": "vitest run",
"check": "npm run typecheck && npm test",
"schema:json": "npm run json -w @gk/schema",
"templates": "npm run templates -w @gk/schema",
"migrate": "npm run migrate -w @gk/migrate --",
- "content": "npm run start -w @gk/content"
+ "content": "npm run start -w @gk/content",
+ "admin": "npm run start -w @gk/admin"
},
"devDependencies": {
"@types/node": "^22.10.0",
diff --git a/services/admin/package.json b/services/admin/package.json
new file mode 100644
index 0000000..fa5d793
--- /dev/null
+++ b/services/admin/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@gk/admin",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "The admin page: the review queue, history and rollback, quick capture, store orders, draft previews and sync health. A client of the content service's HTTP API, reachable on the tailnet only.",
+ "scripts": {
+ "start": "node --import tsx src/main.ts",
+ "dev": "node --watch --import tsx src/main.ts"
+ },
+ "dependencies": {
+ "@gk/schema": "*",
+ "marked": "18.0.14",
+ "tsx": "^4.19.0"
+ }
+}
diff --git a/services/admin/public/admin.css b/services/admin/public/admin.css
new file mode 100644
index 0000000..fc25df6
--- /dev/null
+++ b/services/admin/public/admin.css
@@ -0,0 +1,79 @@
+/* The admin page follows the site's all-dark brand, kept plain: it is a tool. */
+:root {
+ --sky: #0b111c;
+ --midnight: #131c2b;
+ --panel: #182335;
+ --line: #2a3a52;
+ --text: #f4f2ed;
+ --muted: #a9b4c2;
+ --teal: #14a39a;
+ --teal-bright: #2bc2b7;
+ --gold: #c9a24a;
+ --gold-bright: #d8b35a;
+ --red: #f0857a;
+ --add: #12382f;
+ --remove: #45202a;
+ color-scheme: dark;
+}
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--sky); color: var(--text); font: 15px/1.55 Montserrat, system-ui, sans-serif; }
+a { color: var(--teal-bright); text-underline-offset: 3px; }
+h1, h2, h3 { font-family: Cinzel, Georgia, serif; font-weight: 600; letter-spacing: 0.02em; }
+h1 { font-size: 1.6rem; margin: 0 0 4px; }
+h2 { font-size: 1.15rem; margin: 28px 0 10px; color: var(--gold-bright); }
+code, pre, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
+header.top { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 18px; padding: 12px 20px; background: var(--midnight); border-bottom: 1px solid var(--gold); }
+header.top .brand { font-family: Cinzel, serif; color: var(--gold-bright); text-decoration: none; margin-right: 8px; }
+header.top nav { display: flex; flex-wrap: wrap; gap: 4px 14px; }
+header.top nav a { color: var(--text); text-decoration: none; padding: 4px 0; border-bottom: 2px solid transparent; }
+header.top nav a[aria-current="page"] { border-color: var(--teal); }
+.count { display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 10px; background: var(--gold); color: var(--midnight); font-size: 12px; font-weight: 700; text-align: center; }
+main { max-width: 1100px; margin: 0 auto; padding: 24px 16px 64px; }
+.lede { color: var(--muted); margin: 0 0 18px; }
+.flash { padding: 10px 14px; border-left: 3px solid var(--teal); background: var(--panel); margin-bottom: 18px; }
+.flash.err { border-color: var(--red); }
+.flash ul { margin: 6px 0 0; padding-left: 20px; }
+.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px; margin-bottom: 16px; }
+.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
+.stat { font-size: 1.9rem; font-family: Cinzel, serif; color: var(--gold-bright); }
+.muted { color: var(--muted); }
+table { width: 100%; border-collapse: collapse; }
+th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
+th { color: var(--muted); font-weight: 600; font-size: 13px; }
+.table-wrap { overflow-x: auto; }
+.pill { display: inline-block; padding: 1px 10px; border-radius: 999px; font-size: 12px; border: 1px solid var(--line); white-space: nowrap; }
+.pill.live { border-color: var(--teal); color: var(--teal-bright); }
+.pill.draft { border-color: var(--gold); color: var(--gold-bright); }
+.pill.bad { border-color: var(--red); color: var(--red); }
+.reason { margin: 4px 0; }
+.reason code { color: var(--gold-bright); }
+form.inline { display: inline; }
+.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 14px 0; }
+button, .button { font: inherit; cursor: pointer; min-height: 36px; padding: 6px 18px; border-radius: 999px; border: 1px solid var(--gold); background: transparent; color: var(--text); text-decoration: none; display: inline-flex; align-items: center; }
+button.primary { background: var(--teal); border-color: var(--teal); color: var(--midnight); font-weight: 600; }
+button.danger { border-color: var(--red); color: var(--red); }
+button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
+label { display: block; margin: 10px 0 4px; font-size: 13px; color: var(--muted); }
+label.check { display: flex; gap: 8px; align-items: center; color: var(--text); font-size: 15px; }
+input, select, textarea { font: inherit; width: 100%; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--line); background: var(--sky); color: var(--text); }
+input[type="checkbox"] { width: auto; }
+input:focus, select:focus, textarea:focus { border-color: var(--teal); }
+.row { display: grid; gap: 0 14px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
+.filters { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; margin-bottom: 16px; }
+.filters > * { flex: 1 1 160px; }
+.filters button { flex: 0 0 auto; }
+.diff { background: var(--sky); border: 1px solid var(--line); border-radius: 10px; overflow-x: auto; padding: 8px 0; }
+.diff div { white-space: pre-wrap; word-break: break-word; padding: 0 12px; font-family: ui-monospace, monospace; font-size: 13px; }
+.diff .add { background: var(--add); }
+.diff .add::before { content: "+ "; color: var(--teal-bright); }
+.diff .remove { background: var(--remove); }
+.diff .remove::before { content: "- "; color: var(--red); }
+.diff .same::before { content: " "; }
+.diff .skip { color: var(--muted); font-style: italic; }
+.preview { background: var(--midnight); border: 1px solid var(--gold); border-radius: 10px; padding: 8px 22px; }
+.preview img { max-width: 100%; }
+dl.fields { display: grid; grid-template-columns: minmax(110px, max-content) 1fr; gap: 6px 16px; margin: 0; }
+dl.fields dt { color: var(--muted); }
+dl.fields dd { margin: 0; word-break: break-word; }
+dl.fields pre { margin: 0; white-space: pre-wrap; }
+input.num { width: 90px; }
diff --git a/services/admin/src/client.ts b/services/admin/src/client.ts
new file mode 100644
index 0000000..9798941
--- /dev/null
+++ b/services/admin/src/client.ts
@@ -0,0 +1,136 @@
+/**
+ * The content service's HTTP API, as the admin page uses it. The admin page
+ * holds the admin token on the server side; the browser never sees it.
+ */
+export class ApiError extends Error {
+ constructor(readonly status: number, message: string, readonly body: unknown) {
+ super(message);
+ }
+}
+
+export interface Summary {
+ path: string;
+ type: string;
+ title: string;
+ slug: string;
+ publish: boolean;
+ acceptedAt: string;
+}
+
+export interface Note extends Summary {
+ name: string;
+ data: Record;
+ body: string;
+ rev: number;
+ author: string;
+ text: string;
+ backlinks: Summary[];
+ pending: Held | null;
+}
+
+export interface Reason {
+ code: string;
+ message: string;
+}
+
+export interface Held {
+ id: number;
+ path: string;
+ author: string;
+ source: "vault" | "api";
+ reasons: Reason[];
+ at: string;
+ status: string;
+ decidedAt: string | null;
+ text?: string;
+ current?: string | null;
+}
+
+export interface Revision {
+ rev: number;
+ author: string;
+ at: string;
+ action: string;
+ text?: string;
+}
+
+export interface SyncJob {
+ job: string;
+ at: string;
+ ok: boolean;
+ message: string;
+ counts: Record;
+ lastOkAt?: string | null;
+}
+
+export interface Status {
+ startedAt: string;
+ lastScan: { at: string; changed: number; held: number } | null;
+ lastError: { at: string; message: string } | null;
+ counts: { notes: number; published: number; held: number };
+ syncs: SyncJob[];
+}
+
+export interface Problem {
+ path: string;
+ message: string;
+}
+
+export type Outcome =
+ | { status: "accepted"; path: string }
+ | { status: "held"; path: string; held: Held }
+ | { status: "rejected"; path: string | null; reasons: Reason[] }
+ | { status: "unchanged"; path: string };
+
+export type Change =
+ | { op: "create"; data: Record; body?: string }
+ | { op: "update"; path: string; fields?: Record; body?: string };
+
+export type FullNote = Omit;
+
+export class ContentApi {
+ constructor(private readonly base: string, private readonly token: string) {}
+
+ private async call(method: "GET" | "POST", path: string, body?: unknown, okStatuses = [200]): Promise {
+ const res = await fetch(new URL(path, this.base), {
+ method,
+ headers: { authorization: `Bearer ${this.token}`, ...(body !== undefined ? { "content-type": "application/json" } : {}) },
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ });
+ const data = (await res.json().catch(() => null)) as unknown;
+ if (!okStatuses.includes(res.status)) {
+ throw new ApiError(res.status, (data as { error?: string } | null)?.error ?? `The content service answered ${res.status}`, data);
+ }
+ return data as T;
+ }
+
+ private get = (path: string, params: Record = {}) => {
+ const u = new URLSearchParams();
+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== "") u.set(k, String(v));
+ const s = u.toString();
+ return this.call("GET", s ? `${path}?${s}` : path);
+ };
+
+ status = () => this.get("/api/status");
+ notes = (p: { type?: string; q?: string; limit?: number; full?: boolean } = {}) =>
+ this.get("/api/notes", { type: p.type, q: p.q, limit: p.limit, full: p.full ? "true" : undefined });
+ fullNotes = (type: string) => this.get("/api/notes", { type, full: "true", limit: 1000 });
+ note = (path: string) => this.get("/api/note", { path });
+ noteByName = (name: string) => this.get("/api/note", { name });
+ history = (path: string) => this.get("/api/note/history", { path });
+ revision = (path: string, rev: number) => this.get("/api/note/revision", { path, rev });
+ held = (status = "pending") => this.get("/api/held", { status });
+ heldOne = (id: number) => this.get(`/api/held/${id}`);
+ problems = () => this.get("/api/problems");
+ syncRuns = (job: string) => this.get("/api/syncs", { job });
+
+ apply = (change: Change) => this.call("POST", "/api/changes", change, [200, 422]);
+ accept = (id: number) => this.call("POST", `/api/held/${id}/accept`, {}, [200, 422]);
+ reject = (id: number, restore: boolean) => this.call("POST", `/api/held/${id}/reject`, { restore }, [200, 422]);
+ rollback = (path: string, rev: number) => this.call("POST", "/api/note/rollback", { path, rev }, [200, 422]);
+ scan = () => this.call<{ changed: number; held: number }>("POST", "/api/scan", {});
+
+ /** Fetch an attachment to pass through to the browser. */
+ attachment = (name: string) =>
+ fetch(new URL(`/attachments/${encodeURIComponent(name)}`, this.base), { headers: { authorization: `Bearer ${this.token}` } });
+}
diff --git a/services/admin/src/diff.ts b/services/admin/src/diff.ts
new file mode 100644
index 0000000..b1dc19b
--- /dev/null
+++ b/services/admin/src/diff.ts
@@ -0,0 +1,60 @@
+/**
+ * A line diff for the review queue and history: what a change adds and
+ * removes, with unchanged lines folded down to a little context.
+ */
+export type DiffLine = { kind: "same" | "add" | "remove"; text: string } | { kind: "skip"; count: number };
+
+/** Notes are short, so a plain longest-common-subsequence table is enough. */
+const MAX_CELLS = 4_000_000;
+
+export function diffLines(before: string, after: string, context = 3): DiffLine[] {
+ const a = before === "" ? [] : before.replace(/\r\n/g, "\n").split("\n");
+ const b = after === "" ? [] : after.replace(/\r\n/g, "\n").split("\n");
+ let full: DiffLine[];
+ if (a.length * b.length > MAX_CELLS) {
+ // Too large to compare line by line: show it as replaced.
+ full = [...a.map((text) => ({ kind: "remove" as const, text })), ...b.map((text) => ({ kind: "add" as const, text }))];
+ } else {
+ const n = a.length;
+ const m = b.length;
+ const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
+ for (let i = n - 1; i >= 0; i--) {
+ for (let j = m - 1; j >= 0; j--) {
+ lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!);
+ }
+ }
+ full = [];
+ let i = 0;
+ let j = 0;
+ while (i < n && j < m) {
+ if (a[i] === b[j]) {
+ full.push({ kind: "same", text: a[i]! });
+ i++;
+ j++;
+ } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) full.push({ kind: "remove", text: a[i++]! });
+ else full.push({ kind: "add", text: b[j++]! });
+ }
+ while (i < n) full.push({ kind: "remove", text: a[i++]! });
+ while (j < m) full.push({ kind: "add", text: b[j++]! });
+ }
+ return fold(full, context);
+}
+
+function fold(lines: DiffLine[], context: number): DiffLine[] {
+ const changed = lines.map((l) => l.kind !== "same");
+ const keep = lines.map((_, i) => {
+ for (let k = Math.max(0, i - context); k <= Math.min(lines.length - 1, i + context); k++) if (changed[k]) return true;
+ return false;
+ });
+ const out: DiffLine[] = [];
+ let skipped = 0;
+ lines.forEach((l, i) => {
+ if (keep[i]) {
+ if (skipped) out.push({ kind: "skip", count: skipped });
+ skipped = 0;
+ out.push(l);
+ } else skipped++;
+ });
+ if (skipped) out.push({ kind: "skip", count: skipped });
+ return out;
+}
diff --git a/services/admin/src/html.ts b/services/admin/src/html.ts
new file mode 100644
index 0000000..81ef0eb
--- /dev/null
+++ b/services/admin/src/html.ts
@@ -0,0 +1,46 @@
+/**
+ * A tagged template that escapes every value unless it is already HTML. The
+ * admin page shows text agents wrote, so nothing reaches the page unescaped.
+ */
+export class Html {
+ constructor(readonly value: string) {}
+ toString() {
+ return this.value;
+ }
+}
+
+export const raw = (s: string) => new Html(s);
+
+export function escape(s: unknown): string {
+ return String(s ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+const part = (v: unknown): string => {
+ if (v instanceof Html) return v.value;
+ if (Array.isArray(v)) return v.map(part).join("");
+ if (v === null || v === undefined || v === false) return "";
+ return escape(v);
+};
+
+export function html(strings: TemplateStringsArray, ...values: unknown[]): Html {
+ let out = strings[0] ?? "";
+ values.forEach((v, i) => {
+ out += part(v) + (strings[i + 1] ?? "");
+ });
+ return new Html(out);
+}
+
+/** A query string from the set values only. */
+export function qs(params: Record): string {
+ const u = new URLSearchParams();
+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "" && v !== false) u.set(k, String(v));
+ const s = u.toString();
+ return s ? `?${s}` : "";
+}
+
+export const when = (d: string | null | undefined) => (d ? d.replace("T", " ").slice(0, 16) + " UTC" : "never");
diff --git a/services/admin/src/layout.ts b/services/admin/src/layout.ts
new file mode 100644
index 0000000..7b8dc0c
--- /dev/null
+++ b/services/admin/src/layout.ts
@@ -0,0 +1,49 @@
+import { html, type Html } from "./html.ts";
+
+export interface Flash {
+ msg?: string;
+ err?: string;
+ reasons?: string[];
+}
+
+const nav: [string, string][] = [
+ ["/", "Overview"],
+ ["/review", "Review"],
+ ["/notes", "Notes"],
+ ["/drafts", "Drafts"],
+ ["/capture", "Capture"],
+ ["/store", "Store"],
+ ["/problems", "Problems"],
+];
+
+export function layout(p: { title: string; path: string; held: number; flash?: Flash; body: Html }): Html {
+ const current = (href: string) => (href === "/" ? p.path === "/" : p.path.startsWith(href));
+ return html`
+
+
+
+
+
+${p.title} · GregKilmer.com admin
+
+
+
+
+
+
+
+${p.flash?.msg ? html`${p.flash.msg}
` : ""}
+${p.flash?.err
+ ? html`${p.flash.err}${p.flash.reasons?.length ? html`
${p.flash.reasons.map((r) => html`- ${r}
`)}
` : ""}
`
+ : ""}
+${p.body}
+
+
+`;
+}
diff --git a/services/admin/src/main.ts b/services/admin/src/main.ts
new file mode 100644
index 0000000..93dbe58
--- /dev/null
+++ b/services/admin/src/main.ts
@@ -0,0 +1,19 @@
+/**
+ * Starts the admin page. It talks to the content service with the admin
+ * token and must be published on the tailnet only.
+ */
+import { ContentApi } from "./client.ts";
+import { createAdminServer } from "./server.ts";
+
+const contentUrl = process.env.CONTENT_URL ?? "http://localhost:4310";
+const token = process.env.ADMIN_TOKEN;
+if (!token) throw new Error("ADMIN_TOKEN is required: the content service's admin token");
+const port = Number(process.env.PORT ?? 4320);
+const host = process.env.HOST ?? "0.0.0.0";
+
+const server = createAdminServer(new ContentApi(contentUrl, token));
+server.listen(port, host, () => console.log(`Admin page on ${host}:${port}, using ${contentUrl}`));
+
+const stop = () => server.close(() => process.exit(0));
+process.on("SIGTERM", stop);
+process.on("SIGINT", stop);
diff --git a/services/admin/src/markdown.ts b/services/admin/src/markdown.ts
new file mode 100644
index 0000000..ea2628b
--- /dev/null
+++ b/services/admin/src/markdown.ts
@@ -0,0 +1,28 @@
+/**
+ * Draft previews. Raw HTML in a note is shown as text and only http, https,
+ * mailto and relative links survive, so a draft an agent wrote cannot run
+ * anything on the admin page. Wikilinks become links to the note.
+ */
+import { Marked, type Tokens } from "marked";
+import { escape } from "./html.ts";
+
+const safeUrl = (href: string) => (/^(https?:|mailto:|\/|#|\.\/|\.\.\/)/i.test(href.trim()) || !/^[a-z][a-z0-9+.-]*:/i.test(href.trim()) ? href : "#");
+
+export function renderMarkdown(body: string, opts: { noteHref: (name: string) => string; imageSrc: (file: string) => string }): string {
+ const marked = new Marked({
+ gfm: true,
+ renderer: {
+ html: ({ text }: Tokens.HTML | Tokens.Tag) => escape(text),
+ link({ href, title, tokens }: Tokens.Link) {
+ const t = title ? ` title="${escape(title)}"` : "";
+ return `${this.parser.parseInline(tokens)}`;
+ },
+ image: ({ href, text }: Tokens.Image) => `
`,
+ },
+ });
+ // ![[file.png]] embeds and [[Note|label]] links, before Markdown sees them.
+ const withLinks = body
+ .replace(/!\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g, (_, f: string) => `)})`)
+ .replace(/\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g, (_, n: string, label?: string) => `[${label ?? n}](${opts.noteHref(n.trim())})`);
+ return marked.parse(withLinks, { async: false }) as string;
+}
diff --git a/services/admin/src/pages.ts b/services/admin/src/pages.ts
new file mode 100644
index 0000000..a752ec9
--- /dev/null
+++ b/services/admin/src/pages.ts
@@ -0,0 +1,289 @@
+/**
+ * Page bodies. Each takes data already fetched from the content service and
+ * returns HTML; nothing here talks to the network.
+ */
+import { libraryStates, media } from "@gk/schema";
+import type { FullNote, Held, Note, Problem, Revision, Status, Summary, SyncJob } from "./client.ts";
+import { diffLines } from "./diff.ts";
+import { html, qs, raw, when, type Html } from "./html.ts";
+import { renderMarkdown } from "./markdown.ts";
+
+export const noteHref = (path: string) => `/note${qs({ path })}`;
+
+const authorLabel: Record = { greg: "You", agent: "An agent", admin: "Admin page" };
+const who = (a: string) => authorLabel[a] ?? a;
+
+const publishPill = (publish: boolean) => (publish ? html`Live` : html`Draft`);
+
+const reasonList = (reasons: Held["reasons"]) =>
+ reasons.length
+ ? reasons.map((r) => html`${r.code} ${r.message}
`)
+ : html`Waiting for review: no rule flagged it.
`;
+
+export function diffView(before: string, after: string): Html {
+ const lines = diffLines(before, after);
+ if (!lines.some((l) => l.kind === "add" || l.kind === "remove")) return html`No differences.
`;
+ return html`${lines.map((l) =>
+ l.kind === "skip" ? html`
${l.count} unchanged lines
` : html`
${l.text}
`,
+ )}
`;
+}
+
+export function preview(body: string): Html {
+ if (!body.trim()) return html`This note has no body text.
`;
+ return html`${raw(
+ renderMarkdown(body, {
+ noteHref: (name) => `/go${qs({ name })}`,
+ imageSrc: (file) => `/attachments/${encodeURIComponent(file)}`,
+ }),
+ )}
`;
+}
+
+// ------------------------------------------------------------ overview
+
+export function overview(s: Status, held: Held[], problems: Problem[]): Html {
+ const syncs = s.syncs;
+ return html`
+Overview
+What the site is serving, what is waiting for you, and whether the jobs that feed it are running.
+
+
${s.counts.published}
live notes of ${s.counts.notes}
+
+
+
+Content service
+
+
+ - Running since
- ${when(s.startedAt)}
+ - Last full scan
- ${s.lastScan ? html`${when(s.lastScan.at)}: ${s.lastScan.changed} changed, ${s.lastScan.held} held` : "not yet"}
+ - Last error
- ${s.lastError ? html`${when(s.lastError.at)} ${s.lastError.message}` : "none"}
+
+
+
+Sync jobs
+${syncs.length
+ ? html`
+| Job | Last run | Result | Last success | Counts |
+${syncs.map(syncRow)}
`
+ : html`No sync job has reported yet. PersonalReader, BoardGameGeek and the store syncs report here once they run.
`}`;
+}
+
+const syncRow = (j: SyncJob) => html`
+ | ${j.job} |
+ ${when(j.at)} |
+ ${j.ok ? html`OK` : html`Failed`} ${j.message} |
+ ${when(j.lastOkAt)} |
+ ${Object.entries(j.counts).map(([k, v]) => `${k} ${v}`).join(", ")} |
+
`;
+
+export function syncHistory(job: string, runs: SyncJob[]): Html {
+ return html`${job}
Back to the overview
+| When | Result | Counts |
+${runs.map((r) => html`| ${when(r.at)} | ${r.ok ? html`OK` : html`Failed`} ${r.message} | ${Object.entries(r.counts).map(([k, v]) => `${k} ${v}`).join(", ")} |
`)}
+
`;
+}
+
+// ------------------------------------------------------------ review
+
+const statuses = ["pending", "accepted", "rejected", "superseded"];
+
+export function reviewList(held: Held[], status: string): Html {
+ return html`Review
+Changes the rules held. The site keeps showing the last accepted version until you decide.
+${statuses.map((s) => (s === status ? html`${label(s)}` : html`${label(s)}`))}
+${held.length
+ ? html`| Note | From | Why | When |
+${held.map((h) => html`
+ | ${h.path} |
+ ${who(h.author)}${h.source === "vault" ? " in Obsidian" : ""} |
+ ${h.reasons.map((r) => r.code).join(", ") || "review"} |
+ ${when(h.at)} |
`)}
+
`
+ : html`${status === "pending" ? "Nothing is waiting." : "None."}
`}`;
+}
+
+const label = (s: string) => s[0]!.toUpperCase() + s.slice(1);
+
+export function reviewOne(h: Held): Html {
+ const pending = h.status === "pending";
+ return html`${h.path}
+${who(h.author)}${h.source === "vault" ? " in Obsidian" : " through the API"}, ${when(h.at)}. ${pending ? "" : html`${label(h.status)} ${when(h.decidedAt)}`}
+${reasonList(h.reasons)}
+${pending
+ ? html`
+
+
+ ${h.source === "vault" && h.current
+ ? html``
+ : ""}
+
`
+ : ""}
+${h.current === null ? "New note" : "Changes against the live version"}
+${diffView(h.current ?? "", h.text ?? "")}
+Open the live note
`;
+}
+
+// ------------------------------------------------------------ notes
+
+export function notesList(p: { title: string; lede: string; action: string; notes: Summary[]; types: string[]; type?: string; q?: string }): Html {
+ return html`${p.title}
+${p.lede}
+
+${p.notes.length
+ ? html`| Title | Type | State | Changed |
+${p.notes.map((n) => html`| ${n.title} ${n.path} | ${n.type} | ${publishPill(n.publish)} | ${when(n.acceptedAt)} |
`)}
+
`
+ : html`No notes match.
`}`;
+}
+
+const fieldValue = (v: unknown): Html =>
+ typeof v === "object" && v !== null ? html`${JSON.stringify(v, null, 2)}` : html`${String(v)}`;
+
+export function noteView(n: Note, history: Revision[]): Html {
+ const shown = Object.entries(n.data).filter(([k]) => !["type", "title"].includes(k));
+ return html`${n.title}
+${publishPill(n.publish)} ${n.type} · ${n.path} · last changed by ${who(n.author).toLowerCase()} ${when(n.acceptedAt)}
+${n.pending ? html`` : ""}
+
+
+
+Preview
+${preview(n.body)}
+Fields
+${shown.map(([k, v]) => html`- ${k}
- ${fieldValue(v)}
`)}
+Linked from
+${n.backlinks.length ? html`${n.backlinks.map((b) => html`- ${b.title} ${b.type}
`)}
` : html`Nothing links here yet.
`}
+History
+${historyTable(n, history)}`;
+}
+
+function historyTable(n: Note, history: Revision[]): Html {
+ return html`| Version | Who | What | When | |
+${history.map((r) => html`
+ | ${r.rev}${r.rev === n.rev ? html` current` : ""} |
+ ${who(r.author)} | ${r.action} | ${when(r.at)} |
+ ${r.rev === n.rev || r.action === "remove" ? "" : html`Compare and roll back`} |
+
`)}
+
`;
+}
+
+export function revisionView(n: Note, r: Revision): Html {
+ return html`${n.title}, version ${r.rev}
+${who(r.author)}, ${when(r.at)}. Showing what rolling back would change in the live version ${n.rev}.
+
+${diffView(n.text, r.text ?? "")}`;
+}
+
+// ------------------------------------------------------------ capture
+
+export function capture(kinds: string[], today: string): Html {
+ return html`Capture
+Add a line to the Now log or something to the Library. Both are written into the vault, so they show up in Obsidian too.
+`;
+}
+
+// ------------------------------------------------------------ store
+
+interface Category {
+ name: string;
+ ordersOpen: boolean;
+ reopens?: string;
+ turnaround?: string;
+}
+
+export function store(settings: FullNote | undefined, lines: FullNote[]): Html {
+ const categories = (settings?.data.categories as Category[] | undefined) ?? [];
+ return html`Store
+Open and close orders by category, and set how many order slots each made to order line has open.
+Categories
+${settings
+ ? html`${categories.map(
+ (c) => html`
`,
+ )}
`
+ : html`There is no Store/_settings note yet.
`}
+Product lines
+| Line | Category | Sold | Open slots or stock |
+${lines.map((l) => {
+ const slots = l.data.slots as { open: number; total: number } | undefined;
+ const qty = l.data.quantity as number | undefined;
+ return html`
+ | ${l.title} ${publishPill(l.publish)} |
+ ${String(l.data.category)} |
+ ${String(l.data.mode)}, ${String(l.data.channel)} |
+ ${slots
+ ? html``
+ : qty !== undefined
+ ? html``
+ : html`Not counted here`} |
+
`;
+ })}
+
`;
+}
+
+// ------------------------------------------------------------ problems
+
+export function problemsView(problems: Problem[]): Html {
+ return html`Problems
+Things that are wrong across the folder as a whole, like links to notes that do not exist. They do not stop a note going live.
+${problems.length
+ ? html`| Note | Problem |
+${problems.map((p) => html`| ${p.path} | ${p.message} |
`)}
`
+ : html`No problems.
`}`;
+}
diff --git a/services/admin/src/server.ts b/services/admin/src/server.ts
new file mode 100644
index 0000000..27bdaaa
--- /dev/null
+++ b/services/admin/src/server.ts
@@ -0,0 +1,357 @@
+/**
+ * The admin page's server. It renders HTML on the server and every action is
+ * a plain form post, so it works without client scripts. It holds the
+ * content service's admin token; the browser never sees it.
+ *
+ * It must only be reachable on the tailnet. On top of that, every form post
+ * has to come from this page itself, so another site cannot post to it.
+ */
+import { readFileSync } from "node:fs";
+import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
+import { basename } from "node:path";
+import { Readable } from "node:stream";
+import { noteTypeNames } from "@gk/schema";
+import { ApiError, type ContentApi, type Outcome } from "./client.ts";
+import { html, qs, type Html } from "./html.ts";
+import { layout, type Flash } from "./layout.ts";
+import * as pages from "./pages.ts";
+
+const css = readFileSync(new URL("../public/admin.css", import.meta.url), "utf8");
+
+class Redirect {
+ constructor(readonly to: string) {}
+}
+
+type Form = URLSearchParams;
+type Page = { title: string; body: Html };
+
+export function createAdminServer(api: ContentApi, opts: { now?: () => Date } = {}) {
+ const today = () => (opts.now?.() ?? new Date()).toISOString().slice(0, 10);
+
+ /** Where to go after an action, carrying what happened. */
+ const after = (to: string, o: Outcome, saved: string) => {
+ const [path, query = ""] = to.split("?");
+ const p = new URLSearchParams(query);
+ if (o.status === "accepted" || o.status === "unchanged") p.set("msg", saved);
+ else if (o.status === "held") {
+ p.set("msg", "Saved for review. It is not live until you accept it.");
+ } else {
+ p.set("err", "Not saved.");
+ for (const r of o.reasons) p.append("r", r.message);
+ }
+ return new Redirect(`${path}?${p}`);
+ };
+
+ const pathOfOutcome = (o: Outcome) => (o.status === "rejected" ? null : o.path);
+
+ async function get(path: string, q: URLSearchParams): Promise {
+ switch (path) {
+ case "/": {
+ const [status, held, problems] = await Promise.all([api.status(), api.held(), api.problems()]);
+ return { title: "Overview", body: pages.overview(status, held, problems) };
+ }
+ case "/syncs": {
+ const job = q.get("job") ?? "";
+ return { title: job, body: pages.syncHistory(job, await api.syncRuns(job)) };
+ }
+ case "/review": {
+ const status = q.get("status") ?? "pending";
+ return { title: "Review", body: pages.reviewList(await api.held(status), status) };
+ }
+ case "/notes":
+ case "/drafts": {
+ const drafts = path === "/drafts";
+ const type = q.get("type") || undefined;
+ const text = q.get("q") || undefined;
+ let notes = await api.notes({ type, q: text, limit: 1000 });
+ if (drafts) notes = notes.filter((n) => !n.publish);
+ if (!text) notes = notes.sort((a, b) => b.acceptedAt.localeCompare(a.acceptedAt)).slice(0, 300);
+ return {
+ title: drafts ? "Drafts" : "Notes",
+ body: pages.notesList({
+ title: drafts ? "Drafts" : "Notes",
+ lede: drafts
+ ? "Notes that are not on the site yet. Open one to preview it and publish it."
+ : "Every note the site knows about, most recently changed first.",
+ action: path,
+ notes,
+ types: noteTypeNames,
+ type,
+ q: text,
+ }),
+ };
+ }
+ case "/note": {
+ const p = required(q, "path");
+ const [note, history] = await Promise.all([api.note(p), api.history(p)]);
+ return { title: note.title, body: pages.noteView(note, history) };
+ }
+ case "/note/revision": {
+ const p = required(q, "path");
+ const [note, rev] = await Promise.all([api.note(p), api.revision(p, Number(required(q, "rev")))]);
+ return { title: `${note.title}, version ${rev.rev}`, body: pages.revisionView(note, rev) };
+ }
+ case "/go": {
+ const note = await api.noteByName(required(q, "name"));
+ return new Redirect(pages.noteHref(note.path));
+ }
+ case "/capture": {
+ const kinds = (await api.fullNotes("status-kinds"))[0]?.data.kinds as { label: string }[] | undefined;
+ return { title: "Capture", body: pages.capture((kinds ?? []).map((k) => k.label), today()) };
+ }
+ case "/store": {
+ const [settings, lines] = await Promise.all([api.fullNotes("store-settings"), api.fullNotes("product-line")]);
+ return { title: "Store", body: pages.store(settings[0], lines.sort((a, b) => Number(a.data.order ?? 99) - Number(b.data.order ?? 99))) };
+ }
+ case "/problems":
+ return { title: "Problems", body: pages.problemsView(await api.problems()) };
+ }
+ const review = path.match(/^\/review\/(\d+)$/);
+ if (review) {
+ const h = await api.heldOne(Number(review[1]));
+ return { title: `Review ${h.path}`, body: pages.reviewOne(h) };
+ }
+ throw new ApiError(404, "There is no page here.", null);
+ }
+
+ async function post(path: string, f: Form): Promise {
+ switch (path) {
+ case "/scan": {
+ const r = await api.scan();
+ return new Redirect(`/${qs({ msg: `Scanned: ${r.changed} changed, ${r.held} held.` })}`);
+ }
+ case "/note/rollback": {
+ const p = required(f, "path");
+ const o = await api.rollback(p, Number(required(f, "rev")));
+ return after(pages.noteHref(p), o, "Rolled back. The site and the vault show that version again.");
+ }
+ case "/note/publish": {
+ const p = required(f, "path");
+ const publish = f.get("publish") === "1";
+ const o = await api.apply({ op: "update", path: p, fields: { publish } });
+ return after(pages.noteHref(p), o, publish ? "Published." : "Taken off the site.");
+ }
+ case "/capture/status": {
+ const subject = (f.get("subject") ?? "").trim().replace(/^\[\[|\]\]$/g, "");
+ const o = await api.apply({
+ op: "create",
+ data: {
+ type: "status",
+ title: required(f, "title"),
+ kind: required(f, "kind"),
+ date: f.get("date") || today(),
+ ...optional(f, ["note"]),
+ ...(subject ? { subject: `[[${subject}]]` } : {}),
+ ...(f.get("pinned") === "1" ? { pinned: true } : {}),
+ publish: f.get("publish") === "1",
+ },
+ });
+ return after("/capture", o, "Status posted.");
+ }
+ case "/capture/library": {
+ const year = f.get("year");
+ const o = await api.apply({
+ op: "create",
+ data: {
+ type: "library-item",
+ title: required(f, "title"),
+ medium: required(f, "medium"),
+ state: required(f, "state"),
+ ...optional(f, ["creator", "group", "verdict", "note"]),
+ ...(year ? { year: Number(year) } : {}),
+ publish: f.get("publish") === "1",
+ },
+ });
+ const added = pathOfOutcome(o);
+ if (f.get("alsoStatus") === "1" && o.status === "accepted" && added) {
+ const s = await api.apply({
+ op: "create",
+ data: {
+ type: "status",
+ title: required(f, "title"),
+ kind: required(f, "kind"),
+ date: today(),
+ subject: `[[${basename(added, ".md")}]]`,
+ publish: f.get("publish") === "1",
+ },
+ });
+ return after("/capture", s, "Added to the Library and posted a status.");
+ }
+ return after("/capture", o, "Added to the Library.");
+ }
+ case "/store/category": {
+ const [settings] = await api.fullNotes("store-settings");
+ if (!settings) throw new ApiError(404, "There is no Store/_settings note yet.", null);
+ const name = required(f, "name");
+ const categories = (settings.data.categories as Record[]).map((c) => {
+ if (c.name !== name) return c;
+ const next: Record = { ...c, ordersOpen: f.get("ordersOpen") === "1" };
+ for (const k of ["reopens", "turnaround"]) {
+ const v = (f.get(k) ?? "").trim();
+ if (v) next[k] = v;
+ else delete next[k];
+ }
+ return next;
+ });
+ const o = await api.apply({ op: "update", path: settings.path, fields: { categories } });
+ return after("/store", o, `${name} saved.`);
+ }
+ case "/store/stock": {
+ const p = required(f, "path");
+ let fields: Record;
+ if (f.has("open")) {
+ const note = await api.note(p);
+ fields = { slots: { ...(note.data.slots as object), open: Number(f.get("open")) } };
+ } else fields = { quantity: Number(required(f, "quantity")) };
+ const o = await api.apply({ op: "update", path: p, fields });
+ return after("/store", o, "Saved.");
+ }
+ }
+ const decide = path.match(/^\/review\/(\d+)\/(accept|reject)$/);
+ if (decide) {
+ const id = Number(decide[1]);
+ const o = decide[2] === "accept" ? await api.accept(id) : await api.reject(id, f.get("restore") === "1");
+ if (o.status === "rejected") return after(`/review/${id}`, o, "");
+ return new Redirect(`/review${qs({ msg: decide[2] === "accept" ? "Accepted. It is live." : "Rejected. The site keeps the live version." })}`);
+ }
+ throw new ApiError(404, "There is no action here.", null);
+ }
+
+ async function handle(req: IncomingMessage, res: ServerResponse) {
+ const url = new URL(req.url ?? "/", "http://admin");
+ const method = req.method ?? "GET";
+ res.setHeader("x-frame-options", "DENY");
+ res.setHeader("referrer-policy", "same-origin");
+ res.setHeader(
+ "content-security-policy",
+ "default-src 'self'; img-src 'self' data: https:; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'none'; form-action 'self'; frame-ancestors 'none'",
+ );
+
+ if (url.pathname === "/admin.css") {
+ res.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "max-age=300" });
+ return res.end(css);
+ }
+ if (url.pathname === "/health") {
+ res.writeHead(200, { "content-type": "application/json" });
+ return res.end('{"ok":true}');
+ }
+
+ const attachment = url.pathname.match(/^\/attachments\/([^/]+)$/);
+ if (method === "GET" && attachment) {
+ const r = await api.attachment(decodeURIComponent(attachment[1] ?? ""));
+ res.writeHead(r.status, { "content-type": r.headers.get("content-type") ?? "application/octet-stream", "x-content-type-options": "nosniff" });
+ if (r.body) Readable.fromWeb(r.body as never).pipe(res);
+ else res.end();
+ return;
+ }
+
+ if (method === "POST") {
+ if (!sameOrigin(req)) return text(res, 403, "Form posts must come from the admin page itself.");
+ const form = new URLSearchParams(await readBody(req));
+ const r = await post(url.pathname, form);
+ res.writeHead(303, { location: r.to });
+ return res.end();
+ }
+ if (method !== "GET") return text(res, 405, "Method not allowed");
+
+ const out = await get(url.pathname, url.searchParams);
+ if (out instanceof Redirect) {
+ res.writeHead(303, { location: out.to });
+ return res.end();
+ }
+ const held = (await api.held().catch(() => [])).length;
+ page(res, 200, out, url, held);
+ }
+
+ function page(res: ServerResponse, status: number, p: Page, url: URL, held: number) {
+ const flash: Flash = {
+ msg: url.searchParams.get("msg") ?? undefined,
+ err: url.searchParams.get("err") ?? undefined,
+ reasons: url.searchParams.getAll("r"),
+ };
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
+ res.end(layout({ title: p.title, path: url.pathname, held, flash, body: p.body }).toString());
+ }
+
+ return createServer((req, res) => {
+ handle(req, res).catch(async (err: unknown) => {
+ if (res.headersSent) return res.end();
+ const url = new URL(req.url ?? "/", "http://admin");
+ if (err instanceof ApiError) {
+ const reasons = (err.body as { reasons?: { message: string }[] } | null)?.reasons?.map((r) => r.message) ?? [];
+ const message = err.status === 404 ? "Not found." : err.message;
+ // A failed form post goes back where it came from, with the reason.
+ if (req.method === "POST") {
+ const back = safeBack(req.headers.referer) ?? new URL("/", "http://admin");
+ for (const k of ["msg", "err", "r"]) back.searchParams.delete(k);
+ back.searchParams.set("err", message);
+ for (const r of reasons) back.searchParams.append("r", r);
+ res.writeHead(303, { location: back.pathname + back.search });
+ return res.end();
+ }
+ return page(res, err.status === 404 ? 404 : err.status === 400 ? 400 : 502, { title: "Not available", body: errorBody(message) }, url, 0);
+ }
+ console.error(err);
+ page(res, 500, { title: "Error", body: errorBody("Something went wrong. Is the content service running?") }, url, 0);
+ });
+ });
+}
+
+const errorBody = (message: string) => html`Not available
${message}
Back to the overview
`;
+
+function required(f: URLSearchParams, k: string): string {
+ const v = (f.get(k) ?? "").trim();
+ if (!v) throw new ApiError(400, `${k} is required`, null);
+ return v;
+}
+
+function optional(f: URLSearchParams, keys: string[]): Record {
+ const out: Record = {};
+ for (const k of keys) {
+ const v = (f.get(k) ?? "").trim();
+ if (v) out[k] = v;
+ }
+ return out;
+}
+
+/** Browsers send Sec-Fetch-Site or Origin with every form post; both must say this page. */
+function sameOrigin(req: IncomingMessage): boolean {
+ const site = req.headers["sec-fetch-site"];
+ if (site) return site === "same-origin" || site === "none";
+ const origin = req.headers.origin;
+ if (origin) {
+ try {
+ return new URL(origin).host === req.headers.host;
+ } catch {
+ return false;
+ }
+ }
+ // Neither header: not a browser (a script on the tailnet), which the network already limits.
+ return true;
+}
+
+/** The page a failed post came from, as a local path only. */
+function safeBack(referer: string | undefined): URL | null {
+ if (!referer) return null;
+ try {
+ return new URL(new URL(referer).pathname + new URL(referer).search, "http://admin");
+ } catch {
+ return null;
+ }
+}
+
+async function readBody(req: IncomingMessage): Promise {
+ const chunks: Buffer[] = [];
+ let size = 0;
+ for await (const c of req) {
+ size += (c as Buffer).length;
+ if (size > 200_000) throw new ApiError(413, "The form is too large", null);
+ chunks.push(c as Buffer);
+ }
+ return Buffer.concat(chunks).toString("utf8");
+}
+
+function text(res: ServerResponse, status: number, body: string) {
+ res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
+ res.end(body);
+}
diff --git a/services/admin/test/admin.test.ts b/services/admin/test/admin.test.ts
new file mode 100644
index 0000000..8a8c415
--- /dev/null
+++ b/services/admin/test/admin.test.ts
@@ -0,0 +1,152 @@
+import { existsSync, readFileSync } from "node:fs";
+import type { AddressInfo } from "node:net";
+import type { Server } from "node:http";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { parseTokens } from "../../content/src/config.ts";
+import { createHttpServer } from "../../content/src/http.ts";
+import { book, rig, type Rig } from "../../content/test/helpers.ts";
+import { ContentApi } from "../src/client.ts";
+import { createAdminServer } from "../src/server.ts";
+
+const ADMIN = "d".repeat(24);
+let r: Rig;
+let content: Server;
+let admin: Server;
+let base: string;
+
+const listen = async (s: Server) => {
+ await new Promise((ok) => s.listen(0, "127.0.0.1", ok));
+ return `http://127.0.0.1:${(s.address() as AddressInfo).port}`;
+};
+
+beforeAll(async () => {
+ r = rig();
+ r.put({ type: "status-kinds", title: "Status kinds", publish: true, kinds: [{ label: "Reading", icon: "book" }, { label: "Playing", icon: "dice" }] });
+ r.put(
+ {
+ type: "store-settings",
+ title: "Store settings",
+ publish: true,
+ categories: [
+ { name: "Tabletop", ordersOpen: true, turnaround: "2 weeks" },
+ { name: "Paper", ordersOpen: false },
+ ],
+ },
+ );
+ r.put({
+ type: "product-line",
+ title: "Proxy cards",
+ publish: true,
+ category: "Tabletop",
+ mode: "made to order",
+ channel: "here",
+ summary: "Cards.",
+ slots: { open: 3, total: 5 },
+ priceTiers: [{ band: "1-10", price: "$1" }],
+ });
+ r.put(book("Mistborn"), "Allomancy.");
+ r.service.scan();
+ content = createHttpServer(r.service, { tokens: parseTokens(`admin:${ADMIN}`) });
+ const contentUrl = await listen(content);
+ admin = createAdminServer(new ContentApi(contentUrl, ADMIN), { now: () => new Date("2026-10-11T12:00:00Z") });
+ base = await listen(admin);
+});
+afterAll(() => {
+ admin.close();
+ content.close();
+ r.done();
+});
+
+const get = async (path: string) => {
+ const res = await fetch(base + path, { redirect: "manual" });
+ return { status: res.status, text: await res.text(), location: res.headers.get("location") };
+};
+const post = async (path: string, form: Record, headers: Record = {}) => {
+ const res = await fetch(base + path, {
+ method: "POST",
+ redirect: "manual",
+ headers: { "content-type": "application/x-www-form-urlencoded", "sec-fetch-site": "same-origin", ...headers },
+ body: new URLSearchParams(form).toString(),
+ });
+ return { status: res.status, location: res.headers.get("location") ?? "" };
+};
+
+describe("admin page", () => {
+ it("shows the overview", async () => {
+ const page = await get("/");
+ expect(page.status).toBe(200);
+ expect(page.text).toContain("live notes of 4");
+ expect(page.text).toContain("No sync job has reported yet");
+ });
+
+ it("refuses a form posted from another site", async () => {
+ expect((await post("/scan", {}, { "sec-fetch-site": "cross-site" })).status).toBe(403);
+ });
+
+ it("captures a Library item with a status, written into the vault", async () => {
+ const res = await post("/capture/library", { title: "Warbreaker", medium: "book", state: "now", publish: "1", alsoStatus: "1", kind: "Reading" });
+ expect(res.location).toContain("msg=");
+ expect(existsSync(join(r.site, "Library/Books/Warbreaker.md"))).toBe(true);
+ const status = r.store.list({ type: "status" })[0]!;
+ expect(status.data).toMatchObject({ kind: "Reading", subject: "[[Warbreaker]]", date: "2026-10-11" });
+ expect(status.author).toBe("admin");
+ });
+
+ it("sends a rejected capture back with the reasons", async () => {
+ const res = await post("/capture/library", { title: "Warbreaker", medium: "book", state: "now" });
+ expect(res.location).toMatch(/err=.*r=/);
+ });
+
+ it("reviews a held change with a diff and accepts it", async () => {
+ const o = r.service.apply({ op: "update", path: "Library/Books/Mistborn.md", fields: { verdict: "A heist." } }, "agent");
+ expect(o.status).toBe("held");
+ const id = o.status === "held" ? o.held.id : 0;
+ const list = await get("/review");
+ expect(list.text).toContain(`/review/${id}`);
+ const one = await get(`/review/${id}`);
+ expect(one.text).toContain('verdict: A heist.
');
+ expect(one.text).toContain("agent-edit");
+ const res = await post(`/review/${id}/accept`, {});
+ expect(res.location).toContain("Accepted");
+ expect(readFileSync(join(r.site, "Library/Books/Mistborn.md"), "utf8")).toContain("verdict: A heist.");
+ });
+
+ it("previews a note, shows its history and rolls it back", async () => {
+ const page = await get("/note?path=Library%2FBooks%2FMistborn.md");
+ expect(page.text).toContain("Allomancy.
");
+ expect(page.text).toContain("Compare and roll back");
+ const rev = await get("/note/revision?path=Library%2FBooks%2FMistborn.md&rev=1");
+ expect(rev.text).toContain('verdict: A heist.
');
+ await post("/note/rollback", { path: "Library/Books/Mistborn.md", rev: "1" });
+ expect(r.store.get("Library/Books/Mistborn.md")!.data.verdict).toBeUndefined();
+ });
+
+ it("publishes a draft", async () => {
+ r.put(book("Elantris", { publish: false }));
+ r.service.scan();
+ expect((await get("/drafts")).text).toContain("Elantris");
+ await post("/note/publish", { path: "Library/Books/Elantris.md", publish: "1" });
+ expect(r.store.get("Library/Books/Elantris.md")!.publish).toBe(true);
+ });
+
+ it("opens and closes orders and sets slots", async () => {
+ expect((await get("/store")).text).toContain("Proxy cards");
+ await post("/store/category", { name: "Paper", ordersOpen: "1", turnaround: "3 weeks" });
+ await post("/store/category", { name: "Tabletop" });
+ const cats = r.store.get("Store/_settings.md")!.data.categories;
+ expect(cats).toEqual([
+ { name: "Tabletop", ordersOpen: false },
+ { name: "Paper", ordersOpen: true, turnaround: "3 weeks" },
+ ]);
+ await post("/store/stock", { path: "Store/Lines/Proxy cards.md", open: "1" });
+ expect(r.store.get("Store/Lines/Proxy cards.md")!.data.slots).toEqual({ open: 1, total: 5 });
+ });
+
+ it("shows sync jobs once they report", async () => {
+ r.store.reportSync({ job: "personalreader", at: "2026-10-11T11:00:00Z", ok: false, message: "Suwayomi did not answer", counts: { updated: 0 } });
+ const page = await get("/");
+ expect(page.text).toContain("personalreader");
+ expect(page.text).toContain("Suwayomi did not answer");
+ });
+});
diff --git a/services/admin/test/units.test.ts b/services/admin/test/units.test.ts
new file mode 100644
index 0000000..662e559
--- /dev/null
+++ b/services/admin/test/units.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { diffLines } from "../src/diff.ts";
+import { html, raw } from "../src/html.ts";
+import { renderMarkdown } from "../src/markdown.ts";
+
+const md = (s: string) => renderMarkdown(s, { noteHref: (n) => `/go?name=${encodeURIComponent(n)}`, imageSrc: (f) => `/attachments/${f}` });
+
+describe("diff", () => {
+ it("marks added and removed lines and folds the rest", () => {
+ const before = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"].join("\n");
+ const after = ["a", "b", "c", "d", "e", "F", "g", "h", "i", "j"].join("\n");
+ const d = diffLines(before, after, 1);
+ expect(d).toEqual([
+ { kind: "skip", count: 4 },
+ { kind: "same", text: "e" },
+ { kind: "remove", text: "f" },
+ { kind: "add", text: "F" },
+ { kind: "same", text: "g" },
+ { kind: "skip", count: 3 },
+ ]);
+ });
+
+ it("shows a new note as all added", () => {
+ expect(diffLines("", "x\ny")).toEqual([
+ { kind: "add", text: "x" },
+ { kind: "add", text: "y" },
+ ]);
+ });
+});
+
+describe("html", () => {
+ it("escapes values but not nested html", () => {
+ expect(html`${""}${html`${"&"}`}${raw("
")}
`.toString()).toBe("<b>&
");
+ });
+});
+
+describe("draft previews", () => {
+ it("never lets a draft run script", () => {
+ const out = md('\n\n[x](javascript:alert(1))
');
+ expect(out).not.toMatch(/