Orientation for an LLM (or human) adding to this repo. Read this, then
NOTES.md (findings/seams) and ISSUES.md (backlog disposition). The
existing plugins are your reference implementations — this file tells you
which one to copy for what.
Out-of-tree plugins for JavaScript Solid
Server,
built on its #206 loader (createServer({ plugins }), JSS ≥ 0.0.215).
It is an experiment: prove the plugin api by using it, and treat every
wall you hit as a finding, not a blocker. 35 plugins, 527 tests today.
A plugin's plugin.js may import only:
- Node builtins (
node:crypto,node:fs, …), - this repo's deps (
@noble/curves,ws) — add none without cause, javascript-solid-server/auth.js(the one blessed import, forgetAgent).
Never javascript-solid-server/src/.... If you can't build it without an
internal, that gap is the result — document it in your README ## Findings
and in NOTES.md, implement the closest honest approximation (vendor a
trimmed pure module with attribution, or reach the host over loopback), and
move on. Copying an internal defeats the point.
Your module exports async function activate(api). What api gives you:
| member | what |
|---|---|
api.fastify |
the scoped Fastify instance — register routes/hooks here |
api.prefix |
your mount prefix (WAC-exempt automatically); '' if none |
api.config |
the entry's config object, verbatim |
api.log |
logger; speaks both .info/.warn/.error and .log |
api.auth.getAgent(request) |
`async → agentId string |
api.storage.pluginDir() |
a private server-side dir under the data root's dot-guard — never served over HTTP; your persistence lives here |
api.ws.route(path, (socket, request) => {}) |
a WebSocket endpoint via the host's upgrade stack — no @fastify/websocket, no 'upgrade' listener |
Return { deactivate() } for teardown (close sockets, clear timers) on
server close. Fail loudly: if required config is missing, throw in
activate — the loader turns it into a boot failure, which is what you want.
- Loopback to the host — the workhorse. To touch pod data or reach
another host endpoint, make an HTTP call to the server itself
(
config.loopbackUrl/baseUrl) forwarding the client'sAuthorization, so the host's real WAC decides. You get correctness for free and need no internal access. Seenotifications/(a single HEAD authorization check),webdav/carddav/caldav/rss/sparql/(a whole data plane),mastodon/bluesky/activitypub/(token bridge to/idp/credentials). This is how a plugin does anything WAC-governed. - Token bridge — for API shims:
POST /oauth/token(or equivalent) calls the host'sPOST /idp/credentialsover loopback and returns the pod bearer verbatim as the client's token; later calls resolve viagetAgent. A Mastodon/Bluesky/OAuth token and a Solid bearer are the same kind of thing. Seemastodon/,bluesky/. pluginDirpersistence — anything stateful (relay events, capability secrets+revocations, OTP table, AP keypairs/followers, git repos) is JSON or files underapi.storage.pluginDir(). Seerelay/,capability/,otp/,activitypub/,gitscratch/.- HMAC macaroon-lite tokens — for self-verifying scoped tokens with no
DB:
v1.<base64url(payload)>.<base64url(HMAC-SHA256(secret, ...))>, secret inpluginDir. Seecapability/(canonical),otp/(session). ws.routefor realtime — bring your ownWebSocketServer({ noServer: true })if you like and feed it, or just use the handler. Seerelay/,webrtc/,terminal/,tunnel/,notifications/.
Full ranking in NOTES.md. The ones you'll hit:
- No
api.authorize(request, path, mode)— you can't ask "would WAC allow this?" for authority the requester doesn't drive (a proxy governed by a pod owner's.acl, a capability exercising the issuer's right). Workaround: loopback with the requester's own creds covers the common case; the issuer-authority case has no workaround — document it. (4 consumers; the top blocking seam.) api.reservePath()— LANDED (JSS 0.0.219, #602) and consumed. A plugin now claims + WAC-exempts its protocol-pinned paths itself: literal roots (api.reservePath('/xrpc', { methods: [...] })— see mastodon/bluesky/activitypub/matrix) and parameterized shapes (api.reservePath('/:user/did.json')— see didweb/). Reservations are READ-ONLY by default; widenmethodsto exactly the verbs your routes implement, never wider (an exempted verb with no route falls through to LDP's write wildcards unauthenticated). No more operatorappPaths./.well-known/*still also rides core's blanket exemption; reserving a well-known doc makes the claim deliberate and collision-guarded.- No
api.events.onResourceChange— you can't react to pod writes, so a write-time index / cached feed is impossible; do read-time work (walk the container per request) and note the O(N) cost. Seesparql/,rss/. api.serverInfo— LANDED (JSS 0.0.218, #601), retrofit in progress. Resolve the origin at REQUEST time (api.serverInfo().baseUrl; with an ephemeral port it only exists once listening), keepconfig.baseUrlas an optional reverse-proxy override. Consumed by webfinger/, didweb/, dashboard/, admin/, gallery/; ~18 plugins still carry the old config-pair pattern and retrofit the same way.api.mountApp— LANDED (JSS 0.0.219, #583). A raw(req,res)handler with the UN-drained request stream — the streaming-upload lane Fastify's parsers can't give you. See gallery/ (the first consumer) for the contract's edges: the mounted lane sidesteps the hostbodyLimit(cap your own if you don't forward to core), the two lanes can't share a prefix, andgetAgentneeds a shim for raw reqs.- No
api.mcp.registerTool— MCP tools can't be added by a plugin (that's why #495/#496/#500/#501 aren't here). - Can't set Fastify server options — e.g.
maxParamLength(100) 404s long tokens in named params; use a wildcard route (/x/*). Seecapability/.
mkdir <name>/— the directory name is the plugin id by convention.<name>/plugin.jsexportingactivate(api). Obey the import rule. Pick the nearest existing plugin and copy its shape (see the map below).<name>/test.js—node:testusing../helpers.js:Mint a pod bearer when you need auth:import path from 'path'; import { fileURLToPath } from 'url'; import { startJss, probePort } from '../helpers.js'; const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url))); const entry = { id: '<name>', module: path.join(__dirname, 'plugin.js'), prefix: '/<name>' }; // Simple case — no own origin needed: const jss = await startJss({ plugins: [entry] }); // jss.base, jss.wsBase, jss.root, jss.close() // Needs its own origin (baseUrl/loopback) or fixed roots? Probe first: const port = await probePort(); const base = `http://127.0.0.1:${port}`; const jss2 = await startJss({ port, idp: true, // fixed roots outside your prefix: api.reservePath in activate (#602), // NOT appPaths — see the mastodon/bluesky/activitypub/matrix retrofits plugins: [{ ...entry, config: { baseUrl: base, loopbackUrl: base } }] }); // ... drive real HTTP/WS against jss.base / jss.wsBase ... then: await jss.close();
POST /.pods(or/idp/register) thenPOST /idp/credentials→access_token; sendAuthorization: Bearer. Run:node --test --test-concurrency=1 <name>/test.js.<name>/README.md— usage snippet, what maps / what doesn't, and a## Findingssection. The findings are the deliverable — what the api gave you, what it didn't, which seam a wall points to.- Wire it into
compose.test.jsandserve.js(both list every plugin; add fixed roots to theirappPathsif you have any). Add its findings toNOTES.mdand its row toREADME.md/ISSUES.md. Runnpm test.
| If X is… | copy | because |
|---|---|---|
| a realtime/WebSocket service | relay/ or webrtc/ |
ws.route + pluginDir |
| a DAV-family protocol (CalDAV done, e.g. a filesystem/WebDAV variant) | webdav/→carddav/→caldav/ |
loopback + multistatus XML + ETag + Basic→Bearer, proven 3× |
| an API shim (a new social/chat/proto) | mastodon/ or bluesky/ |
token bridge + self-reserved fixed roots (#602) |
a .well-known discovery doc |
nip05/ or webfinger/ |
podsRoot scan + guarded absolute route |
| a scoped-token / auth service | capability/ or otp/ |
HMAC macaroon-lite + pluginDir |
| a query/read/search over pod data | sparql/, rss/, search/ |
loopback container walk + forwarded auth |
| a federation actor | activitypub/ |
keypair in pluginDir + loopback objects |
| a proxy/gateway | corsproxy/ |
fetch upstream, SSRF gates, CORS |
| an object-storage / S3-style gateway | s3/ |
loopback LDP + hand-rolled XML + SigV4 |
| a DID / identity document | didweb/ |
podsRoot scan + key derivation |
| a dev/tooling subsystem | gitscratch/ |
shell a system binary via CGI |
| a hosted-content web app (forge, wiki, …) | forge/ |
CGI plumbing + server-rendered GitHub-light UI + JSON API, zero deps/build |
| a posting protocol (IndieWeb-style, client-discovered endpoint) | micropub/ |
pod bearer as the protocol token + loopback writes |
| a data-export / archive download | backup/ |
loopback container walk streamed into a hand-rolled format |
| an ops/observability endpoint | metrics/ |
node builtins + an api.fastify hook (scope: all plugins, never core) |
| a meta/status page over siblings | dashboard/ |
anonymous loopback probes + an operator-declared list (no registry) |
| a per-resource metadata/unfurl endpoint | oembed/ |
loopback resolve + local-URL-only guard |
| a stateless request/response protocol (mail, sync, …) | jmap/ |
loopback CRUD + in-protocol refusal of push/delta |
| a storage protocol with conditional writes | remotestorage/ |
If-Match/If-None-Match pass through loopback intact |
| a redirect / metadata micro-service | shortlink/ |
pluginDir JSON table + wildcard slug routes |
| an operator/admin surface | admin/ |
compose probes + podsRoot scan behind an agent allowlist |
| a streaming-body app (upload, media) | gallery/ |
api.mountApp raw handler → loopback PUT, auth forwarded |
| a multi-server scenario (not a plugin) | federation-demo/ |
child-process instances (DATA_ROOT is process-global) |
- Module-global
DATA_ROOT: eachcreateServerrepoints a process global — a second boot in one process, even a failing one, poisons the first. Order any validation-failure test before the long-lived boot. - Ambient
~/.gitconfig(git-shelling plugins): spawn git withGIT_CONFIG_NOSYSTEM=1and noHOME, or the operator'sinit.defaultBranchleaks into created repos. - Generic basename id — FIXED in JSS 0.0.219 (#596): the loader now
derives
<name>/plugin.js→ id<name>from the parent dir; explicitidfields are gone fromcompose.test.js/serve.js. Don't reintroduce them — but if a test hardcodes a pluginDir path, it's.plugins/<name>/now, not.plugins/plugin/. - Dotted prefixes (
/.foo) fail the WS upgrade — core reserves dotted paths. Use a plain prefix for anything with a socket. logger: falsekills pluginonResponsehooks: core's access-log hook throws on the null logger and aborts the downstream chain. If your plugin usesonResponse, boot tests withlogger: true, logLevel: 'silent'(helpers.js defaults tologger: false).
35 plugins (7 ports + 28 features) + the two-server federation-demo/,
527 tests, all green (npm test).
A four-axis security review (SECURITY.md) hardened the
inbound-federation and query surfaces; the WAC-deferral pattern held.
compose.test.js runs every plugin on a single server from pure config.
Four seams have now LANDED upstream and are consumed here: api.serverInfo
(#601), api.reservePath (#602), api.mountApp (#583), api.plugins
(#610) — all in JSS 0.0.218/0.0.219, which this repo pins. Still open:
api.events (#603) and api.authorize (#604). Everything else lives
here, by design. REPORT.md is the maintainer-facing
summary of it all.