Skip to content

Commit 1181215

Browse files
Integrate wave 8 (metrics, dashboard): 28 plugins, 282 tests
compose.test.js and serve.js boot all 28; serve.js's dashboard entry carries a hand-copied probe list mirroring its own plugins array — which is the finding it exists to demonstrate (#463/#464: no api.plugins registry, so the two lists can silently drift). Findings folded into NOTES.md and REPORT.md: (1) plugin-to-plugin isolation is zero, measured — api.fastify hooks fire for every plugin's routes in the shared register scope, never for core's, so the #564 line holds against core but siblings can observe and intercept each other ungated; (2) core bug: logger:false breaks every plugin onResponse hook (access-log hook throws on the null logger and aborts the chain) — filed-worthy one-liner; (3) the app-registry seam (#463/#464) gains its first live consumer; (4) api.serverInfo is now ~14 consumers. REPORT.md gains 'A measured surprise' section and the third bug; AGENT.md gains the logger footgun and two copy-map rows.
1 parent 0cb5ab7 commit 1181215

8 files changed

Lines changed: 177 additions & 25 deletions

File tree

AGENT.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Out-of-tree plugins for [JavaScript Solid
1111
Server](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer),
1212
built on its **#206 loader** (`createServer({ plugins })`, JSS ≥ 0.0.215).
1313
It is an *experiment*: prove the plugin api by using it, and treat every
14-
wall you hit as a finding, not a blocker. **26 plugins, 264 tests today.**
14+
wall you hit as a finding, not a blocker. **28 plugins, 282 tests today.**
1515

1616
### The one rule that makes the experiment valid
1717

