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
+5 -1
View File
@@ -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=<the admin token> npm run admin # http://localhost:4320
```
## The old site
+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 }` |
+25
View File
@@ -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",
+3 -2
View File
@@ -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",
+16
View File
@@ -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"
}
}
+79
View File
@@ -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; }
+136
View File
@@ -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<string, unknown>;
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<string, number>;
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<string, unknown>; body?: string }
| { op: "update"; path: string; fields?: Record<string, unknown>; body?: string };
export type FullNote = Omit<Note, "text" | "backlinks" | "pending">;
export class ContentApi {
constructor(private readonly base: string, private readonly token: string) {}
private async call<T>(method: "GET" | "POST", path: string, body?: unknown, okStatuses = [200]): Promise<T> {
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 = <T>(path: string, params: Record<string, string | number | undefined> = {}) => {
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<T>("GET", s ? `${path}?${s}` : path);
};
status = () => this.get<Status>("/api/status");
notes = (p: { type?: string; q?: string; limit?: number; full?: boolean } = {}) =>
this.get<Summary[]>("/api/notes", { type: p.type, q: p.q, limit: p.limit, full: p.full ? "true" : undefined });
fullNotes = (type: string) => this.get<FullNote[]>("/api/notes", { type, full: "true", limit: 1000 });
note = (path: string) => this.get<Note>("/api/note", { path });
noteByName = (name: string) => this.get<Note>("/api/note", { name });
history = (path: string) => this.get<Revision[]>("/api/note/history", { path });
revision = (path: string, rev: number) => this.get<Revision>("/api/note/revision", { path, rev });
held = (status = "pending") => this.get<Held[]>("/api/held", { status });
heldOne = (id: number) => this.get<Held>(`/api/held/${id}`);
problems = () => this.get<Problem[]>("/api/problems");
syncRuns = (job: string) => this.get<SyncJob[]>("/api/syncs", { job });
apply = (change: Change) => this.call<Outcome>("POST", "/api/changes", change, [200, 422]);
accept = (id: number) => this.call<Outcome>("POST", `/api/held/${id}/accept`, {}, [200, 422]);
reject = (id: number, restore: boolean) => this.call<Outcome>("POST", `/api/held/${id}/reject`, { restore }, [200, 422]);
rollback = (path: string, rev: number) => this.call<Outcome>("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}` } });
}
+60
View File
@@ -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;
}
+46
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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, string | number | boolean | undefined | null>): 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");
+49
View File
@@ -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`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>${p.title} · GregKilmer.com admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cinzel:wght@600&family=Montserrat:wght@400;600;700&display=swap">
<link rel="stylesheet" href="/admin.css">
</head>
<body>
<header class="top">
<a class="brand" href="/">GregKilmer.com admin</a>
<nav>${nav.map(
([href, label]) =>
html`<a href="${href}" ${current(href) ? html`aria-current="page"` : ""}>${label}${href === "/review" && p.held ? html`<span class="count">${p.held}</span>` : ""}</a>`,
)}</nav>
</header>
<main>
${p.flash?.msg ? html`<div class="flash" role="status">${p.flash.msg}</div>` : ""}
${p.flash?.err
? html`<div class="flash err" role="alert">${p.flash.err}${p.flash.reasons?.length ? html`<ul>${p.flash.reasons.map((r) => html`<li>${r}</li>`)}</ul>` : ""}</div>`
: ""}
${p.body}
</main>
</body>
</html>`;
}
+19
View File
@@ -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);
+28
View File
@@ -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 `<a href="${escape(safeUrl(href))}"${t} rel="noreferrer">${this.parser.parseInline(tokens)}</a>`;
},
image: ({ href, text }: Tokens.Image) => `<img src="${escape(safeUrl(href))}" alt="${escape(text)}" loading="lazy">`,
},
});
// ![[file.png]] embeds and [[Note|label]] links, before Markdown sees them.
const withLinks = body
.replace(/!\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g, (_, f: string) => `![${f}](${opts.imageSrc(f.trim())})`)
.replace(/\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g, (_, n: string, label?: string) => `[${label ?? n}](${opts.noteHref(n.trim())})`);
return marked.parse(withLinks, { async: false }) as string;
}
+289
View File
@@ -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<string, string> = { greg: "You", agent: "An agent", admin: "Admin page" };
const who = (a: string) => authorLabel[a] ?? a;
const publishPill = (publish: boolean) => (publish ? html`<span class="pill live">Live</span>` : html`<span class="pill draft">Draft</span>`);
const reasonList = (reasons: Held["reasons"]) =>
reasons.length
? reasons.map((r) => html`<p class="reason"><code>${r.code}</code> ${r.message}</p>`)
: html`<p class="reason muted">Waiting for review: no rule flagged it.</p>`;
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`<p class="muted">No differences.</p>`;
return html`<div class="diff">${lines.map((l) =>
l.kind === "skip" ? html`<div class="skip">${l.count} unchanged lines</div>` : html`<div class="${l.kind}">${l.text}</div>`,
)}</div>`;
}
export function preview(body: string): Html {
if (!body.trim()) return html`<p class="muted">This note has no body text.</p>`;
return html`<div class="preview">${raw(
renderMarkdown(body, {
noteHref: (name) => `/go${qs({ name })}`,
imageSrc: (file) => `/attachments/${encodeURIComponent(file)}`,
}),
)}</div>`;
}
// ------------------------------------------------------------ overview
export function overview(s: Status, held: Held[], problems: Problem[]): Html {
const syncs = s.syncs;
return html`
<h1>Overview</h1>
<p class="lede">What the site is serving, what is waiting for you, and whether the jobs that feed it are running.</p>
<div class="grid">
<div class="panel"><div class="stat">${s.counts.published}</div>live notes of ${s.counts.notes}</div>
<div class="panel"><div class="stat">${held.length}</div><a href="/review">waiting for review</a></div>
<div class="panel"><div class="stat">${problems.length}</div><a href="/problems">problems across the folder</a></div>
</div>
<h2>Content service</h2>
<div class="panel">
<dl class="fields">
<dt>Running since</dt><dd>${when(s.startedAt)}</dd>
<dt>Last full scan</dt><dd>${s.lastScan ? html`${when(s.lastScan.at)}: ${s.lastScan.changed} changed, ${s.lastScan.held} held` : "not yet"}</dd>
<dt>Last error</dt><dd>${s.lastError ? html`<span class="pill bad">${when(s.lastError.at)}</span> ${s.lastError.message}` : "none"}</dd>
</dl>
<form method="post" action="/scan" class="actions"><button>Scan the Site folder now</button></form>
</div>
<h2>Sync jobs</h2>
${syncs.length
? html`<div class="table-wrap"><table>
<thead><tr><th>Job</th><th>Last run</th><th>Result</th><th>Last success</th><th>Counts</th></tr></thead>
<tbody>${syncs.map(syncRow)}</tbody></table></div>`
: html`<p class="muted">No sync job has reported yet. PersonalReader, BoardGameGeek and the store syncs report here once they run.</p>`}`;
}
const syncRow = (j: SyncJob) => html`<tr>
<td><a href="/syncs${qs({ job: j.job })}">${j.job}</a></td>
<td>${when(j.at)}</td>
<td>${j.ok ? html`<span class="pill live">OK</span>` : html`<span class="pill bad">Failed</span>`} ${j.message}</td>
<td>${when(j.lastOkAt)}</td>
<td class="mono">${Object.entries(j.counts).map(([k, v]) => `${k} ${v}`).join(", ")}</td>
</tr>`;
export function syncHistory(job: string, runs: SyncJob[]): Html {
return html`<h1>${job}</h1><p class="lede"><a href="/">Back to the overview</a></p>
<div class="table-wrap"><table><thead><tr><th>When</th><th>Result</th><th>Counts</th></tr></thead><tbody>
${runs.map((r) => html`<tr><td>${when(r.at)}</td><td>${r.ok ? html`<span class="pill live">OK</span>` : html`<span class="pill bad">Failed</span>`} ${r.message}</td><td class="mono">${Object.entries(r.counts).map(([k, v]) => `${k} ${v}`).join(", ")}</td></tr>`)}
</tbody></table></div>`;
}
// ------------------------------------------------------------ review
const statuses = ["pending", "accepted", "rejected", "superseded"];
export function reviewList(held: Held[], status: string): Html {
return html`<h1>Review</h1>
<p class="lede">Changes the rules held. The site keeps showing the last accepted version until you decide.</p>
<p class="actions">${statuses.map((s) => (s === status ? html`<strong>${label(s)}</strong>` : html`<a href="/review${qs({ status: s === "pending" ? undefined : s })}">${label(s)}</a>`))}</p>
${held.length
? html`<div class="table-wrap"><table><thead><tr><th>Note</th><th>From</th><th>Why</th><th>When</th></tr></thead><tbody>
${held.map((h) => html`<tr>
<td><a href="/review/${h.id}">${h.path}</a></td>
<td>${who(h.author)}${h.source === "vault" ? " in Obsidian" : ""}</td>
<td>${h.reasons.map((r) => r.code).join(", ") || "review"}</td>
<td>${when(h.at)}</td></tr>`)}
</tbody></table></div>`
: html`<p class="muted">${status === "pending" ? "Nothing is waiting." : "None."}</p>`}`;
}
const label = (s: string) => s[0]!.toUpperCase() + s.slice(1);
export function reviewOne(h: Held): Html {
const pending = h.status === "pending";
return html`<h1>${h.path}</h1>
<p class="lede">${who(h.author)}${h.source === "vault" ? " in Obsidian" : " through the API"}, ${when(h.at)}. ${pending ? "" : html`<span class="pill">${label(h.status)} ${when(h.decidedAt)}</span>`}</p>
<div class="panel">${reasonList(h.reasons)}</div>
${pending
? html`<div class="actions">
<form method="post" action="/review/${h.id}/accept" class="inline"><button class="primary">Accept and go live</button></form>
<form method="post" action="/review/${h.id}/reject" class="inline"><button>Reject</button></form>
${h.source === "vault" && h.current
? html`<form method="post" action="/review/${h.id}/reject" class="inline"><input type="hidden" name="restore" value="1"><button class="danger">Reject and put the live version back in Obsidian</button></form>`
: ""}
</div>`
: ""}
<h2>${h.current === null ? "New note" : "Changes against the live version"}</h2>
${diffView(h.current ?? "", h.text ?? "")}
<p><a href="${noteHref(h.path)}">Open the live note</a></p>`;
}
// ------------------------------------------------------------ notes
export function notesList(p: { title: string; lede: string; action: string; notes: Summary[]; types: string[]; type?: string; q?: string }): Html {
return html`<h1>${p.title}</h1>
<p class="lede">${p.lede}</p>
<form class="filters" method="get" action="${p.action}">
<div><label for="q">Search</label><input id="q" name="q" value="${p.q ?? ""}" placeholder="Title or text"></div>
<div><label for="type">Type</label><select id="type" name="type"><option value="">All types</option>${p.types.map(
(t) => html`<option ${t === p.type ? html`selected` : ""}>${t}</option>`,
)}</select></div>
<button>Show</button>
</form>
${p.notes.length
? html`<div class="table-wrap"><table><thead><tr><th>Title</th><th>Type</th><th>State</th><th>Changed</th></tr></thead><tbody>
${p.notes.map((n) => html`<tr><td><a href="${noteHref(n.path)}">${n.title}</a><div class="muted mono">${n.path}</div></td><td>${n.type}</td><td>${publishPill(n.publish)}</td><td>${when(n.acceptedAt)}</td></tr>`)}
</tbody></table></div>`
: html`<p class="muted">No notes match.</p>`}`;
}
const fieldValue = (v: unknown): Html =>
typeof v === "object" && v !== null ? html`<pre>${JSON.stringify(v, null, 2)}</pre>` : 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`<h1>${n.title}</h1>
<p class="lede">${publishPill(n.publish)} ${n.type} · <span class="mono">${n.path}</span> · last changed by ${who(n.author).toLowerCase()} ${when(n.acceptedAt)}</p>
${n.pending ? html`<div class="flash">A change to this note is waiting: <a href="/review/${n.pending.id}">review it</a>.</div>` : ""}
<div class="actions">
<form method="post" action="/note/publish" class="inline">
<input type="hidden" name="path" value="${n.path}">
<input type="hidden" name="publish" value="${n.publish ? "0" : "1"}">
<button class="${n.publish ? "" : "primary"}">${n.publish ? "Unpublish" : "Publish"}</button>
</form>
</div>
<h2>Preview</h2>
${preview(n.body)}
<h2>Fields</h2>
<div class="panel"><dl class="fields">${shown.map(([k, v]) => html`<dt>${k}</dt><dd>${fieldValue(v)}</dd>`)}</dl></div>
<h2>Linked from</h2>
${n.backlinks.length ? html`<ul>${n.backlinks.map((b) => html`<li><a href="${noteHref(b.path)}">${b.title}</a> <span class="muted">${b.type}</span></li>`)}</ul>` : html`<p class="muted">Nothing links here yet.</p>`}
<h2>History</h2>
${historyTable(n, history)}`;
}
function historyTable(n: Note, history: Revision[]): Html {
return html`<div class="table-wrap"><table><thead><tr><th>Version</th><th>Who</th><th>What</th><th>When</th><th></th></tr></thead><tbody>
${history.map((r) => html`<tr>
<td>${r.rev}${r.rev === n.rev ? html` <span class="pill live">current</span>` : ""}</td>
<td>${who(r.author)}</td><td>${r.action}</td><td>${when(r.at)}</td>
<td>${r.rev === n.rev || r.action === "remove" ? "" : html`<a href="/note/revision${qs({ path: n.path, rev: r.rev })}">Compare and roll back</a>`}</td>
</tr>`)}
</tbody></table></div>`;
}
export function revisionView(n: Note, r: Revision): Html {
return html`<h1>${n.title}, version ${r.rev}</h1>
<p class="lede">${who(r.author)}, ${when(r.at)}. Showing what rolling back would change in the live version ${n.rev}.</p>
<div class="actions">
<form method="post" action="/note/rollback" class="inline">
<input type="hidden" name="path" value="${n.path}"><input type="hidden" name="rev" value="${r.rev}">
<button class="primary">Roll back to version ${r.rev}</button>
</form>
<a href="${noteHref(n.path)}">Back to the note</a>
</div>
${diffView(n.text, r.text ?? "")}`;
}
// ------------------------------------------------------------ capture
export function capture(kinds: string[], today: string): Html {
return html`<h1>Capture</h1>
<p class="lede">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.</p>
<div class="grid">
<form class="panel" method="post" action="/capture/status">
<h2>Status</h2>
<div class="row">
<div><label for="s-kind">Kind</label><select id="s-kind" name="kind" required>${kinds.map((k) => html`<option>${k}</option>`)}</select></div>
<div><label for="s-date">Date</label><input id="s-date" name="date" type="date" value="${today}" required></div>
</div>
<label for="s-title">What</label><input id="s-title" name="title" required placeholder="Mistborn">
<label for="s-subject">About note (optional)</label><input id="s-subject" name="subject" placeholder="The note's name, like Mistborn">
<label for="s-note">Note (optional)</label><input id="s-note" name="note" placeholder="Second read, still great">
<label class="check"><input type="checkbox" name="pinned" value="1"> Pin it</label>
<label class="check"><input type="checkbox" name="publish" value="1" checked> Show it on the site</label>
<div class="actions"><button class="primary">Post status</button></div>
</form>
<form class="panel" method="post" action="/capture/library">
<h2>Library item</h2>
<label for="l-title">Title</label><input id="l-title" name="title" required>
<div class="row">
<div><label for="l-medium">Medium</label><select id="l-medium" name="medium">${media.map((m) => html`<option>${m}</option>`)}</select></div>
<div><label for="l-state">State</label><select id="l-state" name="state">${libraryStates.map((s) => html`<option>${s}</option>`)}</select></div>
</div>
<div class="row">
<div><label for="l-creator">Author or creator</label><input id="l-creator" name="creator"></div>
<div><label for="l-year">Year</label><input id="l-year" name="year" inputmode="numeric" pattern="[0-9]{4}"></div>
</div>
<label for="l-group">Group (optional)</label><input id="l-group" name="group" placeholder="Cosmere">
<label for="l-verdict">Verdict (optional)</label><input id="l-verdict" name="verdict">
<label for="l-note">Note (optional)</label><input id="l-note" name="note">
<label class="check"><input type="checkbox" name="publish" value="1" checked> Show it on the site</label>
<label class="check"><input type="checkbox" name="alsoStatus" value="1"> Also post a status</label>
<label for="l-kind">Status kind</label><select id="l-kind" name="kind">${kinds.map((k) => html`<option>${k}</option>`)}</select>
<div class="actions"><button class="primary">Add to the Library</button></div>
</form>
</div>`;
}
// ------------------------------------------------------------ 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`<h1>Store</h1>
<p class="lede">Open and close orders by category, and set how many order slots each made to order line has open.</p>
<h2>Categories</h2>
${settings
? html`<div class="grid">${categories.map(
(c) => html`<form class="panel" method="post" action="/store/category">
<h3>${c.name} ${c.ordersOpen ? html`<span class="pill live">Open</span>` : html`<span class="pill draft">Closed</span>`}</h3>
<input type="hidden" name="name" value="${c.name}">
<label class="check"><input type="checkbox" name="ordersOpen" value="1" ${c.ordersOpen ? html`checked` : ""}> Taking orders</label>
<label for="reopens-${c.name}">Reopens (when closed)</label><input id="reopens-${c.name}" type="date" name="reopens" value="${c.reopens ?? ""}">
<label for="turn-${c.name}">Turnaround</label><input id="turn-${c.name}" name="turnaround" value="${c.turnaround ?? ""}">
<div class="actions"><button class="primary">Save ${c.name}</button></div>
</form>`,
)}</div>`
: html`<p class="muted">There is no Store/_settings note yet.</p>`}
<h2>Product lines</h2>
<div class="table-wrap"><table><thead><tr><th>Line</th><th>Category</th><th>Sold</th><th>Open slots or stock</th></tr></thead><tbody>
${lines.map((l) => {
const slots = l.data.slots as { open: number; total: number } | undefined;
const qty = l.data.quantity as number | undefined;
return html`<tr>
<td><a href="${noteHref(l.path)}">${l.title}</a> ${publishPill(l.publish)}</td>
<td>${String(l.data.category)}</td>
<td>${String(l.data.mode)}, ${String(l.data.channel)}</td>
<td>${slots
? html`<form method="post" action="/store/stock" class="actions"><input type="hidden" name="path" value="${l.path}">
<input aria-label="Open slots for ${l.title}" name="open" type="number" min="0" max="${slots.total}" value="${slots.open}" class="num"> <span class="muted">of ${slots.total}</span> <button>Save</button></form>`
: qty !== undefined
? html`<form method="post" action="/store/stock" class="actions"><input type="hidden" name="path" value="${l.path}">
<input aria-label="Stock for ${l.title}" name="quantity" type="number" min="0" value="${qty}" class="num"> <span class="muted">in stock</span> <button>Save</button></form>`
: html`<span class="muted">Not counted here</span>`}</td>
</tr>`;
})}
</tbody></table></div>`;
}
// ------------------------------------------------------------ problems
export function problemsView(problems: Problem[]): Html {
return html`<h1>Problems</h1>
<p class="lede">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.</p>
${problems.length
? html`<div class="table-wrap"><table><thead><tr><th>Note</th><th>Problem</th></tr></thead><tbody>
${problems.map((p) => html`<tr><td><a href="${noteHref(p.path)}">${p.path}</a></td><td>${p.message}</td></tr>`)}</tbody></table></div>`
: html`<p class="muted">No problems.</p>`}`;
}
+357
View File
@@ -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<Page | Redirect> {
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<Redirect> {
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<string, unknown>[]).map((c) => {
if (c.name !== name) return c;
const next: Record<string, unknown> = { ...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<string, unknown>;
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`<h1>Not available</h1><p class="lede">${message}</p><p><a href="/">Back to the overview</a></p>`;
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<string, string> {
const out: Record<string, string> = {};
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<string> {
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);
}
+152
View File
@@ -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<void>((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<string, string>, headers: Record<string, string> = {}) => {
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('<div class="add">verdict: A heist.</div>');
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("<p>Allomancy.</p>");
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('<div class="remove">verdict: A heist.</div>');
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");
});
});
+50
View File
@@ -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`<p>${"<b>"}${html`<i>${"&"}</i>`}${raw("<hr>")}</p>`.toString()).toBe("<p>&lt;b&gt;<i>&amp;</i><hr></p>");
});
});
describe("draft previews", () => {
it("never lets a draft run script", () => {
const out = md('<script>alert(1)</script>\n\n[x](javascript:alert(1)) <img src=x onerror=alert(1)>');
expect(out).not.toMatch(/<script|<img src=x|javascript:/i);
expect(out).toContain("&lt;script&gt;");
});
it("turns wikilinks and embeds into links and images", () => {
const out = md("See [[Mistborn|the book]] and ![[cover.png]].");
expect(out).toContain('<a href="/go?name=Mistborn"');
expect(out).toContain("the book</a>");
expect(out).toContain('<img src="/attachments/cover.png"');
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"]
}
+29 -1
View File
@@ -35,6 +35,13 @@ const changeSchema = z.discriminatedUnion("op", [
}),
]);
const syncSchema = z.object({
job: z.string().regex(/^[a-z0-9-]{1,40}$/),
ok: z.boolean(),
message: z.string().max(2000).optional(),
counts: z.record(z.string(), z.number()).optional(),
});
const types = {
".png": "image/png",
".jpg": "image/jpeg",
@@ -107,7 +114,9 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
const type = q("type");
const slug = q("slug");
let note: StoredNote | null = null;
const name = q("name");
if (p) note = store.get(p);
else if (name) note = store.byName(name);
else if (type && slug) {
note = store.bySlug(type, slug);
if (!visible(note, role)) {
@@ -115,7 +124,7 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
const target = to ? visible(store.get(to), role) : null;
if (target) return send(res, 200, { redirect: { type: target.type, slug: target.slug, path: target.path } });
}
} else throw new HttpError(400, "Give path, or type and slug");
} else throw new HttpError(400, "Give path, name, or type and slug");
const found = visible(note, role);
if (!found) throw new HttpError(404, "No such note");
return send(res, 200, {
@@ -143,6 +152,17 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
if (path === "/api/redirects") return send(res, 200, store.redirects());
if (path === "/api/status") {
need(can.readDrafts(role));
return send(res, 200, { ...service.health, counts: store.count(), syncs: store.syncJobs() });
}
if (path === "/api/syncs") {
need(can.readDrafts(role));
const job = q("job");
return send(res, 200, job ? store.syncRuns(job) : store.syncJobs());
}
if (path === "/api/problems") {
need(can.readDrafts(role));
return send(res, 200, service.problems());
@@ -210,6 +230,14 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
return send(res, outcome.status === "rejected" ? 422 : 200, outcomeView(outcome));
}
if (path === "/api/syncs") {
need(can.write(role));
const body = syncSchema.safeParse(await readJson(req));
if (!body.success) throw new HttpError(400, z.prettifyError(body.error));
store.reportSync({ ...body.data, message: body.data.message ?? "", counts: body.data.counts ?? {}, at: new Date().toISOString() });
return send(res, 200, { ok: true });
}
if (path === "/api/scan") {
need(can.review(role));
return send(res, 200, service.scan());
+4 -1
View File
@@ -34,7 +34,10 @@ const watcher = new Watcher(service, config.siteDir, {
if (r.changed) console.log(`Scan: ${r.changed} changed, ${r.held} held`);
if (config.healthcheckUrl) fetch(config.healthcheckUrl).catch(() => {});
},
onError: (err) => console.error("Watcher:", err),
onError: (err) => {
service.noteError(err);
console.error("Watcher:", err);
},
});
watcher.start();
+12
View File
@@ -50,6 +50,17 @@ export class ContentService {
this.onHold = o.onHold;
}
/** What the admin page shows as the service's own health. */
readonly health: {
startedAt: string;
lastScan: { at: string; changed: number; held: number } | null;
lastError: { at: string; message: string } | null;
} = { startedAt: new Date().toISOString(), lastScan: null, lastError: null };
noteError(err: unknown) {
this.health.lastError = { at: this.iso(), message: err instanceof Error ? err.message : String(err) };
}
private iso = () => this.now().toISOString();
private today = () => this.iso().slice(0, 10);
@@ -94,6 +105,7 @@ export class ContentService {
if (o.status !== "unchanged") changed++;
if (o.status === "held") held++;
}
this.health.lastScan = { at: this.iso(), changed, held };
return { changed, held };
}
+47
View File
@@ -117,6 +117,15 @@ create table if not exists seen (
path text primary key,
hash text not null
);
create table if not exists sync_runs (
id integer primary key autoincrement,
job text not null,
at text not null,
ok integer not null,
message text not null,
counts text not null
);
create index if not exists sync_runs_job on sync_runs(job, id);
create virtual table if not exists search using fts5(path unindexed, title, body, tokenize = 'porter unicode61');
`;
@@ -152,6 +161,19 @@ const toHeld = (r: Row): HeldChange => ({
decidedAt: r.decided_at,
});
export interface SyncRun {
job: string;
at: string;
ok: boolean;
message: string;
counts: Record<string, number>;
}
const toSyncRun = (r: Row): SyncRun => ({ job: r.job, at: r.at, ok: !!r.ok, message: r.message, counts: JSON.parse(r.counts) });
/** Runs kept per sync job; older ones are dropped. */
const SYNC_RUNS_KEPT = 50;
export class Store {
readonly db: DatabaseSync;
@@ -379,4 +401,29 @@ export class Store {
seenPaths(): string[] {
return (this.db.prepare("select path from seen").all() as Row[]).map((r) => r.path);
}
// ------------------------------------------------------------ sync jobs
/** A sync job (PersonalReader, BoardGameGeek and the like) reports how its last run went. */
reportSync(run: SyncRun) {
this.db
.prepare("insert into sync_runs (job, at, ok, message, counts) values (?, ?, ?, ?, ?)")
.run(run.job, run.at, run.ok ? 1 : 0, run.message, JSON.stringify(run.counts));
this.db
.prepare("delete from sync_runs where job = ? and id not in (select id from sync_runs where job = ? order by id desc limit ?)")
.run(run.job, run.job, SYNC_RUNS_KEPT);
}
/** Each job's latest run, with when it last succeeded. */
syncJobs(): (SyncRun & { lastOkAt: string | null })[] {
const rows = this.db.prepare("select * from sync_runs where id in (select max(id) from sync_runs group by job) order by job").all() as Row[];
return rows.map((r) => ({
...toSyncRun(r),
lastOkAt: ((this.db.prepare("select max(at) at from sync_runs where job = ? and ok = 1").get(r.job) as Row).at as string) ?? null,
}));
}
syncRuns(job: string, limit = 20): SyncRun[] {
return (this.db.prepare("select * from sync_runs where job = ? order by id desc limit ?").all(job, limit) as Row[]).map(toSyncRun);
}
}
+11
View File
@@ -66,6 +66,17 @@ describe("HTTP API", () => {
});
});
describe("sync reports", () => {
it("records a run and shows it in the status", async () => {
expect((await post_("/api/syncs", T.agent, { job: "personalreader", ok: true, counts: { updated: 3 } })).status).toBe(200);
expect((await post_("/api/syncs", T.agent, { job: "Bad Name", ok: true })).status).toBe(400);
const status = (await (await get("/api/status", T.admin)).json()) as { syncs: { job: string; lastOkAt: string }[] };
expect(status.syncs[0]).toMatchObject({ job: "personalreader" });
expect(status.syncs[0]!.lastOkAt).toBeTruthy();
expect((await get("/api/status", T.reader)).status).toBe(403);
});
});
describe("MCP server", () => {
it("lets an agent search, post a status and draft a post, but not publish it", async () => {
const client = new Client({ name: "test", version: "1.0.0" });