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.
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.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).api.authorize,
api.events.onResourceChange, api.reservePath, and api.serverInfo.
Adding the first three moves essentially every remaining plugin-shaped
issue from “honest approximation” to “faithful implementation”.logger: false breaking every plugin onResponse hook; the loader’s
error wrap dropping err.code; the host’s conditional write being
check-then-write rather than atomic).Validated by use, not opinion:
api.ws.route on the host’s upgrade stack (PR #589’s design): five
WebSocket plugins coexist in one process with zero upgrade code and no
@fastify/websocket dependency anywhere. Keep it.gitscratch/ pipes a raw
request stream into git-http-backend gzip-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.pluginDir as the only persistence — every stateful plugin (relay
events, capability secrets, OTP table, AP keypairs, git repos) fit in it.throw in activate → boot failure is the
right contract; test suites lean on it.api.wac.check(agent, path, mode) is
not on the ask list — only the cases loopback structurally can’t
cover are.remotestorage/; sparql/’s UPDATE is the second consumer):
forwarded If-Match/If-None-Match are 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.
api.authorize(request, path, mode) — the most blocking (#604)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 .acl
should 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.
api.events.onResourceChange(cb) — every “react to writes” app (#603)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.
api.reservePath(pattern) — what every API shim structurally needs (#602)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-known
docs a deployment serves ride an undocumented coincidence./api, /oauth, /xrpc, /ap, /_matrix) do
not work: mastodon/bluesky/activitypub/matrix all 401 until the
operator hand-passes appPaths — the plugin cannot self-exempt the
paths the protocol fixes.didweb/ must serve
/<user>/did.json inside the pod’s WAC-governed namespace —
appPaths matches literal prefixes, so a parameterized reservation
is the only fix (the DID method pins the URL; there’s no fake root to
escape to).webfinger/ and
remotestorage/ both legitimately own parts of
/.well-known/webfinger. Load one order → boot fails with a wrapped
duplicate-route error (the loader drops err.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.micropub/ and jmap/
are protocol shims that needed no reservation, because their
endpoints are client-/session-discovered rather than protocol-fixed
(jmap’s /.well-known/jmap rides 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.
api.serverInfo — the broadest, and the cheapest (#601)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.
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/ and admin/, the plugins whose whole job is
describing the deployment, must each be handed a hand-copied duplicate of
the createServer plugins list (drift is silent; serve.js now maintains
a shared INVENTORY array 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 — an
operators: [] server option surfaced to plugins — would unify them.auth.js is blessed —
relay/ re-vendors NIP-01 verify, pay/ re-vendors mrc20. A
javascript-solid-server/nostr.js export (or explicit vendoring
blessing) ends the drift risk.capability/ found maxParamLength
(100) silently 404s long tokens in named params; wildcard routes are
the workaround. Sharp when it bites.Updates-Via discovery; 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).
<name>/plugin.js, so every derived id is plugin and the (correct)
duplicate-id guard refuses to boot until each entry carries an explicit
id. Fix: when the basename is generic (plugin, index), derive
from the parent directory (relay/plugin.js → relay).
Backward-compatible; removes the most common footgun./.terminal
can’t accept WebSocket connections (host-reserved dotted paths), while
/terminal works. Either validate/refuse dotted prefixes at load or
document the reservation.logger: false silently disables every plugin onResponse hook.
Core’s access-log hook calls request.log.isLevelEnabled('info'),
which doesn’t exist on Fastify’s null logger; the per-request throw
aborts the downstream hook chain, so plugin onResponse hooks never
fire (found by metrics/). One-line guard fixes it.err.code. A route
collision inside activate surfaces as plugin <id>: activate()
failed: <message> with FST_ERR_DUPLICATED_ROUTE stripped — callers
can only string-match. Preserve code (or use cause) when
re-wrapping (found by remotestorage/).sparql/): two concurrent PUTs carrying the same currently-valid
If-Match both 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:
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.
DATA_ROOT: a second createServer in one process —
even a deliberately failing one — poisons the first. Order
validation-failure tests before the long-lived boot.GIT_CONFIG_NOSYSTEM=1 and no
HOME, or operator gitconfig leaks into server-created repos.id per entry (until bug 1 above is fixed).If effort is scarce, this order maximizes unblocked value per unit cost:
api.serverInfo (trivial; tidies ~23 plugins’ config and every test
harness),api.reservePath (small-medium; makes four existing shims
self-contained and didweb possible — pair it with a webfinger link
registry for the shared-document case),api.events.onResourceChange (small; unlocks webhooks/WebSub/indexing
and makes search/sparql/matrix/jmap faithful),api.authorize (medium; the blocking seam for proxy ACLs, capability
semantics, and scheduling delivery),Everything else can wait until a real consumer shows up — this repo is the mechanism for finding those.