Skip to content

Commit ac5e880

Browse files
dashboard: consume api.plugins + api.serverInfo (require JSS 0.0.218)
The first upstream loop closed: two seams this plugin proved the need for landed in core 0.0.218 (api.plugins #610/#612, api.serverInfo #601/#605), and the dashboard now runs on them instead of its workarounds. - Auto-discovers every co-loaded plugin from api.plugins — the hand-copied config.plugins array is gone, and drift is impossible (add/remove a plugin, the dashboard reflects it with zero config change). - Reaches the host via api.serverInfo() per request — no config.loopbackUrl. - config.probes now only *refines* individual probes ({ probe?, expect?, kind? } by id); the default probe is each plugin's own prefix. Residual finding (sharpened, still open): the roster carries { id, prefix, module } but no health hints, so ws endpoints and odd prefixes still need a config.probes refinement — the optional per-entry probe/health hint the #610 sketch anticipated. Pin bumped ^0.0.215 -> ^0.0.218 (caret locks to the exact 0.0.x patch, so the string itself had to change). compose.test.js + serve.js updated to the auto-discovery config; admin/ keeps the hand-fed INVENTORY (phase 2 — it additionally needs an operator-identity seam). Full suite green except the pre-existing notifications fs.watch/inotify flake (fails identically on 0.0.215 — environmental, not a regression).
1 parent dfea5c9 commit ac5e880

7 files changed

Lines changed: 282 additions & 295 deletions

File tree

compose.test.js

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -128,15 +128,15 @@ describe('composition: every plugin on one server', () => {
128128
id: 'dashboard',
129129
module: at('dashboard/plugin.js'),
130130
prefix: '/dashboard',
131+
// No hand-fed list and no loopbackUrl: the dashboard auto-discovers
132+
// every plugin via api.plugins (#610) and reaches the host via
133+
// api.serverInfo (#601). Only the WebSocket endpoints need a
134+
// refinement so a plain-GET upgrade refusal reads as alive.
131135
config: {
132-
loopbackUrl: base,
133-
// A hand-copied slice of this very list — a plugin can't see its
134-
// siblings (the #463/#464 app-registry finding).
135-
plugins: [
136-
{ id: 'nip05', probe: '/nip05/nostr.json', expect: [200] },
137-
{ id: 'rss', probe: '/feed/atom', expect: [400] },
138-
{ id: 'relay', probe: '/relay', kind: 'ws' },
139-
],
136+
probes: {
137+
relay: { kind: 'ws' }, webrtc: { kind: 'ws' }, terminal: { kind: 'ws' },
138+
tunnel: { kind: 'ws' }, notifications: { kind: 'ws' },
139+
},
140140
},
141141
},
142142
],
@@ -387,16 +387,25 @@ describe('composition: every plugin on one server', () => {
387387
assert.match(await res.text(), /process_uptime_seconds/);
388388
});
389389

390-
it('dashboard: the page names the declared plugins; status.json probes live', async () => {
390+
it('dashboard: auto-discovers every plugin via api.plugins; status.json probes live', async () => {
391391
let res = await fetch(`${base}/dashboard/`);
392392
assert.strictEqual(res.status, 200);
393393
const page = await res.text();
394-
for (const id of ['nip05', 'rss', 'relay']) assert.ok(page.includes(id), id);
394+
// Auto-discovered, not a hand-fed three — a broad sample must appear.
395+
for (const id of ['nip05', 'rss', 'relay', 's3', 'micropub', 'metrics']) {
396+
assert.ok(page.includes(id), id);
397+
}
395398
res = await fetch(`${base}/dashboard/status.json`);
396399
assert.strictEqual(res.status, 200);
397400
const status = await res.json();
398401
assert.strictEqual(status.server.alive, true);
399-
assert.ok(status.plugins.every((p) => p.alive), JSON.stringify(status.plugins));
402+
// Discovered ~every sibling (minus itself), far more than a curated list.
403+
assert.ok(status.plugins.length >= 25, `discovered only ${status.plugins.length}`);
404+
// The well-understood HTTP plugins answer <500 at their prefix → alive.
405+
const byId = Object.fromEntries(status.plugins.map((p) => [p.id, p]));
406+
for (const id of ['nip05', 'rss', 'mastodon', 's3', 'micropub', 'backup', 'metrics']) {
407+
assert.strictEqual(byId[id]?.alive, true, `${id}: ${JSON.stringify(byId[id])}`);
408+
}
400409
});
401410

402411
it('pods still work beside all of it (idp register + WAC)', async () => {

dashboard/README.md

Lines changed: 97 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,36 @@
11
# dashboard — plugin status page with live liveness probes
22

3-
One HTML page (plus a JSON API) showing every plugin the operator declared,
3+
One HTML page (plus a JSON API) showing every plugin loaded on the server,
44
each probed live over loopback on every request. It is deliberately the
5-
simplest possible ops surface — and deliberately the first live consumer of
6-
the **#463/#464 app-registry seam**: the api gives a plugin no way to see
7-
its co-loaded siblings, so the operator must hand this dashboard a *copy* of
8-
the very plugins list they already passed to `createServer`.
5+
simplest possible ops surface — and it is the **first live consumer of two
6+
shipped plugin-API seams**: it auto-discovers its co-loaded siblings from
7+
`api.plugins` (#610) and reaches the host over loopback via `api.serverInfo`
8+
(#601), both landed in JSS **0.0.218**. This plugin drove the need for both
9+
seams and now runs on them — no hand-maintained list, no origin in config.
910

1011
## Usage
1112

1213
```js
1314
import { createServer } from 'javascript-solid-server/src/server.js';
1415

15-
const PORT = 3240;
1616
const fastify = createServer({
1717
root: './data/pods',
1818
plugins: [
1919
{ id: 'relay', module: 'plugins/relay/plugin.js', prefix: '/relay' },
20-
{ id: 'capability', module: 'plugins/capability/plugin.js', prefix: '/cap', config: {} },
20+
{ id: 'capability', module: 'plugins/capability/plugin.js', prefix: '/cap' },
2121
{ id: 'rss', module: 'plugins/rss/plugin.js', prefix: '/feed',
22-
config: { baseUrl: PUBLIC_URL, loopbackUrl: `http://127.0.0.1:${PORT}` } },
23-
{ id: 'mastodon', module: 'plugins/mastodon/plugin.js', prefix: '/mastodon',
24-
config: { baseUrl: PUBLIC_URL, loopbackUrl: `http://127.0.0.1:${PORT}` } },
22+
config: { baseUrl: PUBLIC_URL } },
2523

24+
// No plugins list, no loopbackUrl — the dashboard discovers everything
25+
// above via api.plugins and finds the host via api.serverInfo. The only
26+
// config is optional per-plugin probe *refinement*:
2627
{ id: 'dashboard', module: 'plugins/dashboard/plugin.js', prefix: '/dashboard',
2728
config: {
28-
loopbackUrl: `http://127.0.0.1:${PORT}`,
29-
// A HAND-COPIED duplicate of the list above — the api has no
30-
// registry a plugin could read (#463/#464). Keep them in sync
31-
// yourself; nothing will tell you when they drift.
32-
plugins: [
33-
{ id: 'relay', probe: '/relay', kind: 'ws' },
34-
{ id: 'capability', probe: '/cap/issue' },
35-
{ id: 'rss', probe: '/feed/atom' },
36-
{ id: 'mastodon', probe: '/api/v1/instance', expect: [200] },
37-
],
29+
probes: {
30+
relay: { kind: 'ws' }, // a WebSocket endpoint
31+
rss: { probe: '/feed/atom' }, // a better liveness path than bare /feed
32+
// capability: { expect: [200] }, // declare a stricter "up"
33+
},
3834
} },
3935
],
4036
});
@@ -56,121 +52,107 @@ const fastify = createServer({
5652

5753
### Probe semantics
5854

59-
Each `config.plugins` entry is `{ id, probe, expect?, kind? }`:
60-
61-
- `probe` — a **local path** on this server. It must start with `/`, must
62-
not contain `://`, and must not point back at the dashboard itself
63-
(recursion); all three are enforced with a throw at `activate`. Probes go
64-
**only** to `loopbackUrl + probe`, never to external URLs.
65-
- Probes carry **no Authorization** — they are anonymous liveness checks of
66-
public surfaces. By default any status **< 500 counts as alive**: a
67-
400/401/404 from a guard is a living plugin answering. `expect: [200]`
68-
(a status allowlist) narrows that per probe.
69-
- `kind: 'ws'` — see finding 3 below: WebSocket endpoints are probed with a
70-
plain HTTP GET; upgrade-refusal statuses (400/426) count as *up*.
71-
- States: **up** (2xx/3xx, or a matched `expect`), **degraded** (answered
72-
4xx — alive, but guarded/not plainly OK), **down** (no answer, timeout,
73-
5xx, or an `expect` mismatch). `alive` = not down.
74-
- Probes run server-side on **every** `status.json` request, concurrently
75-
(`Promise.all`), each with a ~3s timeout (`AbortSignal.timeout`,
76-
`config.timeoutMs`), **never cached** — so each page refresh costs O(N)
77-
loopback fetches. Fine for a human-refresh dashboard; put a poller with
78-
its own cadence in front if you want to hammer it.
79-
- The host itself is probed too (`GET /`, any non-5xx counts).
80-
- No `config.plugins` → an empty dashboard that still renders, with a note
81-
explaining why it can't populate itself.
55+
The plugin **list** comes from `api.plugins` — you don't supply it. Each
56+
discovered plugin is probed at its own `prefix` by default; `config.probes`
57+
optionally refines a single plugin's probe as `{ probe?, expect?, kind? }`:
58+
59+
- `probe` — a **local path** on this server, overriding the default (the
60+
plugin's prefix). It must start with `/`, must not contain `://`, and must
61+
not point back at the dashboard itself (recursion); all three are enforced
62+
with a throw at `activate`. Probes go **only** to the host's own loopback
63+
origin, never to external URLs.
64+
- Probes carry **no Authorization** — anonymous liveness checks of public
65+
surfaces. By default any status **< 500 counts as alive**: a 400/401/404
66+
from a guard is a living plugin answering. `expect: [200]` narrows that.
67+
- `kind: 'ws'` — a WebSocket endpoint; an upgrade-refusal (400/426) to the
68+
plain-HTTP probe counts as *up* (see finding 3).
69+
- States: **up** (2xx/3xx or a matched `expect`), **degraded** (a 4xx —
70+
alive but guarded), **down** (no answer, timeout, 5xx, or `expect`
71+
mismatch). `alive` = not down.
72+
- Probes run server-side on **every** `status.json` request, concurrently,
73+
each with a ~3s timeout (`config.timeoutMs`), **never cached** — O(N)
74+
loopback fetches per refresh. The host itself is probed too (`GET /`).
75+
- If the dashboard is the only plugin loaded, the page renders with a note
76+
explaining there are no siblings to show.
8277

8378
### The page, in words
8479

85-
A single narrow column, system font, honest table. Header "plugin
86-
dashboard", a muted meta line ("probed 2026-07-11T… — live, uncached,
87-
anonymous loopback probes every 5s"). Then one table: **plugin | probe |
80+
A single narrow column, system font, honest table: **plugin | probe |
8881
status | http | latency**. First row is the server itself, then one row per
89-
declared plugin: the id, the probe path in `code` (ws probes tagged with a
90-
small `ws` chip), a pill badge — green **up**, amber **degraded**, red
91-
**down** — the HTTP status ("—" when nothing answered), and "3 ms" in
92-
tabular numerals. Everything inline: no external assets, no framework, one
93-
small script that re-fetches `status.json` every `refreshMs` (default 5s)
94-
and rewrites the cells. Dark-mode via `prefers-color-scheme`. Without JS
95-
you still get the declared list (statuses stay "…"), so `curl` shows the
96-
inventory.
82+
discovered plugin — id, probe path in `code` (ws probes tagged with a small
83+
`ws` chip), a pill badge (green **up**, amber **degraded**, red **down**),
84+
the HTTP status ("—" when nothing answered), latency in tabular numerals.
85+
Everything inline: no external assets, one small script that re-fetches
86+
`status.json` every `refreshMs` (default 5s). Dark-mode via
87+
`prefers-color-scheme`. Without JS you still get the discovered list, so
88+
`curl` shows the inventory.
9789

9890
## What maps / what doesn't
9991

10092
| capability | status |
10193
|---|---|
102-
| list every installed plugin | ✅ but only from a hand-copied `config.plugins` (the finding) |
94+
| list every loaded plugin | ✅ auto-discovered from `api.plugins` (#610) — no hand-copied list |
95+
| find the host to probe it |`api.serverInfo` (#601) — no `loopbackUrl` in config |
10396
| live liveness per plugin | ✅ anonymous loopback GET, <500 = alive, `expect` to sharpen |
10497
| host self-check |`GET /` non-5xx |
98+
| meaningful per-plugin probe | ⚠️ default = the plugin's prefix; refine via `config.probes` (finding 1) |
10599
| ws endpoint health | ⚠️ plain-HTTP approximation only (finding 3) |
106-
| auto-discovery of siblings | ❌ no `api.plugins` — impossible today (#463/#464) |
107-
| deep health (deps, storage, queue lag) | ❌ out of scope; probes are surface liveness only |
100+
| deep health (deps, storage, queue lag) | ❌ out of scope; surface liveness only |
108101

109102
## Findings
110103

111-
1. **A plugin cannot enumerate its co-loaded plugins — the #463/#464
112-
app-registry seam, first live consumer.** The `api` object carries no
113-
registry: no `api.plugins`, no `api.serverInfo`, nothing that says "these
114-
entries were passed to `createServer` alongside you". So the one plugin
115-
whose entire job is *describing the deployment* must be handed a
116-
**duplicate** of the operator's own plugins list in `config.plugins`
117-
and the two lists drift silently: add a plugin to `createServer` and
118-
forget the dashboard, and the dashboard simply doesn't show it; remove
119-
one and the dashboard cheerfully probes a path that now 401s/404s, which
120-
the default rule calls *alive* (see finding 4 — on this host a missing
121-
route and a guarded one are anonymously indistinguishable). Nothing can
122-
detect the drift from inside. The seam this proves the need for:
123-
`api.plugins``[{ id, prefix, module }]` (read-only, the loader already
124-
holds exactly this), ideally with an optional operator/plugin-supplied
125-
hint like `probe: '/feed/atom'` or `health: () => …` per entry — which is
126-
also precisely the "surface installed plugins as Solid resources" ask of
127-
#463/#464: this dashboard is what the consumer of that resource looks
128-
like, built today at the cost of a hand-maintained shadow copy.
129-
2. **`api.serverInfo` again.** The dashboard needs the host's own origin to
130-
probe it (`loopbackUrl`), and throws at `activate` when it's missing —
131-
the same origin the operator has already told `createServer` (port) and
132-
a dozen sibling plugins (`baseUrl`/`loopbackUrl` in nearly every entry in
133-
`serve.js`). This is the ~13th consumer of the `api.serverInfo` finding;
134-
the repetition is now itself dashboard-visible, since the operator types
135-
the same `http://127.0.0.1:PORT` string into yet another config block.
136-
3. **WebSocket endpoints can't be truthfully probed over plain HTTP — the
137-
`kind: 'ws'` simplification.** A ws endpoint's healthy answer to a plain
138-
GET is *refusal*. We accept 400/426 (upgrade-required) as *up* — but this
139-
host's upgrade stack registers no plain-GET route at all for `ws.route`
140-
paths, so `GET /relay` actually draws a **404**, indistinguishable from
141-
"no such plugin" (it lands as *degraded*, honest but mushy). An honest ws
142-
probe needs a real upgrade handshake — buildable here since `ws` is an
143-
allowed repo dep, but it drags in a dep and connection lifecycle for a
144-
liveness ping, so we documented the approximation instead. Which seam?
145-
None cleanly: it's a corollary of the registry gap — if `api.plugins`
146-
existed, entries could carry `kind`/liveness hints from the plugins
147-
themselves (relay *knows* it's a socket; `api.ws.route` could register a
148-
conventional HTTP `GET → 426` on the same path for free, which would
149-
also make this dashboard's 400/426 rule land as designed).
150-
4. **Anonymous probes can't tell "guarded" from "missing".** On this host,
151-
an unknown path (`/ghost/health`) draws **401**, not 404 — WAC answers
152-
before routing — and a POST-only route answers GET with 404. So every
153-
anonymous 4xx means only "the server routed and answered", and the
154-
default <500-is-alive rule is exactly as strong as that statement, no
155-
stronger. `expect: [200]` is the operator's tool for surfaces that should
156-
answer anonymously (that's how the test demonstrates *down*). A
157-
per-probe `Authorization` was deliberately not added: a dashboard config
158-
holding live bearer tokens is a worse failure mode than a mushy probe.
159-
5. **Self-probe recursion.** `status.json` runs probes; a probe pointed at
160-
`status.json` would recurse — each probe fans out N more probes until
161-
the timeouts cascade. Declaring a probe under the dashboard's own prefix
162-
is therefore rejected at `activate`. Trivial, but it's the kind of
163-
footgun a real `api.plugins` registry would have to consider too (a
164-
registry-driven dashboard would find *itself* in the list).
104+
1. **`api.plugins` shipped — and this is what it unlocked (with a residual).**
105+
This plugin was the proof-of-need for the app-registry seam (#463/#464
106+
filed #610 → merged #612, JSS 0.0.218), and it now consumes it: the
107+
hand-copied `config.plugins` array is **gone**, and drift is impossible —
108+
add or remove a plugin and the dashboard reflects it with zero config
109+
change. The residual, now sharpened: the roster is `{ id, prefix, module }`
110+
with **no health hints**, so the dashboard defaults to probing each
111+
plugin's prefix and still needs `config.probes` to characterise the ones a
112+
bare-prefix hit doesn't (ws endpoints; a plugin whose prefix 404s but a
113+
sub-path answers). That is exactly the *optional per-entry `probe`/`health`
114+
hint* anticipated in the #610 sketch — the remaining, smaller ask.
115+
2. **`api.serverInfo` shipped — origin repetition gone.** The dashboard used
116+
to require `loopbackUrl` and throw without it; now it calls
117+
`api.serverInfo()` (#601, 0.0.218) per request and builds the callable
118+
`host:port` itself. One timing note that makes serverInfo's function
119+
shape (not a snapshot) matter: the origin is resolved **at request time**,
120+
not cached at `activate` — a probe-time port-0 boot only knows its real
121+
port once listening, which is precisely the case serverInfo() handles.
122+
3. **WebSocket endpoints still can't be truthfully probed over plain HTTP —
123+
the `kind: 'ws'` refinement.** A ws endpoint's healthy answer to a plain
124+
GET is *refusal*; we accept 400/426 as *up*. But this host's upgrade stack
125+
registers no plain-GET route for `ws.route` paths, so `GET /relay` draws a
126+
**404**, indistinguishable from "no such plugin" (lands as *degraded*).
127+
An honest ws probe needs a real handshake. This is now a corollary of
128+
finding 1's residual: if the roster carried a `kind`/health hint, the
129+
plugin itself (relay *knows* it's a socket) could supply it instead of the
130+
operator typing `{ kind: 'ws' }` — or `api.ws.route` could register a
131+
conventional `GET → 426` on the same path.
132+
4. **Anonymous probes can't tell "guarded" from "missing".** On this host an
133+
unknown path draws **401** (WAC answers before routing), and a POST-only
134+
route answers GET with 404. So every anonymous 4xx means only "the server
135+
routed and answered", and the default <500-is-alive rule is exactly that
136+
strong. `expect: [200]` is the operator's tool for surfaces that should
137+
answer anonymously. A per-probe `Authorization` was deliberately not
138+
added: a dashboard config holding live bearer tokens is a worse failure
139+
mode than a mushy probe.
140+
5. **Self-probe recursion.** The dashboard filters *itself* out of the
141+
roster (by matching its own prefix) so it never probes `status.json`
142+
which would fan out N more probes until timeouts cascade. A `config.probes`
143+
refinement pointing under the dashboard's own prefix is also rejected at
144+
`activate`. Exactly the footgun the #610 sketch flags for any
145+
registry-driven consumer: it finds itself in the list.
165146

166147
## Test
167148

168149
```
169150
node --test --test-concurrency=1 dashboard/test.js
170151
```
171152

172-
Boots one JSS carrying the dashboard plus three real siblings from this
173-
repo (rss, capability, relay) and asserts the page, the JSON shape, the
174-
alive/expect/down semantics, the no-cache property, the empty-list
175-
fallback, and the pre-boot validation throws (ordered before the long-lived
153+
Boots one JSS (≥ 0.0.218) carrying the dashboard plus three real siblings
154+
(rss, capability, relay), with **no** hand-fed list and **no** loopbackUrl,
155+
and asserts: auto-discovery names every sibling, the JSON shape, the
156+
alive/expect/down semantics, the no-cache property, an empty-server render,
157+
and the `config.probes` validation throws (ordered before the long-lived
176158
boot — the `DATA_ROOT` footgun).

0 commit comments

Comments
 (0)