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.routecarried every realtime port — relay, webrtc, terminal, tunnel control, notifications — with zero upgrade-handling code and no@fastify/websocketdependency 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.- Fail-loudly activation — several test suites rely on
assert.rejects(listen)for misconfiguration; the contract reads well from the consumer side. - The loopback pattern (notifications): a plugin that needs "would the
server allow X?" can ask the server itself — HTTP to the host with the
client's own credentials. Slower than an internal check but definitionally
correct. This removes a whole class of would-be seams (
api.wac.check) from the necessary list, leaving them merely nice. - Conditional writes pass through loopback intact (remotestorage/,
measured; sparql/ is the second consumer):
If-Match/If-None-Matchforwarded verbatim are honored by the host end-to-end — staleIf-MatchPUT/DELETE → 412,If-None-Match: *on an existing resource → 412 with the body proven not to land,If-None-MatchGET → 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.
New (2026-07-12, found standing up Phanpy against serve.js): agent
resolution is Host-sensitive. A WebID minted under the idpIssuer host
(localhost:<port>) fails core's ownership check when the same request
arrives via 127.0.0.1:<port> — same server, same valid token, 403.
Every loopback-forwarding plugin is exposed: the loopback bind MUST use
the issuer's host (serve.js now does), and a reverse-proxied deployment
whose loopback carries a different Host header would hit the same wall.
Test suites never see it because helpers.js uses 127.0.0.1 for both.
Seam-shaped ask: either core matches agents host-insensitively for
loopback binds, or api.serverInfo should bless one canonical loopback
URL that is guaranteed ownership-safe.
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 feedingapi.ws.route— so a stateless bridge can only do full-state/syncat 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 afterapi.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 adid.jsonwhoseiddoesn'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.) LANDED — #601, JSS 0.0.218 — and consumed by webfinger/, didweb/, dashboard/, admin/, gallery/ (resolve at request time;config.baseUrlstays as the reverse-proxy override); ~18 mechanical retrofits remain. Was the single most broadly needed seam (vs. api.authorize being the most blocking); trivially cheap to provide.- 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 (
uploadUrlabsent,maxSizeUpload: 0advertised honestly). LANDED — asapi.mountApp, JSS 0.0.219 — and consumed by gallery/ (streaming upload → loopback PUT, O(1) plugin memory). gallery/'s measurements sharpen what remains: the mounted lane sidesteps the hostbodyLimit(a mounted app that does NOT forward to core has no cap unless it adds one); streaming still ends at core's front door (the loopback PUT buffers — the missing half is a streaming LDP write path); core's LDP GET honors Range natively, so playback needed nothing; andgetAgentwants a raw-req form (Bearer works via a shim, DPoP/NIP-98 can't verify in the raw lane). Whateverapi.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.) - Routes/WAC-exemption outside the single prefix — the most-hit
finding: seven+ consumers. LANDED — as
api.reservePath(#602), JSS 0.0.219 — and consumed: mastodon/bluesky/activitypub/matrix self-reserve their literal roots (widened to exactly the verbs their routes implement — the read-only default is the right trap-guard), didweb/ consumes the parameterized form (/:user/did.json, the previously-inexpressible case). Still open from this cluster: the shared-discovery-document case (webfinger↔remotestorage both own parts of one JRD — a reservation can't split a document; the ask is a link registry), and didweb/'s new edge: the reservation matcher is length-unbounded while:userroutes cap atmaxParamLength100, so an over-long name falls through to LDPGET /*with the exemption applied (harmless read-only, but a real mismatch). The history, kept because the sharpenings still teach: A plugin can register absolute/exact routes outside its prefix (the loader doesn't confineapi.fastify), but the loader WAC-exempts only its oneprefix. 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-knowndocs 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-passesappPaths. 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.appPathsmatches 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 parameterizedapi.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'sFST_ERR_DUPLICATED_ROUTE, but the loader's error wrap dropserr.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')(orpaths: [...]in the entry) — the loader exempts and claims each deliberately and reports collisions. The seam every API-shim plugin structurally requires; third most-demanded afterapi.authorizeandapi.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.
- 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. - Internal utility modules plugins re-vendor — consumer: relay/
(
src/nostr/event.jsNIP-01 verify), pay/ (src/mrc20.js). Pure crypto. Export like auth.js (javascript-solid-server/nostr.js) or bless vendoring. - Response-header injection on core routes — three consumers:
notifications/ (
Updates-Viadiscovery), 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; theLink: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 behindcapabilities: ['hooks']if ever. - 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 futurecapabilities: ['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 fromregister. 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 thecreateServerplugins list, and the copies silently drift (an added plugin never appears; a removed one keeps probing). serve.js now maintains ONE sharedINVENTORYarray 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.- 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/ (adminAgentsWebID allowlist). The host itself knows nothing of operators, so every ops-facing plugin invents its own gate. A tiny seam (api.isOperator(agentId)or anoperators: []server option) would unify them. - 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 — theadminPagelink convention is the workaround), log viewing (api.logis write-only), and storage introspection (pods/disk stats need an operator-repeatedpodsRootpath — a filesystem cousin ofapi.serverInfo). Full gap table in admin/README.md.
Every multi-boot suite independently rediscovered these; worth a line in the plugin-author docs.
- Module-global
DATA_ROOT: JSS keeps the storage root (and IdP key resolution) in a process-global env var that everycreateServerrepoints — 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.) - Ambient
~/.gitconfig: git-shelling plugins inherit the operator's config —init.defaultBranch = gh-pagesleaked dangling HEADs into server-created bare repos (empty clones). Spawn git withGIT_CONFIG_NOSYSTEM=1and noHOME. (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.
- Generic-basename id collision — every port follows
<name>/plugin.js, so all six modules derive the idpluginand the loader's duplicate-id guard (added in PR #589 review) refuses to boot until each entry gets an explicitid. 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. - The host's conditional write is check-then-write, not atomic
(sparql/, measured live): two concurrent PUTs carrying the same
currently-valid
If-Matchboth 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. - The loader's activate-failure wrap drops
err.code(remotestorage/): a route collision insideactivatesurfaces asplugin <id>: activate() failed: <message>with the originalFST_ERR_DUPLICATED_ROUTEcode stripped — callers can only string-match the message. Preservecode(orcause) when re-wrapping. logger: falsesilently kills every pluginonResponsehook (metrics/): core's access-log hook callsrequest.log.isLevelEnabled('info'), which doesn't exist on Fastify's null logger underlogger: false— the hook throws per-request and the aborted chain means downstream pluginonResponsehooks never run (earlier stages are unaffected). Workaround: boot withlogger: true, logLevel: 'silent'. One-line core fix; filed-worthy. (helpers.js defaults tologger: false, so any plugin usingonResponsein tests hits this.)- Dotted prefixes fail the ws upgrade — a plugin mounted at
/.terminalcannot accept WebSocket connections (immediate upgrade error), while/terminaland even/.notificationsHTTP 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;/.notificationsHTTP happens to work but wasn't stressed for upgrades.)
- A plugin owns exactly one prefix. Features with scattered paths (core
notifications'
/.well-known/solid/notificationsstatus 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. - Test harness dance: a plugin whose config references the server's own
origin forces the port-probe-then-boot pattern (helpers.js
portoption) — same finding as api.serverInfo, visible in test setup. - No snapshot semantics (backup/): a multi-resource read is never
atomic — the api offers no lock, snapshot, or conditional multi-read, so
an archive of a busy pod is an honest but mixed-state export. Related:
a streaming response can't report post-walk results (skip counts) in
headers — they're committed before the walk starts and there are no
usable trailers — so backup/ writes an in-band
MANIFEST.jsonas the last archive entry. - Permissions don't round-trip (backup/):
.acl/.metacould 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 vs pod resources — no doctrine (shortlink/): stateful
plugins choose between
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 inpluginDir— the most settled seam in the api.