plugins

The plugin api, 33 plugins later — a report for the maintainer

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.

Executive summary

What already works — decisions worth keeping

Validated by use, not opinion:

  1. 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.
  2. The scoped pass-through body parser: 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.
  3. pluginDir as the only persistence — every stateful plugin (relay events, capability secrets, OTP table, AP keypairs, git repos) fit in it.
  4. Fail-loudly activationthrow in activate → boot failure is the right contract; test suites lean on it.
  5. 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.
  6. Conditional writes survive loopback intact (measured by 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.

The seams to add, ranked by independent demand

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.

1. 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?”:

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.

2. 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.

3. 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:

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.

4. 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.

Smaller, real, lower-priority

Five loader/core bugs worth fixing regardless

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).

  1. Generic-basename id derivation. Every plugin follows <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.jsrelay). Backward-compatible; removes the most common footgun.
  2. Dotted prefixes break the WS upgrade. A plugin at /.terminal can’t accept WebSocket connections (host-reserved dotted paths), while /terminal works. Either validate/refuse dotted prefixes at load or document the reservation.
  3. 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.
  4. The loader’s activate-failure wrap drops 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/).
  5. The conditional write is check-then-write, not atomic (found live by 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.

A measured surprise: plugins are not isolated from each other

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.

The wp-admin test (the capstone)

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.

The core/plugin line (#564, answered empirically)

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.

For plugin-author docs (footguns every suite rediscovered)

Suggested order of work

If effort is scarce, this order maximizes unblocked value per unit cost:

  1. api.serverInfo (trivial; tidies ~23 plugins’ config and every test harness),
  2. 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),
  3. api.events.onResourceChange (small; unlocks webhooks/WebSub/indexing and makes search/sparql/matrix/jmap faithful),
  4. api.authorize (medium; the blocking seam for proxy ACLs, capability semantics, and scheduling delivery),
  5. the five bug fixes (anytime; small — the non-atomic conditional write is the one with real data-loss consequences).

Everything else can wait until a real consumer shows up — this repo is the mechanism for finding those.