plugins

Findings log

What the 0.0.215 plugin api gave us, what it didn’t, and what that implies upstream. One section per theme; port-specific detail lives in each port’s README. Nothing here has been filed as an issue yet — proposals below are candidates, each with a consumer in this repo attached.

What just worked (no changes wanted)

Candidate seams (ranked by how many independent plugins demanded them)

Twelve plugins in, the ranking is now empirical — a seam’s rank is how many ports reached for it without coordinating.

  1. api.authorize(request, path, mode) — “would the host’s WAC allow this?” Four independent consumers: corsproxy/, capability/, pay/, caldav/. The loopback trick (below) covers the case where the requester’s own credentials should decide (notifications, webdav, sparql all use it), but it can’t cover authorization the requester doesn’t drive: a proxy governed by a pod owner’s .acl (corsproxy #382), a capability exercising the issuer’s authority (capability #506), pay/’s 402 gate (which wants to compose with WAC rather than replace it), or CalDAV scheduling (RFC 6638), where Outbox→Inbox delivery is a write into the recipient’s pod the sender has no WAC right to make — loopback structurally cannot express it (a server-mediated deliver-to-inbox primitive is the narrower alternative). This is the most-requested seam and the one that moves the most backlog issues from “plugin-approximation” to “faithful”.
  2. api.events.onResourceChange(cb)seven consumers now: notifications/, sparql/, search/, matrix/, backup/, jmap/, remotestorage/ (plus oembed/ as a soft eighth: a write-time image- dimension cache would replace per-unfurl byte parsing), in rising sharpness: notifications (a miss is a late notification), sparql (a miss is a wrong query result), search (a miss is stale results, the property users most expect to be fresh — the most user-visible instance), matrix (its /sync?since= long-poll needs server-side sync cursors + live push on writes — an event hook feeding api.ws.route — so a stateless bridge can only do full-state /sync at all), and backup (with no change hook and no plugin-owned read authority, incremental and scheduled/server-initiated backup are both unbuildable — every backup is a caller-driven full crawl), jmap (JMAP push (eventSourceUrl) and delta sync (*/changes, Email/queryChanges) are omitted/refused in-protocol), and remotestorage (rS’s descendant-version propagation at depth ≥ 2 needs a write-time index). sparql/’s UPDATE work sharpened the whole entry: owning a write endpoint does not buy the write-time index — a plugin could index its own writes synchronously, but plain-LDP writes bypass it, yielding a silently-wrong index (worse than none); only the host’s event stream sees all writes. caldav/’s free-busy added a wrinkle: a shared write-time index must answer “indexed under whose authority?” or it leaks WAC-hidden data. Core already has the emitter internally (src/notifications/events.js); this is the seam every “react to pod writes” app (webhooks, indexing, sync, search, live chat) will want, and it’s now clearly the #2 most-demanded after api.authorize.
  3. api.serverInfo ({ baseUrl, port } at listen)23 consumers: notifications/, webdav/, carddav/, caldav/, sparql/, rss/, webfinger/, mastodon/, bluesky/, activitypub/, matrix/, micropub/, backup/, metrics/, dashboard/, oembed/, jmap/, remotestorage/, s3/, search/, shortlink/, didweb/, admin/ — essentially every plugin that mints absolute URLs or reaches the host over loopback. All repeat the origin in config today; a wrong value fails quietly (webfinger mints WebIDs on the wrong origin; didweb serves a did.json whose id doesn’t match its URL). (nip05/ was once listed here — wrongly: its README finding is that NIP-05 documents carry no absolute self-URLs; its config repetition is the data-root class, podsRoot, which serverInfo doesn’t cover.) The single most broadly needed seam (vs. api.authorize being the most blocking); trivially cheap to provide.
  4. The unconsumed-body-stream** primitive (#583)** — consumers: gitscratch/ sharpened it; micropub/ adds a blocked one. tunnel/ needed the raw buffer; git needs the raw stream piped to a subprocess gzip-and-all; micropub’s media endpoint (multipart file upload) is simply not implemented until a plugin can pipe an un-drained body; jmap/ likewise omits blobs/attachments (uploadUrl absent, maxSizeUpload: 0 advertised honestly). Whatever api.mountApp / raw-body mode ships must hand back the un-drained stream, not just a buffered body. (This is exactly what the merged loader’s scoped pass-through parser does — the finding is to keep it that way.)
  5. Routes/WAC-exemption outside the single prefixthe most-hit finding: seven+ consumers. A plugin can register absolute/exact routes outside its prefix (the loader doesn’t confine api.fastify), but the loader WAC-exempts only its one prefix. Consequences, in increasing severity:
    • /.well-known/* served by luck — nip05/ (nostr.json), webfinger/ (webfinger), webdav/carddav/caldav (caldav/carddav). Core happens to blanket-exempt /.well-known/*, so they work but by coincidence, not contract. Notably nip05 and webfinger are the two most-wanted .well-known docs a deployment serves, both riding the same undocumented luck.
    • fixed roots core does NOT exempt — mastodon/ (/api,/oauth), bluesky/ (/xrpc), activitypub/ (/ap), matrix/ (/_matrix). Here the plugin cannot serve its own surface: every call 401s at the WAC hook until the operator hand-passes appPaths. Four independent protocol-shim confirmations, each built separately. bluesky sharpened it (one root, still unreachable → the ask is “declare owned paths,” not “more prefixes”); activitypub sharpened it further — its natural layout wants paths interleaved with the pod’s own /<user>/ namespace, which no single mount prefix can carve out at all.
    • parameterized paths can’t be exempted at all — didweb/ serves /<user>/did.json, which lives inside the pod’s own WAC-governed /<user>/ namespace. appPaths matches literal prefixes, so it can’t carve out a parameterized route interleaved with pod paths; and unlike an API shim, did:web can’t escape to a fake root (the DID id is fixed by the method). It works only where the pod grants public Read. This is the sharpest form and needs a parameterized api.reservePath.
    • conflict detection: the collision is now WITNESSED, both ways (remotestorage/ + webfinger/ both own /.well-known/webfinger). Unguarded claim second → boot fails: plugin remotestorage: activate() failed: Method 'GET' already declared for route '/.well-known/webfinger' (Fastify’s FST_ERR_DUPLICATED_ROUTE, but the loader’s error wrap drops err.code — only the message identifies it). Reverse order → boot succeeds and webfinger/’s try/catch-guarded claim silently loses: its profile/actor/issuer links vanish with no error. Loud failure or silent loss — both wrong, because both plugins legitimately own parts of one discovery document. The missing seam is a link/JRD registry (api.webfinger.addLink). The seam: api.reservePath('/xrpc') / api.reservePath('/:user/did.json') (or paths: [...] in the entry) — the loader exempts and claims each deliberately and reports collisions. The seam every API-shim plugin structurally requires; third most-demanded after api.authorize and api.events. Counter-witness that sharpens it: micropub/ is a protocol shim that needed no reservation at all, because Micropub endpoints are client-discovered rather than protocol-fixed — the seam is specifically about protocols that pin absolute paths, not API shims per se.
  6. Can’t set fastify server options — consumers: capability/ hit maxParamLength (100) silently 404ing long tokens in named params; shortlink/ dodged it pre-emptively the same way. Workaround is a wildcard route. A plugin has no way to raise per-route limits. Minor, but sharp when it bites.
  7. Internal utility modules plugins re-vendor — consumer: relay/ (src/nostr/event.js NIP-01 verify), pay/ (src/mrc20.js). Pure crypto. Export like auth.js (javascript-solid-server/nostr.js) or bless vendoring.
  8. Response-header injection on core routesthree consumers: notifications/ (Updates-Via discovery), micropub/ (clients find the endpoint via <link rel="micropub"> on the user’s homepage — a core-owned resource the plugin can’t decorate; the operator must advertise it by hand), and oembed/ — the sharpest form: oEmbed discovery wants a per-resource <link> in every HTML resource’s head, which even a gated header hook couldn’t provide (in-HTML injection is content rewriting — core’s side of the #564 line; the Link: header variant would cover the rest). A plugin can’t add headers to routes it doesn’t own. NOT a default-on hook (bigger grant than route ownership); gate behind capabilities: ['hooks'] if ever.
  9. Plugin-to-plugin isolation is ZERO (measured — metrics/). All loader entries are activated against one shared Fastify register scope: a hook added on api.fastify (onRequest, preHandler, onResponse, onSend) fires for every plugin’s routes, whichever order the entries load — and never for core routes (/, LDP, /.well-known/*), which live on the parent instance behind Fastify encapsulation. So the #564 line holds against core (a plugin cannot observe or modify core’s pipeline), but plugins can already observe and intercept each other with no capability gate. Design tension for any future capabilities: ['hooks']: per-plugin encapsulation would be the safer default, but it would break the one legitimate consumer found (metrics/’ cross-plugin request counters) — a scoped-vs-shared choice the loader should make deliberately, not inherit from register.
  10. api.plugins (the #463/#464 app-registry) — two consumers: dashboard/ and admin/. The plugins whose job is describing the deployment cannot enumerate their co-loaded siblings; the operator hands each a hand-copied duplicate of the createServer plugins list, and the copies silently drift (an added plugin never appears; a removed one keeps probing). serve.js now maintains ONE shared INVENTORY array beside the real plugins array — the workaround that proves the seam. The loader already holds exactly the needed data: api.plugins → [{ id, prefix, module }], read-only, plus optional probe/health hints per entry.
  11. No operator concept — api.isOperator (admin/). Three plugins now hold three incompatible answers to “who is the operator?”: terminal/ (shared token), metrics/ (optional bearer token), admin/ (adminAgents WebID allowlist). The host itself knows nothing of operators, so every ops-facing plugin invents its own gate. A tiny seam (api.isOperator(agentId) or an operators: [] server option) would unify them.
  12. The admin-surface gaps, named (admin/). The wp-admin pillars the api cannot express, each measured by building the operator home: runtime enable/disable (deactivate() exists but nothing calls it after boot), install (#200 marketplace + post-boot loading), settings panels (no contribute-a-panel affordance — the adminPage link convention is the workaround), log viewing (api.log is write-only), and storage introspection (pods/disk stats need an operator-repeated podsRoot path — a filesystem cousin of api.serverInfo). Full gap table in admin/README.md.

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

Every multi-boot suite independently rediscovered these; worth a line in the plugin-author docs.

The core/plugin line — answering #564 empirically

#564 asks: which of JSS’s bundled features become plugins, and where’s the line? This repo answers it by trying — porting each feature onto the public api and seeing what survives. The line that fell out:

A feature is plugin-able iff it OWNS its routes. It stays core iff it MODIFIES the request pipeline of routes it doesn’t own.

Feature Shape Verdict Evidence
remoteStorage owns /storage/:user/* ✅ plugin remotestorage/ — 7th port; core’s bundled copy could move out-of-tree
nostr relay owns /relay ws ✅ plugin relay/ — parity + persistence
webrtc owns /webrtc ws ✅ plugin webrtc/ — full parity, zero imports
terminal owns a ws ✅ plugin terminal/ — hardened beyond core
tunnel owns a prefix ✅ plugin tunnel/ — parity, one prefix deviation
notifications owns a ws + watches storage ✅ plugin notifications/ — WAC via loopback
NIP-05, cors-proxy, webdav, capabilities own routes ✅ plugin this repo’s later ports
pay 402s LDP routes from the WAC hook core pay/ — the wall-report
conneg, quotas, WAC, LDP, the auth chain modify every request ❌ core — they ARE the pod

So #564’s migration list is right for the route-owning features (relay, AP, webrtc, terminal, tunnel, remoteStorage, the IdP’s own endpoints) — each can move out-of-tree behind the loader with no core change, exactly as this repo demonstrates for six of them. Pay is the one on that list that CAN’T, and it’s instructive: its essence is intercepting the LDP pipeline, which is the definition of core. The bundled features that look like plugins ARE plugins; the ones that look like middleware stay middleware.

Corollary for the loader: the only thing that would move a pipeline-modifying feature across the line is a capabilities: ['hooks'] grant (an explicit, separately-gated onRequest/onSend), which several findings above independently ask for. Until then, the line is clean and this repo is its proof.

Bugs the composition surfaced (this repo’s own dogfooding)

Smaller notes