@@ -151,6 +151,8 @@ Full ranking in `NOTES.md`. The ones you'll hit:
151151
| a dev/tooling subsystem | `gitscratch/` | shell a system binary via CGI |
152152
| a posting protocol (IndieWeb-style, client-discovered endpoint) | `micropub/` | pod bearer as the protocol token + loopback writes |
153153
| a data-export / archive download | `backup/` | loopback container walk streamed into a hand-rolled format |
154+
| an ops/observability endpoint | `metrics/` | node builtins + an `api.fastify` hook (scope: all plugins, never core) |
155+
| a meta/status page over siblings | `dashboard/` | anonymous loopback probes + an operator-declared list (no registry) |
154156
155157
## Footguns (every multi-boot suite rediscovered these)
156158
@@ -165,10 +167,14 @@ Full ranking in `NOTES.md`. The ones you'll hit:
165167
pass an explicit `id` in `compose.test.js`/`serve.js`.
166168
- **Dotted prefixes** (`/.foo`) fail the WS upgrade — core reserves dotted
167169
paths. Use a plain prefix for anything with a socket.
170+
- **`logger: false` kills plugin `onResponse` hooks**: core's access-log
171+
hook throws on the null logger and aborts the downstream chain. If your
172+
plugin uses `onResponse`, boot tests with `logger: true, logLevel:
173+
'silent'` (helpers.js defaults to `logger: false`).
168174
169175
## Current state
170176
171-
26 plugins (6 ports + 20 features), 264 tests, all green (`npm test`).
177+
28 plugins (6 ports + 22 features), 282 tests, all green (`npm test`).
172178
`compose.test.js` runs every one on a single server from pure config. Two
173179
core PRs (#590 `api.mountApp`, #591 `/idp/refresh`) sit upstream, unmerged,
174180
for the maintainer's call. Everything else lives here, by design.

ISSUES.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ evidence. Legend:
3737
|| `s3/` | S3-compatible object-storage gateway |
3838
|| `micropub/` | IndieWeb Micropub endpoint (media upload → 🔩 raw-body stream #583) |
3939
|| `backup/` | pod → `.tar.gz` export (incremental backup → 🔩 `api.events`) |
40+
|| `metrics/` | `/healthz` + Prometheus exporter (core-pipeline metrics → 🔩 gated hooks) |
41+
|| `dashboard/` | plugin status page (sibling discovery → 🔩 app-registry #463/#464) |
4042

4143
Plus six **ports of bundled features** proving the migration path for #564 /
4244
#164: `relay/` `webrtc/` `terminal/` `tunnel/` `notifications/`, and `pay/`
@@ -71,7 +73,7 @@ Each names the seam and the consumer that proves it. Ranked in
7173
| #515/#516, #211 (both built, but need operator `appPaths`) | `api.reservePath()` — an API-shim owns fixed roots outside its prefix and can't self-exempt them from WAC; **two independent confirmations** |
7274
| #505 (recovery) | `api.auth.mintSession` / `api.identity.addAuthKey` — turn a proven channel into pod authority |
7375
| #495 #496 #500 #501 | `api.mcp.registerTool` — MCP has no plugin-tool seam; all four MCP issues want new tools a plugin can't add today |
74-
| #463 #464 | app-registry primitive — surfacing installed plugins as Solid resources |
76+
| #463 #464 | app-registry primitive — surfacing installed plugins as Solid resources; `dashboard/` is the live proof-of-need (it must be handed a hand-copied duplicate of the `createServer` list) |
7577

7678
## Core, not a plugin 🏛️ (a finding, not a gap)
7779

@@ -98,11 +100,12 @@ exists.
98100
## Tally
99101

100102
Of ~40 plugin-tagged issues: **13 built as plugins here** (plus `rss/`,
101-
`matrix/`, `search/`, `didweb/`, `s3/`, `micropub/`, `backup/` — capability
103+
`matrix/`, `search/`, `didweb/`, `s3/`, `micropub/`, `backup/`, `metrics/`,
104+
`dashboard/` — capability
102105
demonstrations with no single issue), **6 ported**, **5 shipped upstream**,
103106
**2 more plugin-able with no blocker**, **6 clusters blocked on a named
104107
seam** (each with a proof-of-need consumer), the rest core-by-nature or
105-
product-scale. **26 plugins total, 264 tests.** The plugin api reaches most
108+
product-scale. **28 plugins total, 282 tests.** The plugin api reaches most
106109
of the backlog today; ranked by demand, the seams that would unlock the
107110
most next are `api.authorize` (3 blocking consumers), `api.events` (5
108111
consumers — matrix `/sync` needs live push, backup can only pull-on-demand),

NEXT.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,18 @@ plugin), `NOTES.md` (the findings/seams — the real deliverable), and
88

99
## Where things stand
1010

11-
- **26 plugins, 264 tests, all green** (`npm test`), all pushed to
11+
- **28 plugins, 282 tests, all green** (`npm test`), all pushed to
1212
`github.com/JavaScriptSolidServer/plugins` (branch `gh-pages`).
13-
- `compose.test.js` boots all 26 on **one** JSS from pure config; `serve.js`
13+
- `compose.test.js` boots all 28 on **one** JSS from pure config; `serve.js`
1414
is the runnable demo. Both must be updated when you add a plugin.
1515
- Built so far: 6 ports (relay, webrtc, terminal, tunnel, notifications,
16-
pay) + 20 features (nip05, corsproxy, capability, webdav, sparql,
16+
pay) + 22 features (nip05, corsproxy, capability, webdav, sparql,
1717
gitscratch, otp, carddav, mastodon, bluesky, caldav, webfinger,
18-
activitypub, rss, matrix, search, didweb, s3, micropub, backup).
18+
activitypub, rss, matrix, search, didweb, s3, micropub, backup,
19+
metrics, dashboard).
1920
- Capability classes covered: realtime, WebDAV family, fediverse/social/chat
2021
(5 shims), IndieWeb publishing, identity, data/query/search, object
21-
storage, proxy, dev, pay, data portability.
22+
storage, proxy, dev, pay, data portability, ops/observability.
2223
- **REPORT.md exists** — the maintainer-facing summary (ranked seams, each
2324
fileable nearly verbatim). Keep its consumer counts current as plugins
2425
land.
@@ -75,8 +76,6 @@ Still genuinely plugin-shaped and distinct:
7576
- **oEmbed** provider for pod resources (link unfurling).
7677
- **feed ingest** — subscribe to external RSS/Atom, store items in the pod
7778
(the inverse of `rss/`; uses a corsproxy-style fetch).
78-
- **healthz / Prometheus `/metrics`** and a **plugin status dashboard**
79-
(ops; the dashboard is a nice meta-demo listing all loaded plugins).
8079
- **link-shortener**, **WebSub/PubSubHubbub** (needs `api.events`),
8180
**SPARQL UPDATE** (extends `sparql/`), **CalDAV scheduling/free-busy**
8281
(extends `caldav/`), **remoteStorage** (another bundled-feature port).

NOTES.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,27 @@ ports reached for it without coordinating.
125125
advertise it by hand). A plugin can't add headers to routes it doesn't
126126
own. NOT a default-on hook (bigger grant than route ownership); gate
127127
behind `capabilities: ['hooks']` if ever.
128+
9. **Plugin-to-plugin isolation is ZERO (measured — metrics/).** All
129+
loader entries are activated against **one shared Fastify register
130+
scope**: a hook added on `api.fastify` (`onRequest`, `preHandler`,
131+
`onResponse`, `onSend`) fires for **every plugin's routes, whichever
132+
order the entries load** — and **never** for core routes (`/`, LDP,
133+
`/.well-known/*`), which live on the parent instance behind Fastify
134+
encapsulation. So the #564 line holds against *core* (a plugin cannot
135+
observe or modify core's pipeline), but plugins can already observe
136+
*and intercept* each other with no capability gate. Design tension for
137+
any future `capabilities: ['hooks']`: per-plugin encapsulation would be
138+
the safer default, but it would break the one legitimate consumer found
139+
(metrics/' cross-plugin request counters) — a scoped-vs-shared choice
140+
the loader should make deliberately, not inherit from `register`.
141+
10. **`api.plugins` (the #463/#464 app-registry) — first live consumer:
142+
dashboard/.** The one plugin whose job is describing the deployment
143+
cannot enumerate its co-loaded siblings; the operator hands it a
144+
hand-copied duplicate of the `createServer` plugins list, and the two
145+
silently drift (an added plugin never appears; a removed one keeps
146+
probing). The loader already holds exactly the needed data:
147+
`api.plugins → [{ id, prefix, module }]`, read-only, plus optional
148+
probe/health hints per entry.
128149

129150
## Test-harness footguns (host quirks, not plugin api)
130151

@@ -184,6 +205,15 @@ this repo is its proof.
184205
candidate: when the basename is generic (`plugin`, `index`), derive from
185206
the parent directory (`relay/plugin.js``relay`). Small, backward-
186207
compatible, removes the most common footgun. **Filed-worthy.**
208+
- **`logger: false` silently kills every plugin `onResponse` hook**
209+
(metrics/): core's access-log hook calls
210+
`request.log.isLevelEnabled('info')`, which doesn't exist on Fastify's
211+
null logger under `logger: false` — the hook throws per-request and the
212+
aborted chain means downstream *plugin* `onResponse` hooks never run
213+
(earlier stages are unaffected). Workaround: boot with `logger: true,
214+
logLevel: 'silent'`. One-line core fix; **filed-worthy.** (helpers.js
215+
defaults to `logger: false`, so any plugin using `onResponse` in tests
216+
hits this.)
187217
- **Dotted prefixes fail the ws upgrade** — a plugin mounted at
188218
`/.terminal` cannot accept WebSocket connections (immediate upgrade
189219
error), while `/terminal` and even `/.notifications` HTTP work. Core

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,11 @@ README and NOTES.md, ship the closest honest approximation.
8484
| `s3/` || S3-compatible object-storage gateway (SigV4) |
8585
| `micropub/` || IndieWeb Micropub endpoint — posts land in the author's pod |
8686
| `backup/` || pod container → `.tar.gz` download (data portability) |
87+
| `metrics/` || `/healthz` + Prometheus exporter; measured the plugin-hook scope |
88+
| `dashboard/` || live plugin-status page; first consumer of the #463/#464 registry seam |
8789

88-
**264 tests, all green** (`npm test`), including `compose.test.js` — all
89-
twenty-six plugins on one server from pure config, pods + WAC intact
90+
**282 tests, all green** (`npm test`), including `compose.test.js` — all
91+
twenty-eight plugins on one server from pure config, pods + WAC intact
9092
beside them. Findings consolidated in [NOTES.md](./NOTES.md); the full
9193
plugin-tagged backlog triaged in [ISSUES.md](./ISSUES.md); the build guide
9294
is [AGENT.md](./AGENT.md); the maintainer-facing summary — what to add

REPORT.md

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# The plugin api, 26 plugins later — a report for the maintainer
1+
# The plugin api, 28 plugins later — a report for the maintainer
22

33
This document is the actionable summary of the whole experiment: what the
44
#206 plugin api can already do, what it can't, and — ranked with evidence —
@@ -14,11 +14,11 @@ want it.
1414
## Executive summary
1515

1616
- The api as shipped in 0.0.215 (`createServer({ plugins })` + `prefix` +
17-
`getAgent` + `pluginDir` + `ws.route`) is **sufficient for 26 real
18-
plugins across eleven capability classes** — realtime, DAV,
17+
`getAgent` + `pluginDir` + `ws.route`) is **sufficient for 28 real
18+
plugins across twelve capability classes** — realtime, DAV,
1919
fediverse/chat shims, IndieWeb publishing, identity, query/search,
20-
object storage, proxy, dev tooling, pay, data portability — with zero
21-
core changes.
20+
object storage, proxy, dev tooling, pay, data portability,
21+
ops/observability — with zero core changes.
2222
- Of the ~40 `plugin`-tagged backlog issues, **13 are built here**, 6
2323
bundled features are ported, 5 shipped upstream during this line of work,
2424
2 more are unblocked, and **6 clusters are blocked on exactly four
@@ -27,8 +27,13 @@ want it.
2727
`api.events.onResourceChange`, `api.reservePath`, and `api.serverInfo`.
2828
Adding the first three moves essentially every remaining plugin-shaped
2929
issue from "honest approximation" to "faithful implementation".
30-
- Two small loader bugs are worth fixing regardless (generic-basename id
31-
derivation; dotted-prefix WS validation).
30+
- Three small loader/core bugs are worth fixing regardless
31+
(generic-basename id derivation; dotted-prefix WS validation;
32+
`logger: false` breaking every plugin `onResponse` hook).
33+
- One measured surprise: **plugins are not isolated from each other**
34+
all entries share one Fastify register scope, so any plugin can hook
35+
every other plugin's routes (never core's). Worth a deliberate
36+
decision, not an inherited one.
3237

