Skip to content

Commit 5fec215

Browse files
admin plugin: wp-admin-style operator home — the capstone gap analysis
One page composing the repo's whole vocabulary: server health (loopback probe), the declared plugin inventory with live status, pods/storage stats (realpath'd, symlink-refusing, capped podsRoot walk), all behind a config.adminAgents allowlist — plus an explicit 'not possible yet' strip naming the seam behind each wp-admin pillar the api can't express: inventory (#463/#464 api.plugins), install (#200 + post-boot loading), enable/disable (deactivate() exists, nothing calls it at runtime), settings panels (no contribute-a-panel affordance), logs (api.log is write-only). Zero write operations by design — the absence is the result, not a TODO. Meta-finding: the read side works only by the operator re-telling the plugin what the host already knows, three different ways (origin, plugins list, data root); and metrics/terminal/admin now hold three incompatible answers to 'who is an operator?' — an api.isOperator seam.
1 parent cd15875 commit 5fec215

3 files changed

Lines changed: 1051 additions & 0 deletions

File tree

admin/README.md

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# admin — a wp-admin-style operator home, and the measure of how far the api gets
2+
3+
One self-contained HTML page (plus `status.json`) that shows *everything about
4+
a JSS deployment the plugin api lets an operator see*: the server (origin,
5+
liveness, uptime, node, platform), every installed plugin (live loopback
6+
probes, prefix links, per-plugin admin pages), the pods (count, disk usage,
7+
newest activity) — composed on top of `dashboard/`'s probe machinery,
8+
`metrics/`'s healthz idea, `nip05/`'s podsRoot scan, and `shortlink/`'s
9+
`getAgent` auth.
10+
11+
**The point is the gap analysis.** The read side of wp-admin is mostly
12+
expressible today — but every pillar of it rests on config that *repeats
13+
something the host already knows*, and the entire write side (install,
14+
enable/disable, settings, log viewing) is architecturally absent. The page
15+
carries a "Not possible yet" strip naming each missing pillar and the seam
16+
behind it; the table below is the same analysis in full.
17+
18+
## Usage
19+
20+
```js
21+
import { createServer } from 'javascript-solid-server/src/server.js';
22+
23+
const PORT = 3240;
24+
const PUBLIC_URL = 'https://pod.example';
25+
const PODS = '/srv/jss/data/pods';
26+
27+
const fastify = createServer({
28+
root: PODS,
29+
idp: true,
30+
plugins: [
31+
{ id: 'rss', module: 'plugins/rss/plugin.js', prefix: '/feed',
32+
config: { baseUrl: PUBLIC_URL, loopbackUrl: `http://127.0.0.1:${PORT}` } },
33+
{ id: 'metrics', module: 'plugins/metrics/plugin.js', prefix: '/metrics',
34+
config: { loopbackUrl: `http://127.0.0.1:${PORT}` } },
35+
36+
{ id: 'admin', module: 'plugins/admin/plugin.js', prefix: '/admin',
37+
config: {
38+
loopbackUrl: `http://127.0.0.1:${PORT}`, // required (no api.serverInfo)
39+
baseUrl: PUBLIC_URL, // optional, shown on the page
40+
podsRoot: PODS, // optional → pod/storage stats
41+
// Who is an operator? The api has no answer, so this plugin invents
42+
// one: an agent-id allowlist. Omit it and the page serves OPEN
43+
// (loud warn at activate).
44+
adminAgents: ['https://pod.example/alice/profile/card.jsonld#me'],
45+
// A HAND-COPIED duplicate of the list above — no api.plugins
46+
// registry to read (#463/#464); the two lists drift silently.
47+
plugins: [
48+
{ id: 'rss', prefix: '/feed', description: 'Atom/RSS feeds',
49+
probe: '/feed/atom', expect: [400] },
50+
{ id: 'metrics', prefix: '/metrics', description: 'healthz + Prometheus',
51+
probe: '/metrics/healthz', expect: [200], adminPage: '/metrics/metrics' },
52+
{ id: 'admin', prefix: '/admin', description: 'this page' },
53+
],
54+
} },
55+
],
56+
});
57+
```
58+
59+
- `GET /admin/` — the page (server-side rendered from a live snapshot, so
60+
`curl` shows real content; an inline script re-polls every ~10s — when
61+
guarded, append `#token=<bearer>` to the URL so the poller can authenticate).
62+
- `GET /admin/status.json` — the same snapshot as JSON. Probed live per
63+
request: anonymous loopback GETs, concurrent, ~3s timeout each, never
64+
cached. Probe semantics are `dashboard/`'s: `<500` = alive, `expect: [...]`
65+
narrows, `kind: 'ws'` accepts 400/404/426-style upgrade refusals as routed.
66+
- Both surfaces are guarded by `adminAgents` when present: 401 anonymous,
67+
403 authenticated-but-not-listed, JSON errors either way.
68+
69+
### The page, in words
70+
71+
A single column, system font, four labeled sections plus a warning strip.
72+
Header "server admin" with the origin and either "guarded: 1 admin agent" or
73+
"OPEN: no adminAgents configured — anyone can read this page". **Server**: a
74+
key/value table — base URL, host liveness as a green/red pill with the HTTP
75+
status and loopback latency, process uptime ("3h 12m"), node version,
76+
platform. **Plugins**: a table of *plugin | description | prefix | status |
77+
http | latency | admin* — each prefix a link, each status a pill (green up /
78+
amber degraded / red down / grey unprobed), and a "settings" link where the
79+
entry declared an `adminPage`. **Pods / Users**: pod count, disk usage
80+
("1.2 GB (1288490188 bytes, 4302 entries walked)", with a TRUNCATED note if
81+
the 50k-entry walk cap hit), newest pod mtime. **Not possible yet**: an
82+
amber strip listing the five wp-admin pillars this page can't have, each
83+
with its seam. **Meta**: "3 declared — 2 up, 0 degraded, 0 down, 1 unprobed
84+
· generated 2026-07-11T… · polls status.json every 10s". Dark-mode via
85+
`prefers-color-scheme`; no external assets anywhere.
86+
87+
## Findings
88+
89+
The headline: **how much of a wp-admin/Nextcloud-style admin can the plugin
90+
api express today?** Answer: most of the *read* side, none of the *write*
91+
side — and every read pillar leans on operator-repeated config. Pillar by
92+
pillar:
93+
94+
| wp-admin pillar | what this plugin does today | the seam that would close the gap |
95+
|---|---|---|
96+
| **Plugin inventory** | renders a hand-copied `config.plugins` list; drifts silently from the real `createServer` list (add a plugin and forget the admin entry → it never appears; remove one → it keeps probing) | `api.plugins → [{ id, prefix, module }]` — the #463/#464 app-registry; the loader already holds exactly this data |
97+
| **Install / marketplace** | nothing — architecturally impossible; shown in the "not possible yet" strip | a package source + runtime install (#200 marketplace); would also need the loader to activate entries after boot |
98+
| **Enable / disable** | nothing — the loader activates at boot only; `deactivate()` exists in the plugin contract but nothing calls it at runtime | runtime `activate()`/`deactivate()` on the loader, driven by an authorized surface |
99+
| **Settings panels** | links to a per-plugin `adminPage` path the operator declares — each plugin hand-rolls its own page and this one merely points at it | a contribute-a-panel affordance (a plugin registers a settings fragment/route with the admin surface); today there is no gated hook or registration point for it |
100+
| **Health / status** | ✅ the strongest pillar: host liveness + per-plugin anonymous loopback probes, live per request, `expect`/`ws` semantics from `dashboard/` | mostly closed already; per-plugin *self-reported* health (`health: () => …` on a registry entry) would beat anonymous probing |
101+
| **Users / storage** | ✅ if the operator repeats `podsRoot`: pod count, du-style recursive size (capped at 50k entries, truncation reported, realpath'd root, symlinks never followed, contents never read), newest pod mtime | the api doesn't mediate any filesystem access — `podsRoot` re-declares what the host already knows (cousin of `api.serverInfo`); an `api.storage.stats()` or accounts enumeration would do this honestly |
102+
| **Log viewer** | nothing — `api.log` is write-only | an `api.log` tailing/reading seam (ring buffer or stream); until then "recent logs" can't exist on any plugin page |
103+
| **Auth / roles** | invents an operator concept from scratch: `config.adminAgents`, an agent-id allowlist checked via `api.auth.getAgent`; without it the surface is OPEN behind a warn | an operator/role concept in the api. Today every ops plugin invents its own admin auth — `metrics/` a shared token, `terminal/` a token, this one an agent allowlist — three plugins, three incompatible answers to "who is an operator?" |
104+
105+
Beyond the table:
106+
107+
1. **"Who is an operator?" has no api answer** (the auth/roles row, sharpened).
108+
`getAgent` verifies *identity* perfectly across every credential scheme,
109+
but authorization is entirely the plugin's problem, and there is no
110+
host-level notion of "the server operator" to defer to — even though the
111+
host clearly has one (whoever writes `createServer`'s config). An
112+
`api.isOperator(agent)` (or an `operators: [...]` server option plugins
113+
can read) would let every ops surface in this repo share one answer.
114+
2. **The pods stats are a config-repetition, not an api capability.** The
115+
plugin can count users and measure storage *only because* the operator
116+
passed the same `root` path to both `createServer` and this plugin's
117+
config — the api itself mediates no storage introspection. Same shape as
118+
`api.serverInfo` (the origin repeated in ~17 configs) and the same shape
119+
as the inventory (the plugins list repeated): the whole read side of this
120+
admin page is the operator telling the plugin what the host already knows,
121+
three different ways.
122+
3. **A guarded plugin page has no session story.** Core's UI has its own
123+
login; a plugin page can't ride it — the only credential a plugin can
124+
check is what arrives on the request, so the browser poller has to shuttle
125+
a bearer by hand (`#token=` in the URL fragment here; the fragment never
126+
leaves the browser, but it is still a token pasted into a URL bar). A
127+
contribute-a-panel seam would solve this for free, since panels would be
128+
served *inside* an already-authenticated core surface.
129+
4. **The write side is absent by architecture, not by effort** — and that is
130+
the finding, not a TODO list. Install, enable/disable, and config editing
131+
all require the loader to act *after* boot; today the plugins array is
132+
consumed once by `createServer` and the only lifecycle event a plugin ever
133+
sees is its own `activate()`. Nothing on this page pretends otherwise: it
134+
has zero write operations, and the "not possible yet" strip names each
135+
absence with its seam (also exported as `NOT_POSSIBLE` from `plugin.js`
136+
so the list can't drift from the docs).
137+
5. **Composition worked.** This plugin is `dashboard/`'s probes +
138+
`metrics/`'s healthz idea + `nip05/`'s podsRoot scan + `shortlink/`'s
139+
`getAgent` guard in one page, with no new primitive needed — evidence
140+
that the api's *read-side* vocabulary is genuinely composable. What it
141+
could not compose away is any of the four repeated-config findings above.
142+
143+
## Test
144+
145+
```
146+
node --test --test-concurrency=1 admin/test.js
147+
```
148+
149+
Validation throws first (missing `loopbackUrl`, external probe URL,
150+
self-probe recursion — before the long-lived boot; the `DATA_ROOT` footgun),
151+
then one JSS with admin + rss + metrics, `podsRoot` at the server root and
152+
`adminAgents` set to a pod minted via `/.pods` + `/idp/credentials`:
153+
anonymous → 401, second pod's bearer → 403, admin bearer → the full page
154+
(plugin ids, server-side-rendered pods count, the not-possible strip) and
155+
the `status.json` shape (rss up via `expect: [400]`, metrics up via
156+
`[200]`, an unprobed self-entry, pod count 2, real byte totals, meta
157+
counts), plus the no-caching property. The open-mode boot (no
158+
`adminAgents`) runs LAST against a fresh server after the first is closed,
159+
and asserts the page itself declares it is OPEN.

0 commit comments

Comments
 (0)