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.
api.ws.route carried every realtime port — relay, webrtc, terminal,
tunnel control, notifications — with zero upgrade-handling code and no
@fastify/websocket dependency in any plugin. The decision to build it
on the host’s websocket stack (PR #589) rather than raw 'upgrade'
listeners meant five websocket features coexist in one process without a
single conflict.api.storage.pluginDir() — relay grew persistence core never had, in
~15 lines.assert.rejects(listen) for misconfiguration; the contract reads well
from the consumer side.api.wac.check)
from the necessary list, leaving them merely nice.If-Match/If-None-Match
forwarded verbatim are honored by
the host end-to-end — stale If-Match PUT/DELETE → 412,
If-None-Match: * on an existing resource → 412 with the body proven
not to land, If-None-Match GET → 304. No plugin-side conditional
logic needed for the stale-writer case. Caveat measured by sparql/:
the host’s check is check-then-write, not atomic — see the bugs section.Twelve plugins in, the ranking is now empirical — a seam’s rank is how many ports reached for it without coordinating.
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”.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.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.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.)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./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./<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./.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.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.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.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.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.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.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.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.Every multi-boot suite independently rediscovered these; worth a line in the plugin-author docs.
DATA_ROOT: JSS keeps the storage root (and IdP key
resolution) in a process-global env var that every createServer
repoints — a second boot in one process, even a deliberately-failing
one, poisons the first. Order validation-failure tests before the
long-lived boot. (notifications/, webdav/, sparql/ all hit this.)~/.gitconfig: git-shelling plugins inherit the operator’s
config — init.defaultBranch = gh-pages leaked dangling HEADs into
server-created bare repos (empty clones). Spawn git with
GIT_CONFIG_NOSYSTEM=1 and no HOME. (gitscratch/.)#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.
<name>/plugin.js,
so all six modules derive the id plugin and the loader’s duplicate-id
guard (added in PR #589 review) refuses to boot until each entry gets an
explicit id. The guard is correct; the derivation is weak. Fix
candidate: when the basename is generic (plugin, index), derive from
the parent directory (relay/plugin.js → relay). Small, backward-
compatible, removes the most common footgun. Filed-worthy.If-Match both return 2xx and the loser is silently
overwritten; the same two PUTs run sequentially 412 correctly. Two
simultaneous writers can lose one with both told success — unfixable
plugin-side; the host needs an atomic stat+write. Filed-worthy.err.code
(remotestorage/): a route collision inside activate surfaces as
plugin <id>: activate() failed: <message> with the original
FST_ERR_DUPLICATED_ROUTE code stripped — callers can only string-match
the message. Preserve code (or cause) when re-wrapping.logger: false silently kills every plugin onResponse hook
(metrics/): core’s access-log hook calls
request.log.isLevelEnabled('info'), which doesn’t exist on Fastify’s
null logger under logger: false — the hook throws per-request and the
aborted chain means downstream plugin onResponse hooks never run
(earlier stages are unaffected). Workaround: boot with logger: true,
logLevel: 'silent'. One-line core fix; filed-worthy. (helpers.js
defaults to logger: false, so any plugin using onResponse in tests
hits this.)/.terminal cannot accept WebSocket connections (immediate upgrade
error), while /terminal and even /.notifications HTTP work. Core
reserves specific dotted paths (/.terminal, /.webrtc) for its own
built-ins in the WAC-skip list; a plugin claiming a dotted prefix
collides with host-level dotfile/route handling in a way plain prefixes
don’t. Consequence: plugins should avoid dotted prefixes, or the loader
should validate/reserve them explicitly. (The composition uses
/terminal; /.notifications HTTP happens to work but wasn’t stressed
for upgrades.)/.well-known/solid/notifications status endpoint,
tunnel’s split control/traffic paths, ActivityPub’s webfinger) must
consolidate under one prefix or deviate from core’s URLs. Candidate:
prefixes: [] (plural) if a real consumer is blocked; consolidation was
fine for everything here.port option)
— same finding as api.serverInfo, visible in test setup.MANIFEST.json as the
last archive entry..acl/.meta could be
exported (loopback enforces Control for free) but absolute-URL ACLs
don’t restore elsewhere — policy portability is a spec-level gap, not a
plugin-api gap.pluginDir (fast, private, invisible to WAC,
backup, and portability) and pod resources (WAC-governed, portable,
but a loopback round-trip per operation). The api offers one primitive
and no guidance; a design note in the plugin docs would spare every
author the same deliberation. Eleven plugins now persist in
pluginDir — the most settled seam in the api.