3338
## What already works — decisions worth keeping
3439

@@ -131,9 +136,9 @@ exists, moving it from operator config to plugin declaration.
131136

132137
### 4. `api.serverInfo` — the broadest, and the cheapest
133138

134-
**~12 consumers** — every plugin that mints absolute URLs or loopbacks
139+
**~14 consumers** — every plugin that mints absolute URLs or loopbacks
135140
(the DAV family, the shims, rss, sparql, nip05, webfinger, notifications,
136-
micropub, backup…)
141+
micropub, backup, metrics, dashboard…)
137142
repeats `baseUrl`/`loopbackUrl` in config today. A wrong value fails
138143
*quietly* (nip05 serves an empty map). Test suites all need a
139144
probe-port-then-boot dance for the same reason.
@@ -149,6 +154,11 @@ probe-port-then-boot dance for the same reason.
149154
property to preserve, not new work.
150155
- **`api.mcp.registerTool`** — blocks the four MCP-tool issues
151156
(#495/#496/#500/#501); no consumer here because it's impossible today.
157+
- **`api.plugins` (the #463/#464 app-registry)** — first live consumer:
158+
`dashboard/`, the plugin whose whole job is describing the deployment,
159+
must be handed a hand-copied duplicate of the `createServer` plugins
160+
list (drift is silent). The loader already holds the needed data:
161+
`api.plugins → [{ id, prefix, module }]`, read-only.
152162
- **Export pure utility modules** the way `auth.js` is blessed —
153163
`relay/` re-vendors NIP-01 verify, `pay/` re-vendors mrc20. A
154164
`javascript-solid-server/nostr.js` export (or explicit vendoring
@@ -162,7 +172,7 @@ probe-port-then-boot dance for the same reason.
162172
Deliberately *not* asked for as a default-on hook: it's a bigger grant
163173
than route ownership. If ever, gate it: `capabilities: ['hooks']`.
164174

165-
## Two loader bugs worth fixing regardless
175+
## Three loader/core bugs worth fixing regardless
166176

167177
1. **Generic-basename id derivation.** Every plugin follows
168178
`<name>/plugin.js`, so every derived id is `plugin` and the (correct)
@@ -174,6 +184,36 @@ probe-port-then-boot dance for the same reason.
174184
can't accept WebSocket connections (host-reserved dotted paths), while
175185
`/terminal` works. Either validate/refuse dotted prefixes at load or
176186
document the reservation.
187+
3. **`logger: false` silently disables every plugin `onResponse` hook.**
188+
Core's access-log hook calls `request.log.isLevelEnabled('info')`,
189+
which doesn't exist on Fastify's null logger; the per-request throw
190+
aborts the downstream hook chain, so plugin `onResponse` hooks never
191+
fire (found by `metrics/`). One-line guard fixes it.
192+
193+
## A measured surprise: plugins are not isolated from each other
194+
195+
`metrics/` needed request counters, so it added an `onResponse` hook on
196+
`api.fastify` and measured what the hook can see (both entry orders,
197+
core routes probed too). Result: **the hook fires for every plugin's
198+
routes and never for core's.** All loader entries are activated against
199+
one shared `fastify.register` scope, while core routes live on the
200+
parent instance behind Fastify encapsulation.
201+
202+
Two readings, both true:
203+
204+
- The #564 line **holds against core** — a plugin cannot observe or
205+
modify the pipeline of core routes, exactly as intended.
206+
- Plugins can already observe **and intercept** each other, ungated. A
207+
hostile or buggy plugin can shadow-log or rewrite a sibling's traffic
208+
today.
209+
210+
This is worth a deliberate decision rather than an inherited one:
211+
per-plugin encapsulation (each entry in its own `register` scope) is the
212+
safer default, but note it would break the one legitimate cross-plugin
213+
consumer found (a metrics exporter counting all plugin traffic) — if
214+
hooks are ever capability-gated (`capabilities: ['hooks']` /
215+
`['observe']`), the shared scope could become the *granted* behavior and
216+
isolation the default.
177217

178218
## The core/plugin line (#564, answered empirically)
179219

compose.test.js

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ describe('composition: every plugin on one server', () => {
4646

4747
after(async () => { if (jss) await jss.close(); });
4848

49-
it('boots pods + idp + twenty-six plugins from config', async () => {
49+
it('boots pods + idp + twenty-eight plugins from config', async () => {
5050
const port = await probePort();
5151
base = `http://127.0.0.1:${port}`;
5252
wsBase = `ws://127.0.0.1:${port}`;
@@ -95,6 +95,22 @@ describe('composition: every plugin on one server', () => {
9595
{ id: 's3', module: at('s3/plugin.js'), prefix: '/s3', config: { baseUrl: base, loopbackUrl: base } },
9696
{ id: 'micropub', module: at('micropub/plugin.js'), prefix: '/micropub', config: { baseUrl: base, loopbackUrl: base } },
9797
{ id: 'backup', module: at('backup/plugin.js'), prefix: '/backup', config: { baseUrl: base, loopbackUrl: base } },
98+
{ id: 'metrics', module: at('metrics/plugin.js'), prefix: '/metrics', config: { loopbackUrl: base } },
99+
{
100+
id: 'dashboard',
101+
module: at('dashboard/plugin.js'),
102+
prefix: '/dashboard',
103+
config: {
104+
loopbackUrl: base,
105+
// A hand-copied slice of this very list — a plugin can't see its
106+
// siblings (the #463/#464 app-registry finding).
107+
plugins: [
108+
{ id: 'nip05', probe: '/nip05/nostr.json', expect: [200] },
109+
{ id: 'rss', probe: '/feed/atom', expect: [400] },
110+
{ id: 'relay', probe: '/relay', kind: 'ws' },
111+
],
112+
},
113+
},
98114
],
99115
});
100116
assert.ok(jss.base);
@@ -294,6 +310,27 @@ describe('composition: every plugin on one server', () => {
294310
assert.strictEqual(res.status, 400);
295311
});
296312

313+
it('metrics: healthz is ok and the exposition names the process gauges', async () => {
314+
let res = await fetch(`${base}/metrics/healthz`);
315+
assert.strictEqual(res.status, 200);
316+
assert.strictEqual((await res.json()).status, 'ok');
317+
res = await fetch(`${base}/metrics/metrics`);
318+
assert.strictEqual(res.status, 200);
319+
assert.match(await res.text(), /process_uptime_seconds/);
320+
});
321+
322+
it('dashboard: the page names the declared plugins; status.json probes live', async () => {
323+
let res = await fetch(`${base}/dashboard/`);
324+
assert.strictEqual(res.status, 200);
325+
const page = await res.text();
326+
for (const id of ['nip05', 'rss', 'relay']) assert.ok(page.includes(id), id);
327+
res = await fetch(`${base}/dashboard/status.json`);
328+
assert.strictEqual(res.status, 200);
329+
const status = await res.json();
330+
assert.strictEqual(status.server.alive, true);
331+
assert.ok(status.plugins.every((p) => p.alive), JSON.stringify(status.plugins));
332+
});
333+
297334
it('pods still work beside all of it (idp register + WAC)', async () => {
298335
let res = await fetch(`${base}/idp/register`, {
299336
method: 'POST',

0 commit comments

Comments
 (0)