This document is the actionable summary of the whole experiment: what the
#206 plugin api can already do, what it can't, and — ranked with evidence —
what to add next. Everything asserted here has a working consumer in this
repo; nothing is speculative. Detail lives in NOTES.md
(findings), ISSUES.md (per-issue disposition), and each
plugin's README.md ## Findings.
Both the bugs and the seams are now filed upstream (2026-07-11): the
five bugs as #596–#600, and the four ranked seams as #601 (serverInfo),
#602 (reservePath), #603 (events), #604 (authorize). api.plugins (the read-only loaded-plugin registry) is also filed —
#610 — as the phase-1 blocker for a blessed read-only status console
(dashboard/). The remaining secondary asks (api.isOperator,
mcp.registerTool, per-route options, response-header injection,
pure-utility exports) are not filed yet — lower-priority, better raised
when a design discussion on the primary seams opens.
- The api as shipped in 0.0.215 (
createServer({ plugins })+prefix+getAgent+pluginDir+ws.route) is sufficient for 33 real plugins across fifteen capability classes — realtime, DAV, fediverse/chat shims, IndieWeb publishing, identity, query/search, object storage, proxy, dev tooling, pay, data portability, ops/observability, mail (JMAP), link-embeds (oEmbed), remoteStorage — with zero core changes. Seven of those are ports of bundled features; remoteStorage in particular still ships always-on in core and could move out-of-tree behind the loader. - Of the ~40
plugin-tagged backlog issues, 14 are built here, 7 bundled features are ported, 5 shipped upstream during this line of work, 2 more are unblocked, and 6 clusters are blocked on exactly four missing seams. The rest are core-by-nature (a finding, not a gap). - The four seams, ranked by independent demand, are
api.authorize,api.events.onResourceChange,api.reservePath, andapi.serverInfo. Adding the first three moves essentially every remaining plugin-shaped issue from "honest approximation" to "faithful implementation". - Five small loader/core bugs are worth fixing regardless
(generic-basename id derivation; dotted-prefix WS validation;
logger: falsebreaking every pluginonResponsehook; the loader's error wrap droppingerr.code; the host's conditional write being check-then-write rather than atomic). - One measured surprise: plugins are not isolated from each other — all entries share one Fastify register scope, so any plugin can hook every other plugin's routes (never core's). Worth a deliberate decision, not an inherited one.
Validated by use, not opinion:
api.ws.routeon the host's upgrade stack (PR #589's design): five WebSocket plugins coexist in one process with zero upgrade code and no@fastify/websocketdependency anywhere. Keep it.- The scoped pass-through body parser:
gitscratch/pipes a raw request stream intogit-http-backendgzip-and-all;tunnel/needs raw buffers;micropub/'s media endpoint (multipart upload) is waiting on exactly this. Whatever raw-body mode ships for #583 must keep handing back the un-drained stream, exactly as today. pluginDiras the only persistence — every stateful plugin (relay events, capability secrets, OTP table, AP keypairs, git repos) fit in it.- Fail-loudly activation —
throwinactivate→ boot failure is the right contract; test suites lean on it. - The loopback pattern removes a class of seams. A plugin that needs
"would WAC allow this for the requester?" asks the server itself over
HTTP with the client's own credentials. A dozen plugins run a whole
data plane this way. This is why
api.wac.check(agent, path, mode)is not on the ask list — only the cases loopback structurally can't cover are. - Conditional writes survive loopback intact (measured by
remotestorage/;sparql/'s UPDATE is the second consumer): forwardedIf-Match/If-None-Matchare honored by the host end-to-end (412 on stale, 304 on match). A protocol that needs optimistic concurrency gets the stale-writer case for free — but see bug 5: under true concurrency the check is not atomic.
Rank = how many plugins reached for the seam without coordinating. Each entry is written as a fileable issue: motivation, consumers, sketch, cost. All four are now filed: authorize → #604, events → #603, reservePath → #602, serverInfo → #601.
Ask: let a plugin ask the host's WAC engine for a decision the requester's credentials don't drive.
Why loopback can't cover it: loopback answers "may this caller read X?". Four plugins need "may someone else — a pod owner, a token issuer, a message recipient — authorize this?":
corsproxy/(#382): per-pod proxy ACLs — the pod owner's.aclshould govern what the proxy fetches, whatever the caller presents.capability/(#506): a capability URL exercises the issuer's authority; the bearer has no Solid credentials at all. Today the plugin can only enforce its own token scopes, not re-check the issuer's current WAC rights (revocation by ACL edit doesn't propagate).pay/: the 402 gate wants to compose with WAC instead of replacing it.caldav/(RFC 6638 scheduling): 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 narrower server-mediated deliver-to-inbox primitive would also cover this one.)
Sketch: api.authorize({ agent, path, mode }) → Promise<boolean> —
agent id (not request) in, decision out, same engine the LDP path uses.
Cost: medium — the WAC engine exists; this is plumbing an entry point
into the loader's api object.
Consumers (seven, rising sharpness): notifications/ (a miss = late
notification), sparql/ (a miss = wrong query result), search/ (stale
results — the most user-visible), matrix/ (its /sync long-poll needs
live push; a stateless bridge can only do full-state sync without it),
backup/ (no change hook + no plugin-owned read authority means
incremental and scheduled backup are both unbuildable — every backup is a
caller-driven full crawl), jmap/ (push and delta sync refused
in-protocol: no eventSourceUrl, cannotCalculateChanges), and
remotestorage/ (descendant-version propagation needs a write-time
index). Every unbuilt "react to writes" idea —
webhooks, WebSub, write-time indexing — joins this list on day one.
A sharpening from jmap/ worth keeping: what makes a protocol bridgeable
is not its domain but the absence of server push — JMAP (email redesigned
stateless by the IETF) bridges with zero approximation where IMAP never
could. This seam is exactly what turns the "stateless slice" of matrix,
jmap, and remotestorage into full implementations.
Sketch: api.events.onResourceChange(({ path, method }) => {}),
fired post-commit on PUT/PATCH/DELETE. Core already has the emitter
internally (src/notifications/events.js) — the seam is exposure, not
construction.
Cost: small — the event stream exists; scope it and pass it through.
3. api.reservePath(pattern) — what every API shim structurally needs (#602) — ✅ SHIPPED (0.0.218/219) & CONSUMED
(mastodon/bluesky/activitypub/matrix self-reserve their literal roots;
didweb consumes the parameterized /:user/did.json. Two follow-ups
surfaced in consumption: shared discovery documents still need a
link/JRD registry — a reservation can't split one document between two
plugins — and the parameterized matcher is length-unbounded while
:param routes cap at maxParamLength 100, so an over-long name falls
through to LDP GET /* with the read-only exemption applied. The
original case for the seam, kept for the record:)
The most-hit finding: seven+ consumers. A plugin can register routes
outside its prefix, but only its one prefix is WAC-exempt:
/.well-known/*(nip05, webfinger, the DAV family) works by luck — core happens to blanket-exempt it. The two most-wanted.well-knowndocs a deployment serves ride an undocumented coincidence.- Fixed protocol roots (
/api,/oauth,/xrpc,/ap,/_matrix) do not work: mastodon/bluesky/activitypub/matrix all 401 until the operator hand-passesappPaths— the plugin cannot self-exempt the paths the protocol fixes. - The sharpest case can't be expressed at all:
didweb/must serve/<user>/did.jsoninside the pod's WAC-governed namespace —appPathsmatches literal prefixes, so a parameterized reservation is the only fix (the DID method pins the URL; there's no fake root to escape to). - Collision behavior is now witnessed, both ways:
webfinger/andremotestorage/both legitimately own parts of/.well-known/webfinger. Load one order → boot fails with a wrapped duplicate-route error (the loader dropserr.code, bug 4 below); the other order → the try/catch-guarded claimant silently loses its links. Loud failure or silent loss — both wrong. For shared discovery documents the reservation primitive isn't enough; a link/JRD registry (api.webfinger.addLink) is the real fix. - The counter-witnesses that sharpen the scope:
micropub/andjmap/are protocol shims that needed no reservation, because their endpoints are client-/session-discovered rather than protocol-fixed (jmap's/.well-known/jmaprides the blanket exemption). The seam is for protocols that pin absolute paths — not API shims per se.
Sketch: api.reservePath('/xrpc'), api.reservePath('/:user/did.json')
(or paths: [...] on the entry): loader WAC-exempts and claims each,
reporting collisions at boot.
Cost: small-medium — generalizes the appPaths mechanism that already
exists, moving it from operator config to plugin declaration.
(consumed at request time by webfinger/didweb/dashboard/admin/gallery;
config.baseUrl survives as an optional reverse-proxy override; ~18
mechanical retrofits remain. The original case, kept for the record:)
23 consumers — every plugin that mints absolute URLs or loopbacks
(the DAV family, the four shims, rss, sparql, webfinger, notifications,
micropub, backup, metrics, dashboard, oembed, jmap, remotestorage, s3,
search, shortlink, didweb, admin)
repeats baseUrl/loopbackUrl 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). Test suites all need a
probe-port-then-boot dance for the same reason.
Sketch: { baseUrl, port } available by activate-time or via an
onListen hook.
Cost: trivial. Best effort-to-value ratio on this list.
- Raw-stream guarantee for #583 (see "worth keeping" above) — a property to preserve, not new work.
api.mcp.registerTool— blocks the four MCP-tool issues (#495/#496/#500/#501); no consumer here because it's impossible today.api.plugins— the read-only loaded-plugin registry (filed #610). Two consumers:dashboard/andadmin/, the plugins whose whole job is describing the deployment, must each be handed a hand-copied duplicate of thecreateServerplugins list (drift is silent;serve.jsnow maintains a sharedINVENTORYarray beside the real list — the workaround that proves the seam). The loader already holds the needed data:api.plugins → [{ id, prefix, module }], read-only. Distinct from the apps-as-pod-resources vision (#463/#464) and the marketplace (#184/#194/#200) — this is the minimal loader primitive those could build on. The phase-1 blocker for a blessed read-only status console.api.isOperator(an operator concept) — terminal/, metrics/ and admin/ hold three incompatible answers to "who is the operator?" (shared token, optional bearer, WebID allowlist). A tiny seam — anoperators: []server option surfaced to plugins — would unify them.- Export pure utility modules the way
auth.jsis blessed —relay/re-vendors NIP-01 verify,pay/re-vendors mrc20. Ajavascript-solid-server/nostr.jsexport (or explicit vendoring blessing) ends the drift risk. - Per-route Fastify options —
capability/foundmaxParamLength(100) silently 404s long tokens in named params; wildcard routes are the workaround. Sharp when it bites. - Response-header injection on core routes — wanted three times
(notifications'
Updates-Viadiscovery; micropub's<link rel="micropub">endpoint discovery on the user's homepage; oembed's per-resource discovery<link>, whose in-HTML form is content rewriting even a header hook couldn't do). Deliberately not asked for as a default-on hook: it's a bigger grant than route ownership. If ever, gate it:capabilities: ['hooks'].
All five filed 2026-07-11: #596
(id derivation), #597
(dotted-prefix WS), #598
(logger: false vs onResponse), #599
(error wrap drops err.code), #600
(non-atomic conditional write).
- Generic-basename id derivation. Every plugin follows
<name>/plugin.js, so every derived id ispluginand the (correct) duplicate-id guard refuses to boot until each entry carries an explicitid. Fix: when the basename is generic (plugin,index), derive from the parent directory (relay/plugin.js→relay). Backward-compatible; removes the most common footgun. - Dotted prefixes break the WS upgrade. A plugin at
/.terminalcan't accept WebSocket connections (host-reserved dotted paths), while/terminalworks. Either validate/refuse dotted prefixes at load or document the reservation. logger: falsesilently disables every pluginonResponsehook. Core's access-log hook callsrequest.log.isLevelEnabled('info'), which doesn't exist on Fastify's null logger; the per-request throw aborts the downstream hook chain, so pluginonResponsehooks never fire (found bymetrics/). One-line guard fixes it.- The loader's activate-failure wrap drops
err.code. A route collision insideactivatesurfaces asplugin <id>: activate() failed: <message>withFST_ERR_DUPLICATED_ROUTEstripped — callers can only string-match. Preservecode(or usecause) when re-wrapping (found byremotestorage/). - The conditional write is check-then-write, not atomic (found live
by
sparql/): two concurrent PUTs carrying the same currently-validIf-Matchboth return 2xx and one is silently overwritten; the same PUTs run sequentially 412 correctly. Both callers are told success but one write is gone. Unfixable from a plugin; the LDP write path needs an atomic stat+write.
metrics/ needed request counters, so it added an onResponse hook on
api.fastify and measured what the hook can see (both entry orders,
core routes probed too). Result: the hook fires for every plugin's
routes and never for core's. All loader entries are activated against
one shared fastify.register scope, while core routes live on the
parent instance behind Fastify encapsulation.
Two readings, both true:
- The #564 line holds against core — a plugin cannot observe or modify the pipeline of core routes, exactly as intended.
- Plugins can already observe and intercept each other, ungated. A hostile or buggy plugin can shadow-log or rewrite a sibling's traffic today.
This is worth a deliberate decision rather than an inherited one:
per-plugin encapsulation (each entry in its own register scope) is the
safer default, but note it would break the one legitimate cross-plugin
consumer found (a metrics exporter counting all plugin traffic) — if
hooks are ever capability-gated (capabilities: ['hooks'] /
['observe']), the shared scope could become the granted behavior and
isolation the default.
admin/ asks the question every plugin platform eventually answers: can
the ecosystem build its own WordPress-style admin? The read side — server
health, live plugin inventory with probes, pod/user and storage stats,
an agent-allowlist gate — works today, but only because the operator
re-tells the plugin what the host already knows, three different ways
(origin, plugins list, data root). The write side is where the api's
edges are exact:
| wp-admin pillar | Today | The seam |
|---|---|---|
| Plugin inventory | hand-copied config, silent drift | api.plugins (#463/#464) |
| Install from a store | impossible (boot-time config) | #200 + post-boot loading |
| Enable / disable | impossible — deactivate() exists, nothing calls it at runtime |
runtime lifecycle on the loader |
| Per-plugin settings panels | adminPage link convention |
a contribute-a-panel affordance |
| Health | ✅ live probes | registry health hints would refine it |
| Users / storage | ✅ via operator-repeated podsRoot |
storage introspection (cousin of serverInfo) |
| Logs | impossible — api.log is write-only |
a log-tailing seam |
| Who is admin? | every plugin invents a gate | api.isOperator / operators: [] |
None of these are asks to build an admin panel into core — the plugin proves the shell can live out-of-tree; the seams are the data feeds.
A feature is plugin-able iff it OWNS its routes; it stays core iff it
MODIFIES the pipeline of routes it doesn't own. Seven bundled features
ported (relay, webrtc, terminal, tunnel, notifications,
remoteStorage — plus
pay/ as the deliberate counter-example: 402-gating LDP routes is
pipeline modification, which is the definition of core). The migration
list in #564 is right for every route-owning feature; the
middleware-shaped ones (conneg, quotas, WAC, LDP, auth) are the pod.
The only thing that would move that line is an explicit, separately-gated
hooks capability.
- Module-global
DATA_ROOT: a secondcreateServerin one process — even a deliberately failing one — poisons the first. Order validation-failure tests before the long-lived boot. - Git-shelling plugins must spawn with
GIT_CONFIG_NOSYSTEM=1and noHOME, or operator gitconfig leaks into server-created repos. - Explicit
idper entry (until bug 1 above is fixed). - Plain (non-dotted) prefixes for anything with a socket.
If effort is scarce, this order maximizes unblocked value per unit cost
(items 1–2 are ✅ DONE — shipped in 0.0.218/0.0.219 and consumed here,
along with api.mountApp #583 and api.plugins #610):
✅ shipped & consumed (retrofit tail in progress),api.serverInfo✅ shipped & consumed by all five provers — the webfinger link registry for the shared-document case is still worth it,api.reservePathapi.events.onResourceChange(small; unlocks webhooks/WebSub/indexing and makes search/sparql/matrix/jmap faithful) — now the top ask,api.authorize(medium; the blocking seam for proxy ACLs, capability semantics, and scheduling delivery),- the five bug fixes (anytime; small — the non-atomic conditional write is the one with real data-loss consequences; #596 generic-basename id is ✅ fixed in 0.0.219).
Everything else can wait until a real consumer shows up — this repo is the mechanism for finding those.