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. 33 plugins, 386 tests today.
A plugin’s plugin.js may import only:
node:crypto, node:fs, …),@noble/curves, ws) — add none without cause,javascript-solid-server/auth.js (the one blessed import, for getAgent).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 | null (WebID or did:nostr), verified across every credential scheme |
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.
config.loopbackUrl/baseUrl) forwarding the client’s
Authorization, so the host’s real WAC decides. You get correctness
for free and need no internal access. See notifications/ (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.POST /oauth/token (or equivalent)
calls the host’s POST /idp/credentials over loopback and returns the
pod bearer verbatim as the client’s token; later calls resolve via
getAgent. A Mastodon/Bluesky/OAuth token and a Solid bearer are the
same kind of thing. See mastodon/, bluesky/.pluginDir persistence — anything stateful (relay events, capability
secrets+revocations, OTP table, AP keypairs/followers, git repos) is JSON
or files under api.storage.pluginDir(). See relay/, capability/,
otp/, activitypub/, gitscratch/.v1.<base64url(payload)>.<base64url(HMAC-SHA256(secret, ...))>,
secret in pluginDir. See capability/ (canonical), otp/ (session).ws.route for realtime — bring your own WebSocketServer({ noServer:
true }) if you like and feed it, or just use the handler. See relay/,
webrtc/, terminal/, tunnel/, notifications/.Full ranking in NOTES.md. The ones you’ll hit:
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() — you can register routes outside your one
prefix, but only prefix is WAC-exempt. /.well-known/* works by luck
(core blanket-exempts it). Fixed roots like /api, /xrpc, /ap do
not work until the operator passes appPaths: ['/api',...] to
createServer. If you build an API shim, your test.js must pass those
appPaths, and your README must say the operator does too. (4 shim
consumers.)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. See sparql/, rss/.api.serverInfo — a plugin can’t learn its own origin at
activate time. Take config.baseUrl (and loopbackUrl), and throw if
missing. ~23 plugins do this.api.mcp.registerTool — MCP tools can’t be added by a plugin
(that’s why #495/#496/#500/#501 aren’t here).maxParamLength (100) 404s
long tokens in named params; use a wildcard route (/x/*). See
capability/.mkdir <name>/ — the directory name is the plugin id by convention.<name>/plugin.js exporting activate(api). Obey the import rule. Pick
the nearest existing plugin and copy its shape (see the map below).<name>/test.js — node:test using ../helpers.js:
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,
appPaths: ['/api'], // only if you own fixed roots
plugins: [{ ...entry, config: { baseUrl: base, loopbackUrl: base } }] });
// ... drive real HTTP/WS against jss.base / jss.wsBase ... then: await jss.close();
Mint a pod bearer when you need auth: POST /.pods (or /idp/register)
then POST /idp/credentials → access_token; send Authorization:
Bearer. Run: node --test --test-concurrency=1 <name>/test.js.
<name>/README.md — usage snippet, what maps / what doesn’t, and a
## Findings section. The findings are the deliverable — what the api
gave you, what it didn’t, which seam a wall points to.compose.test.js and serve.js (both list every plugin;
add fixed roots to their appPaths if you have any). Add its findings to
NOTES.md and its row to README.md / ISSUES.md. Run npm 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 + fixed-root appPaths |
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 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 |
DATA_ROOT: each createServer repoints 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.~/.gitconfig (git-shelling plugins): spawn git with
GIT_CONFIG_NOSYSTEM=1 and no HOME, or the operator’s init.defaultBranch
leaks into created repos.<name>/plugin.js all reduce to plugin and collide — always
pass an explicit id in compose.test.js/serve.js./.foo) fail the WS upgrade — core reserves dotted
paths. Use a plain prefix for anything with a socket.logger: false kills plugin onResponse hooks: core’s access-log
hook throws on the null logger and aborts the downstream chain. If your
plugin uses onResponse, boot tests with logger: true, logLevel:
'silent' (helpers.js defaults to logger: false).33 plugins (7 ports + 26 features), 386 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 one on a single server from pure config. Two
core PRs (#590 api.mountApp, #591 /idp/refresh) sit upstream, unmerged,
for the maintainer’s call. Everything else lives here, by design.
REPORT.md is the maintainer-facing summary of it all.