|
| 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