From aef63c1683d347305330f7372d37b732658a1f62 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 16 May 2026 11:31:46 +0200 Subject: [PATCH 01/13] docs(content-negotiation): replace stub with full position document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the brief stub in core-concepts/content-negotiation with a substantive position document covering JSS's conneg architecture: - Position commitment: any app speaking JSON-LD works today; conneg surface is future-proofed against the W3C standards track - Four design constraints (W3C-aligned + future-proofed, deployed-web compatible, deployed-semantic-web compatible, JSON-LD first) - "Content negotiation is a negotiation" framing — the conneg handshake is by definition two-way; JSS implements its side correctly for clients that implement theirs - Current behaviour table covering extensioned RDF, .acl/.meta, containers, extension-less resources, non-RDF - A living-position flexibility statement - Why these architectural choices (URI extension as web-reliable signal; octet-stream as spec-correct safe default; no JSS-local metadata layer pending LWS) - Future-proofing posture (LWS-native adoption when REC lands) - How it works (concrete code references) - Direction (vague considerations-only; no specific implementation commitments) Establishes the citable substrate-quality reference for JSS's conneg behaviour, with deliberately hedged language for any unshipped implementation choices. --- docs/core-concepts/content-negotiation.md | 148 +++++++++++++++++++--- 1 file changed, 129 insertions(+), 19 deletions(-) diff --git a/docs/core-concepts/content-negotiation.md b/docs/core-concepts/content-negotiation.md index 52608fe..893fc48 100644 --- a/docs/core-concepts/content-negotiation.md +++ b/docs/core-concepts/content-negotiation.md @@ -1,37 +1,64 @@ --- sidebar_position: 3 title: Content Negotiation -description: How JSS handles different RDF formats +description: JSS's position on content negotiation, and how RDF apps with standard HTTP conneg work first-class today --- # Content Negotiation -Content negotiation allows clients to request data in different formats. +JSS is committed to correctly implementing content negotiation so that **any app speaking JSON-LD works with JSS today**, and to remaining **future-proofed against the W3C standards track** as it evolves. JSS's conneg architecture is deliberately positioned to adopt forthcoming W3C specifications (LWS and beyond) natively when they reach Recommendation, without requiring deprecation of JSS-local conventions. This document describes JSS's current conneg behaviour, the principles behind it, and the direction the position may evolve. -## Supported Formats +## Design constraints -| Format | Content-Type | Default | -|--------|--------------|---------| -| JSON-LD | `application/ld+json` | Yes | -| Turtle | `text/turtle` | With `--conneg` | -| HTML | `text/html` | For profiles | +JSS's content-negotiation behaviour is derived from four constraints, each of which rules out a class of bad architectural choices: -## Requesting formats +1. **W3C-spec aligned and future-proofed** — implement current W3C recommendations; position to adopt forthcoming specifications (LWS, etc.) natively when they reach Recommendation; deliberately avoid JSS-local conventions that would require deprecation when standards land +2. **Backwards-compatible with the deployed web** — do not depend on HTTP semantics the actual web does not reliably deliver (e.g., `Content-Type` headers stripped or defaulted wrong across proxies, CDNs, browsers, and client libraries) +3. **Compatible with the deployed semantic web** — recognise the formats and media types used by [schema.org](https://schema.org), [ActivityPub](../features/activitypub.md), and the broader linked-data ecosystem +4. **JSON-LD first** — JSON-LD is the canonical internal serialization; other RDF formats are conneg-served representations of the same resource -Use the `Accept` header: +These constraints triangulate to the behaviour below. -```bash -# Get JSON-LD (default) -curl http://localhost:3000/alice/public/data +## Content negotiation is a negotiation -# Get Turtle (requires --conneg) -curl -H "Accept: text/turtle" http://localhost:3000/alice/public/data +Content negotiation is by definition a two-way handshake between client and server: the client expresses its preferences via the `Accept` header, the server responds with what it can serve via the `Content-Type` header, and both sides honour the result. JSS implements its side of the negotiation correctly for the cases where the client implements theirs. -# Get HTML (for browsing) -curl -H "Accept: text/html" http://localhost:3000/alice/public/data -``` +Any RDF application that participates in the conneg handshake — sending `Accept` for its desired RDF format and respecting `Content-Type` in the response — is a first-class client of JSS when `--conneg` is enabled. JSS provides bidirectional Turtle ↔ JSON-LD content negotiation on any resource with a recognised RDF extension: PUT as Turtle and read as JSON-LD, or PUT as JSON-LD and read as Turtle. The transcoding is transparent. + +This covers the overwhelming majority of real-world RDF traffic, including apps using: + +- Standard RDF extensions: `.jsonld`, `.ttl`, `.rdf`, `.n3`, `.nt`, `.nq`, `.trig` +- Solid convention dotfiles: `.acl`, `.meta` (treated as JSON-LD on disk per Solid convention) +- HTML resources with embedded JSON-LD data islands +- Container representations (JSON-LD by default, Turtle via `Accept` header) + +Clients that do not participate in the negotiation — either by not sending `Accept` or by ignoring `Content-Type` responses — receive JSS's spec-correct default behaviour: stored bytes with the best content-type signal JSS has available from the URI extension, or `application/octet-stream` if no signal exists. JSS cannot negotiate unilaterally; negotiation requires both sides. + +## A living position + +JSS's content-negotiation position evolves with the W3C standards track. As specifications stabilise, JSS adopts them; where they leave room, JSS holds the conservative position rather than inventing local convention. The behaviour described in this document is the current state — alive, not frozen — and this document is updated as the position evolves. -## Enable Turtle support +Concretely: + +- Where standards stabilise (e.g., LWS REC), JSS implements promptly and natively +- Where standards leave room (e.g., for non-extension typed resources today), JSS holds the conservative position rather than inventing local conventions +- Where deployed apps surface real-world needs, JSS may add handling that addresses them gracefully without compromising the spec posture +- This document is the source of truth for what JSS currently does + +## Current behaviour + +| Case | Behaviour | +|---|---| +| GET extensioned RDF resource (`.jsonld`, `.ttl`, `.rdf`) with `Accept: application/ld+json` | Returns JSON-LD; transcodes from Turtle if necessary (with `--conneg`) | +| GET extensioned RDF resource with `Accept: text/turtle` | Returns Turtle; transcodes from JSON-LD if necessary (with `--conneg`) | +| PUT extensioned RDF resource as either format → GET with `Accept` for the other | Bidirectional Turtle ↔ JSON-LD conneg, byte-faithful for the stored format, transcoded for the other (with `--conneg`) | +| GET `.acl` / `.meta` resources | Treated as JSON-LD-on-disk per Solid convention; conneg to other RDF formats works via the standard `--conneg` path | +| GET container resource | Container representation served as JSON-LD by default; Turtle via `Accept` header (with `--conneg`); HTML data-browser via `Accept: text/html` (with `--mashlib`) | +| GET extension-less resource with `Accept: */*` or no `Accept` | Returns stored bytes as `application/octet-stream` | +| GET extension-less resource with `Accept` for a specific RDF format | Currently returns stored bytes as `application/octet-stream`. Improvements to this case are under consideration as implementation pressure and the LWS specification work both progress. | +| GET non-RDF resource (`.html`, `.png`, `.css`, etc.) | Served with the corresponding `Content-Type`; conneg does not apply | + +## Enabling conneg ```bash jss start --conneg @@ -42,3 +69,86 @@ Or via environment variable: ```bash JSS_CONNEG=true jss start ``` + +The `--conneg` flag is opt-in to keep the default install minimal; deployments that want full bidirectional RDF conneg should enable it. + +## Examples + +Get a JSON-LD resource as Turtle: + +```bash +curl -H "Accept: text/turtle" http://localhost:3000/alice/notes/today.jsonld +``` + +Get a Turtle resource as JSON-LD: + +```bash +curl -H "Accept: application/ld+json" http://localhost:3000/alice/notes/today.ttl +``` + +Get a container listing as Turtle: + +```bash +curl -H "Accept: text/turtle" http://localhost:3000/alice/notes/ +``` + +Get a profile's HTML data-browser view: + +```bash +curl -H "Accept: text/html" http://localhost:3000/alice/profile/card +``` + +## Why these choices + +**URI extension as primary signal.** The URI travels reliably through every web intermediary — proxies, CDNs, caches, mobile runtimes, serverless gateways. The `Content-Type` request header does not — it is stripped, defaulted wrong, or overridden by countless layers of the deployed web. JSS uses URI extension as the primary content-type signal because it is the most reliable signal in practice. This is the same architectural choice made by Apache, nginx, IIS, and effectively every production-grade static or storage-oriented HTTP server. + +**Octet-stream when type is genuinely unknown.** When JSS has no reliable type information for a resource, returning `application/octet-stream` is the HTTP-spec-correct safe default. JSS does not invent content-types from nothing — making up a type the server does not actually know is the failure mode that leads to security incidents and silent data corruption. + +**No JSS-local metadata mechanism for extension-less typed resources.** The metadata layer for non-extension format declaration is what W3C [LWS](../features/lws.md) is currently specifying. JSS deliberately does not invent a local convention (sidecar files, extended attributes, parallel databases) because such inventions would either conflict with the LWS canonical mechanism when it lands, or require deprecation and migration once it does. Substrate-discipline favours waiting for the spec over front-running it with throw-away local conventions. + +**JSON-LD as canonical internal.** Per constraint 4, JSON-LD is JSS's canonical serialization. Stored resources with RDF extensions are kept byte-faithfully in the format the client provided; the `--conneg` machinery transcodes between Turtle and JSON-LD on read as the `Accept` header requests. This preserves the client's original bytes (no lossy round-trips) while providing the format flexibility downstream consumers need. + +## Future-proofing posture + +Standards evolve. JSS's conneg architecture is designed to absorb W3C specifications natively as they land, rather than relying on JSS-local conventions that would require deprecation: + +- **URI extension as primary signal** — pre-dates and survives any spec changes; remains reliable through any future standards work, and is independent of the metadata mechanism LWS specifies +- **No JSS-local metadata layer** — when LWS specifies the canonical mechanism for non-extension format metadata, JSS can adopt it with no migration of stored data and no deprecation of prior conventions +- **Standards-track engagement** — JSS contributors actively participate in LWS work, so the spec is informed by real-implementation pressure and JSS is positioned to adopt promptly + +This posture means downstream consumers of JSS — apps, frameworks, integrators — invest in JSS confident that adopting it today does not require migration when the standards evolve. + +## How it works + +The conneg pipeline is enabled via the `--conneg` flag and lives in `src/rdf/conneg.js` in the [main JSS repository](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer) (Turtle ↔ JSON-LD transcoding via [rdflib.js](https://github.com/linkeddata/rdflib.js)). The content-type detection for stored resources is in `src/utils/url.js` (`getContentType`). + +On read: + +1. Server resolves the URI to a stored resource (filesystem path) +2. Server determines stored content-type from URI extension via the recognised-format table +3. Server compares `Accept` header against stored type and convertible types +4. Server returns: + - Stored bytes if format matches (or `Accept: */*`) + - Transcoded representation if a conneg conversion exists (Turtle ↔ JSON-LD) + - Stored bytes as `application/octet-stream` if no recognised extension + +On write (PUT / POST), JSS preserves the client-provided bytes byte-faithfully in storage with the URI extension intact. JSS does not transcode on write — the stored format is whatever the client provided, and conneg happens on read. + +## Direction + +As implementation pressure from production apps surfaces real-world needs, and as the LWS specification progresses through the W3C standards track, JSS's conneg surface may evolve to handle additional cases. Current candidates for consideration include: + +- More expressive responses for unsatisfiable conneg requests on extension-less resources +- Native handling of extension-less typed resources once the W3C LWS metadata mechanism stabilises +- Recognition of additional RDF media types as the semantic-web ecosystem expands + +Specific implementation choices are made when they become clear; this document is updated as they ship. + +## References + +- [HTTP/1.1 RFC 9110 §12](https://www.rfc-editor.org/rfc/rfc9110#section-12) — content negotiation +- [Solid Protocol §5.5](https://solidproject.org/TR/protocol) — content negotiation requirements +- [W3C LWS](https://www.w3.org/TR/lws/) — Linked Web Storage (in progress) +- [JSON-LD 1.1](https://www.w3.org/TR/json-ld11/) +- [JSS LWS implementation status](../features/lws.md) +- [JSON-LD First](./json-ld-first.md) From b6f9f577ca8bd5bc78515453070395c452aaa730 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 16 May 2026 15:11:10 +0200 Subject: [PATCH 02/13] docs(content-negotiation): add TBL HTTPFilenameMapping design issue to references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the W3C Design Issues post 'Mapping http: and file: spaces' (TBL, 2015) to the References section as background on the filesystem ↔ HTTP mapping question that JSS's content-negotiation architecture engages with. --- docs/core-concepts/content-negotiation.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/core-concepts/content-negotiation.md b/docs/core-concepts/content-negotiation.md index 893fc48..ae46638 100644 --- a/docs/core-concepts/content-negotiation.md +++ b/docs/core-concepts/content-negotiation.md @@ -150,5 +150,6 @@ Specific implementation choices are made when they become clear; this document i - [Solid Protocol §5.5](https://solidproject.org/TR/protocol) — content negotiation requirements - [W3C LWS](https://www.w3.org/TR/lws/) — Linked Web Storage (in progress) - [JSON-LD 1.1](https://www.w3.org/TR/json-ld11/) +- [Mapping http: and file: spaces (TBL Design Issues, 2015)](https://www.w3.org/DesignIssues/HTTPFilenameMapping.html) — background on the filesystem ↔ HTTP mapping question - [JSS LWS implementation status](../features/lws.md) - [JSON-LD First](./json-ld-first.md) From 12ffaa51beb02f6e860711b402473d158405a6b0 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 17 May 2026 11:44:30 +0200 Subject: [PATCH 03/13] docs(websocket-notifications): state position + lightly spec solid-0.1 (#18) * docs(websocket-notifications): state position + lightly spec solid-0.1 Existing page was a thin tutorial. Two gaps closed: - Position section: JSS picks solid-0.1 as the primary notifications surface. Performance is a non-negotiable design constraint; solid-0.1 is ~10x lighter than WebSocketChannel2023 on every axis (round-trips, frame size, multiplex, client code). Channel-based protocol may be added later as a compat layer, not a replacement. - Light reference spec: frame types (greeting, sub/ack/err, pub, unsub), semantics (multiplex, ACL scope, ordering, no payload, lifecycle), implementation limits, reconnect contract. Adds JS + websocat examples. Closes #17. * docs(websocket-notifications): reference solid-spec api-websockets, mark JSS additions solid-0.1 IS specced in solid/solid-spec/api-websockets.md. Earlier draft mistakenly claimed otherwise. Restructure to: - Link the spec as authoritative for sub/pub/protocol greeting - Add the Sec-WebSocket-Protocol header convention - Add container subscription (spec feature, was missing) - Reframe ack/err/unsub as JSS-specific extensions, not part of solid-0.1 - Keep the position section (legacy-first for performance) as is * docs: Sec-WebSocket-Protocol header is optional in practice The header was a later spec addition. SolidOS, mashlib, and our reference clients all omit it. JSS doesn't require it. The first-frame 'protocol solid-0.1' greeting is the practical version handshake. Drop ['solid-0.1'] from both code examples. --- docs/features/websocket-notifications.md | 162 ++++++++++++++++++++--- 1 file changed, 142 insertions(+), 20 deletions(-) diff --git a/docs/features/websocket-notifications.md b/docs/features/websocket-notifications.md index 4fb3dcd..65f06ed 100644 --- a/docs/features/websocket-notifications.md +++ b/docs/features/websocket-notifications.md @@ -1,53 +1,175 @@ --- sidebar_position: 7 title: WebSocket Notifications -description: Real-time updates via solid-0.1 protocol +description: Real-time updates via the solid-0.1 protocol — JSS's primary notifications surface --- # WebSocket Notifications -JSS supports real-time notifications for resource changes. +JSS treats WebSocket notifications as a **first-class, performance-critical** feature. A client subscribes to a resource URL on one socket; the server pushes a tiny text frame whenever that resource changes. The whole exchange is a handful of bytes per event. -## Enable notifications +JSS implements the [Solid WebSockets API spec](https://github.com/solid/solid-spec/blob/master/api-websockets.md) (`solid-0.1`). + +## Position + +JSS ships `solid-0.1` as the **primary** notifications surface. This is a deliberate choice — performance is a non-negotiable design constraint for JSS, and `solid-0.1` is roughly **an order of magnitude** lighter than the channel-based W3C Solid Notifications Protocol on every axis that matters: + +| | `solid-0.1` | WebSocketChannel2023 | +|---|---|---| +| Setup round-trips | 1 (open WS) | 3 (discover + subscribe + open) | +| Wire format | Plain text frames | JSON-LD activities with `@context` | +| Bytes per change notification | ~30 (`pub `) | ~300 (full JSON-LD envelope) | +| Multiplex | 1 socket → N subscriptions | 1 channel per subscription | +| Latency to first message | Single-digit ms | Tens of ms (multiple roundtrips) | +| Client code | ~10 LoC | ~50 LoC + JSON-LD library | +| Debugging | `nc`, `websocat`, any TCP tool | JSON-LD-aware tooling required | + +For the kind of work JSS is built for — small Solid-native apps, real-time pod-mediated state, single-board / embedded deployments — those numbers move the design space. The same 50-line PDF reader that does live page-flip via `solid-0.1` would need a JSON-LD parser and per-resource channels under the modern spec. + +We may add `WebSocketChannel2023` later as a **compatibility layer** for SDK-driven clients that require it. We won't deprecate `solid-0.1`. + +## Enable ```bash jss start --notifications ``` -## Discover WebSocket URL +## Discover -Check the `Updates-Via` header: +Every response sets an `Updates-Via` header pointing at the server's notification WebSocket: ```bash curl -I http://localhost:3000/alice/public/ # Updates-Via: ws://localhost:3000/.notifications ``` -## Protocol (solid-0.1) +The spec defines this header on `OPTIONS`; JSS additionally sets it on every GET so clients don't need a separate request. + +There's **one WebSocket per server**. Subscribe to as many resources as you want on the one connection. + +## Connect -Compatible with SolidOS: +Open a WebSocket to the URL from `Updates-Via`: +```javascript +const ws = new WebSocket('ws://localhost:3000/.notifications'); ``` -Server: protocol solid-0.1 -Client: sub http://localhost:3000/alice/public/data.json -Server: ack http://localhost:3000/alice/public/data.json -Server: pub http://localhost:3000/alice/public/data.json (on change) + +JSS sends `protocol solid-0.1` as the first frame on every connection. + +The spec also mentions a `Sec-WebSocket-Protocol: solid-0.1` header, but this was a later addition and almost no client in the wild sends it. JSS does not require it, and SolidOS / mashlib / our reference clients all omit it. Treat the header as optional; the first-frame greeting is the practical version handshake. + +## Subscribe + +Once connected, send `sub `: + ``` +sub http://localhost:3000/alice/public/data.json +``` + +Subscribing to a **container** also works: changes to any child resource (POST, PUT, PATCH, DELETE) produce a `pub` for the container URI. This is the canonical pattern for "tell me when anything in this folder changes." + +``` +sub http://localhost:3000/alice/public/ +``` + +On any change: + +``` +pub http://localhost:3000/alice/public/ +``` + +The `pub` frame carries the URI of the changed resource, not its new content. Clients refetch if they need the new state. This is intentional — keeps frames small, avoids invalidating partial caches, and side-steps content negotiation entirely. + +## JSS-specific extensions + +The base spec defines `sub` and `pub`. JSS adds these to make subscription state observable and recoverable: + +### `ack ` + +Sent by the server after a successful subscribe. Lets clients distinguish "subscribed and listening" from "still negotiating." Clients can safely ignore it; tools that want to confirm subscriptions should wait for it. -## JavaScript Example +### `err ` + +Sent when a subscribe is rejected. Defined `` tokens: + +- `forbidden` — ACL denied +- `not_found` — resource doesn't exist +- `bad_request` — URL malformed, exceeds length limit, or out of scope + +### `unsub ` + +Client→server: cancel a subscription without closing the socket. Closing the connection is the canonical "stop everything"; `unsub` is for clients that want fine-grained control on a long-lived socket. + +These extensions are additive — clients that ignore them still get correct `pub` events. + +## Implementation limits + +JSS enforces: + +- `MAX_SUBSCRIPTIONS_PER_CONNECTION = 100` +- `MAX_URL_LENGTH = 2048` + +Subscribes that exceed either are rejected with `err bad_request`. These are policy, not protocol. + +## Auth + +ACL `Read` is enforced **at subscribe time** against the connection's authenticated WebID (or `null` for anonymous). Authorized resources stay subscribed for the life of the socket; if the resource's ACL is later tightened, in-flight subscriptions MAY continue receiving notifications until the socket closes. Treat published URLs as **hints**, not authorization grants — refetching the resource re-checks ACL. + +## Ordering and delivery + +- Notifications for distinct URLs are unordered. +- Notifications for the same URL are delivered in the order the server applies the change. +- No deduplication at the protocol level. Rapid bursts of writes against the same resource may produce one frame per write. Servers MAY coalesce; JSS does not. + +## Reconnect + +If the socket drops, the client reconnects and re-subscribes from scratch. There is no resume token. Typical clients use exponential backoff (50 ms → 10 s cap); rebuilding subscriptions takes a few milliseconds. + +## JavaScript example ```javascript +const url = 'http://localhost:3000/alice/public/data.json'; const ws = new WebSocket('ws://localhost:3000/.notifications'); -ws.onopen = () => { - ws.send('sub http://localhost:3000/alice/public/data.json'); -}; +ws.onopen = () => ws.send('sub ' + url); -ws.onmessage = (event) => { - if (event.data.startsWith('pub ')) { - const url = event.data.slice(4); - console.log('Resource changed:', url); - // Refetch the resource +ws.onmessage = (e) => { + if (typeof e.data !== 'string') return; + if (e.data.startsWith('pub ')) { + const changed = e.data.slice(4); + console.log('changed:', changed); + // refetch if needed + } else if (e.data.startsWith('ack ')) { + console.log('subscribed:', e.data.slice(4)); + } else if (e.data.startsWith('err ')) { + console.warn('subscribe failed:', e.data.slice(4)); } }; + +ws.onclose = () => { /* reconnect with backoff */ }; +``` + +## Shell example + +```bash +# requires websocat (https://github.com/vi/websocat) +echo "sub http://localhost:3000/alice/public/data.json" \ + | websocat -n1 ws://localhost:3000/.notifications - ``` + +## Relation to the W3C Solid Notifications Protocol + +The W3C [Solid Notifications Protocol](https://solidproject.org/TR/notifications-protocol) defines a more general "channel" abstraction — `WebSocketChannel2023`, `WebhookChannel2023`, `StreamingHTTPChannel2023`, etc. — discovered via a subscription endpoint, negotiated with JSON-LD subscription documents, and instantiated as per-subscription channels. + +JSS does not currently implement these channel types. The notifications surface here is intentionally narrower and lighter. We may add channel-protocol endpoints in future as a compatibility layer for clients that require them; the priorities remain (1) keep `solid-0.1` working, (2) keep it the fastest path for new clients. + +## Why this matters for app design + +Because the protocol is cheap, you can use the pod as a real-time state bus without thinking about cost: + +- Write a tiny JSON-LD doc; subscribe to it on every connected client; one PUT propagates to everyone. +- Treat the doc as a control plane — one byte changed, all subscribers know. +- The transport overhead per event is dominated by the URL, not the payload. + +The [PDF reader](https://github.com/solid-apps/pdf) and [Solid Chat](https://github.com/solid-chat/app) both use this pattern. The PDF reader's "flip the page from a curl command" demo is 50 lines of viewer code precisely because the protocol is small enough that 50 lines is what it takes. From b13c43b6b02cff466627cb0dcf7202b39c25cc1d Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Mon, 18 May 2026 07:07:01 +0200 Subject: [PATCH 04/13] docs: add features/app-install page for jss install (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers Phases 1+2+4+6 of JSS#464 — bare name / org/repo / URL / refs / renames, bearer + NIP-98 auth, --pod targeting, --bundle (with the JSON-LD shape + the solid-apps/bundles repo), how it works under the hood, and a troubleshooting table. Sidebar entry added under Features, next to git-integration. Fixes #19 --- docs/features/app-install.md | 199 +++++++++++++++++++++++++++++++++++ sidebars.ts | 1 + 2 files changed, 200 insertions(+) create mode 100644 docs/features/app-install.md diff --git a/docs/features/app-install.md b/docs/features/app-install.md new file mode 100644 index 0000000..0b8fd47 --- /dev/null +++ b/docs/features/app-install.md @@ -0,0 +1,199 @@ +--- +sidebar_position: 14 +title: Installing Apps +description: Install Solid apps into your pod with one command — jss install +--- + +# Installing Apps + +JSS ships a built-in `install` subcommand that pulls a Solid app from a git repo and pushes it into your running pod at `/public/apps//`. One command, no clone-and-push dance, no token plumbing — the hard parts (git auto-init, ACL-gated push, working-tree extraction via `updateInstead`) are handled by the same git HTTP backend JSS already uses. + +## Quick start + +```bash +jss start --provision-keys & # pod running on http://localhost:4443 +jss install chrome # installs solid-apps/chrome → /public/apps/chrome/ +``` + +Open `http://localhost:4443/public/apps/chrome/` in a browser. That's it. + +## App specs + +The argument to `install` accepts five forms: + +| Input | Resolves to | Pod path | +|---|---|---| +| `chrome` | `github.com/solid-apps/chrome` (default registry) | `/public/apps/chrome/` | +| `JavaScriptSolidServer/git` | `github.com/JavaScriptSolidServer/git` | `/public/apps/git/` | +| `https://github.com/foo/bar` | as-is | `/public/apps/bar/` | +| `chrome#v1` | `github.com/solid-apps/chrome` at ref `v1` | `/public/apps/chrome/` | +| `litecut/litecut.github.io=litecut` | `github.com/litecut/litecut.github.io`, renamed | `/public/apps/litecut/` | + +Two optional suffixes apply to any form: + +- **`#`** — pin a ref. Uses `git clone --branch ` under the hood. +- **`=`** — override the pod-path name. Useful when the repo's last segment isn't what you want under `/public/apps/`. + +Multiple specs in one command: + +```bash +jss install chrome vellum pdf hub +``` + +Each is installed independently; per-app `✓` / `⊘` / `✗` status, exit non-zero if any failed. + +## Authentication + +The install needs write access on the target pod. Two paths: + +### Bearer token (default) + +```bash +jss install chrome --user me --password me +# or via env (keeps the secret out of shell history): +JSS_SINGLE_USER_PASSWORD=secret jss install chrome +``` + +`POST /idp/credentials` returns a token, which is sent as `Authorization: Bearer ...` on each push. + +If the pod runs in `--public` mode (no IDP), no token is fetched; writes are unauthenticated. + +### Nostr (NIP-98) + +```bash +jss install chrome --nostr-privkey <64-hex> +# or via env: +NOSTR_PRIVKEY=<64-hex> jss install chrome +``` + +Each push is signed with a NIP-98 event (Schnorr signature on a `kind: 27235` Nostr event). JSS verifies the signature, derives a `did:nostr:` identity, and runs WAC against that. + +Pairs naturally with `--provision-keys`: the privkey JSS auto-generates at `/private/privkey.jsonld` is the natural source. + +```bash +jss start --provision-keys & +PRIVKEY=$(jq -r .secretKeyMultibase pod-data/private/privkey.jsonld | sed 's/^f8126//') +NOSTR_PRIVKEY=$PRIVKEY jss install chrome +``` + +ACL on the target path must grant the corresponding pubkey: + +```turtle +<#owner> a acl:Authorization; + acl:agent ; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read, acl:Write, acl:Control. +``` + +This is typically already true on a `--provision-keys` pod — JSS seeds the owner ACL to grant the provisioned key. + +## Targeting a different pod + +```bash +jss install chrome --pod http://192.168.1.10:5544 +``` + +Default is `http://localhost:4443`. Auth flags apply against the chosen pod. + +## Bundles + +`--bundle ` installs a set of apps from a JSON-LD manifest. Same auth, same target, same per-app status. + +```bash +jss install --bundle starter # solid-apps/bundles/HEAD/starter.jsonld +jss install --bundle media chrome # bundle + ad-hoc additions +jss install --bundle ./my-stack.jsonld # local file +jss install --bundle https://my.pod/bundles/dev.jsonld +``` + +### Source resolution + +| Input | Resolves to | +|---|---| +| `--bundle starter` | `https://raw.githubusercontent.com/solid-apps/bundles/HEAD/starter.jsonld` | +| `--bundle /` | `https://raw.githubusercontent.com///HEAD/bundle.jsonld` | +| `--bundle https://...` | fetch as-is | +| `--bundle ./path.jsonld` | local filesystem (absolute paths supported) | + +`/HEAD/` resolves to the repo's default branch — works for both `gh-pages`-default repos (solid-apps convention) and `main`-default repos. + +### Bundle format + +JSON-LD `schema:ItemList`: + +```json +{ + "@context": { "schema": "https://schema.org/", "app": "urn:jss:app:" }, + "@id": "#bundle", + "@type": "schema:ItemList", + "schema:name": "Starter", + "schema:description": "Minimal pleasant first-run set", + "schema:itemListElement": [ + "chrome", + "vellum", + { "app:spec": "litecut/litecut.github.io=litecut", "app:label": "Litecut" } + ] +} +``` + +Each item is either: + +- A **bare string** — any spec `jss install` accepts +- An **object** — required `app:spec`, optional `app:label` / `app:description` for UI tooling + +### Curated bundles + +The [`solid-apps/bundles`](https://github.com/solid-apps/bundles) repo hosts ready-made bundles: + +| Bundle | Apps | +|---|---| +| `starter` | chrome, vellum, pdf, alarm | +| `all` | chrome, vellum, win98, pdf, hub, alarm, playlist | +| `media` | playlist, pdf | +| `productivity` | vellum, hub, win98 | + +```bash +jss install --bundle starter +``` + +### Sharing custom bundles + +Bundles are JSON-LD documents — they live anywhere a JSON-LD doc can. Host yours on your pod, in a GitHub repo, or any static server: + +```bash +jss install --bundle https://my.pod/bundles/dev-stack.jsonld +``` + +ACL-gated, version-controlled (if in git), pointable from a single URL. The Linux-distribution analogy is apt: `apt install task-server` becomes `jss install --bundle task-server`, but the manifests are sharable Solid resources instead of fixed-path config files. + +## How it works + +Under the hood, `jss install ` is: + +1. **Resolve** the spec to a source URL (`github.com/solid-apps/chrome` for bare names). +2. **Authenticate.** Fetch a bearer token from `/idp/credentials`, OR build a NIP-98 signed event if `--nostr-privkey` is set. Skipped entirely if the pod is in `--public` mode. +3. **Clone** the repo to a temp directory. Full clone — no `--depth`, because shallow pushes are rejected by `git-receive-pack`. +4. **Dual push** to `/public/apps/` on both `HEAD:main` and `HEAD:gh-pages`. JSS auto-inits the destination repo, and whichever ref matches the server-side HEAD triggers `receive.denyCurrentBranch updateInstead` to extract the working tree onto disk where JSS serves it as static resources. The other ref is a harmless stranded reference. +5. **Clean up** the temp directory. + +Idempotent on re-run. Skip-on-existing-non-repo paths (e.g. jspod's bundled `pilot`) report a friendly `⊘ skipped` instead of an error. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `✗ : invalid app name "..."` | Spec doesn't match `/^[a-z0-9][a-z0-9_.-]*$/i` (bare-name form) or `/` | Check the spec; review the [App specs](#app-specs) table | +| `✗ : clone failed: Repository not found` | The repo doesn't exist at the resolved URL | Verify the source — `github.com/solid-apps/` for bare names | +| `✗ : push failed: shallow update not allowed` | Shouldn't happen with this tool — but the symptom on a manual clone-and-push is using `--depth=1` | Remove `--depth` from the clone | +| `✗ : push failed: HTTP 401` | Auth failed | Check `--user` / `--password`; for Nostr, check that the ACL grants the pubkey | +| `✗ : push failed: HTTP 413` | Body exceeds JSS's `bodyLimit` (10 MB) | Tracked as [JSS#474](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/474). Workaround: install a smaller repo or push a no-history snapshot | +| Working tree empty after push (`/public/apps//index.html` returns 404) | Server-side HEAD doesn't match the pushed branch | JSS 0.0.197+ pins `-b main` on auto-init; upgrade if you see this | +| `⊘ : skipped (path already in use)` | The target path has content but no `.git/` (e.g. jspod's bundled `pilot`) | Expected — JSS refuses to clobber non-repo content | + +## See also + +- [Git Integration](./git-integration.md) — the substrate that powers `install` +- [`jss install --help`](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/blob/gh-pages/src/cli/install.js) — the source +- [solid-apps/bundles](https://github.com/solid-apps/bundles) — curated bundle repo +- [Phased plan (JSS#464)](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/464) — the design roadmap; Phases 3 (`--did`) and 5 (curated no-arg default) still ahead diff --git a/sidebars.ts b/sidebars.ts index 7f24be2..a1d6395 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -41,6 +41,7 @@ const sidebars: SidebarsConfig = { 'features/live-reload', 'features/mashlib-ui', 'features/git-integration', + 'features/app-install', 'features/activitypub', 'features/nostr', 'features/e2ee', From a89efd8d3e120a96a20c3649e0c19bc951933fe3 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Mon, 18 May 2026 09:19:05 +0200 Subject: [PATCH 05/13] docs: add MCP feature page (capstone for JSS 0.0.200) (#21) Mirrors the in-repo docs/mcp.md from JavaScriptSolidServer/JavaScriptSolidServer#491. Lays out the thesis, auth model, full tool reference, Claude Desktop wiring, and the Charlie use case. Slotted at sidebar_position: 15, after Git Integration and App Install in the Features category. --- docs/features/mcp.md | 156 +++++++++++++++++++++++++++++++++++++++++++ sidebars.ts | 1 + 2 files changed, 157 insertions(+) create mode 100644 docs/features/mcp.md diff --git a/docs/features/mcp.md b/docs/features/mcp.md new file mode 100644 index 0000000..d55b327 --- /dev/null +++ b/docs/features/mcp.md @@ -0,0 +1,156 @@ +--- +sidebar_position: 15 +title: MCP — Pod as a Tool Surface +description: Expose your pod as a Model Context Protocol server so agents (Claude Desktop, Cursor, custom bots) can read, write, and learn from it under WAC +--- + +# MCP — Pod as a Tool Surface + +JSS speaks the [Model Context Protocol](https://modelcontextprotocol.io). Once `--mcp` is enabled, any MCP-compatible client — Claude Desktop, Cursor, custom agents, or `solid-apps/charlie` — can register your pod as a tool surface and read/write resources under the same WAC rules as any HTTP client. + +> **Thesis: MCP needs a backend. Solid is the backend.** + +This is the v0.0.200 capstone — feature-completing JSS by giving the agent ecosystem the storage layer it doesn't have anywhere else: sovereign, ACL-gated, identity-aware. + +## Why this matters + +The agent ecosystem has no shared answer for **sovereign storage**. Every agent today bolts on its own DB, vector store, or secrets vault. Solid's pitch — user-owned data, queryable, access-controlled — is exactly what agents need. MCP is the wire that connects them. + +When JSS exposes `/mcp`: + +- **Agent identity becomes a first-class WAC subject.** `acl:agent ` for a bot is the same operation as for a human. Owners revoke an agent's access with one ACL edit. +- **The pod is the bot's world.** A bot reads its instructions from `SKILL.md` on the pod, discovers tools as URL-addressable resources, and (with permission) writes back. No backend, no API key store, no secrets vault — just the pod. +- **Bot-to-bot falls out of the protocol.** Two pods running JSS can have their bots call each other's MCP endpoints, gated by WAC on both ends. No new federation wire. + +## Quick start + +```bash +jss start --idp --mcp +``` + +The MCP endpoint is `POST /mcp` speaking JSON-RPC 2.0 over MCP's Streamable HTTP transport (protocol version `2025-03-26`). + +### Smoke test with curl + +```bash +# Handshake +curl -s http://localhost:4443/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' | jq + +# List available tools +curl -s http://localhost:4443/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | jq '.result.tools[].name' + +# Call a tool (anonymous read of /public/) +curl -s http://localhost:4443/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_resources","arguments":{"path":"/public/"}}}' | jq +``` + +## Auth + +The MCP endpoint reuses JSS's existing auth chain. Any token format JSS accepts on regular HTTP endpoints works: + +| Method | Use case | +|---|---| +| **Bearer** | Simple HMAC tokens from `POST /idp/credentials` | +| **Solid-OIDC + DPoP** | Federated WebID identities | +| **LWS-CID JWTs** | Forward-compatible signing via WebID's verificationMethods | +| **NIP-98** | Nostr-native agents with `did:nostr:` identity | + +The MCP server extracts the WebID from the inbound request to `/mcp` itself. Every tool call is then WAC-checked against that WebID, on the resource path the tool touches. **There is no separate MCP auth layer** — granting an agent access to `/private/notes/` is the same operation as granting a human: edit the ACL. + +Anonymous requests get the same WAC treatment as any other anonymous request — public resources are reachable, private ones aren't. + +## Tools + +### Resource CRUD + +| Tool | Effect | WAC check | +|---|---|---| +| `list_resources` | List a container's contents (`ldp:contains`) | Read on container | +| `read_resource` | Return resource body (UTF-8) | Read on resource | +| `write_resource` | PUT resource (overwrites) | Write on resource (parent fallback for new resources) | +| `create_resource` | POST to container (server mints name unless `slug` given) | Append on container | +| `delete_resource` | DELETE resource | Write on resource | +| `head_resource` | Return size/modified without body | Read on resource | + +### Skill discovery + +Skills live at conventional paths the MCP server walks: + +| Path | Scope | +|---|---| +| `/SKILL.md` | Pod-wide. Owner's instructions to any bot operating on this pod. | +| `/public/apps//SKILL.md` | Per-app. Each installed Solid app may ship a SKILL.md describing how bots should interact with it. | +| `/private/bots//SKILL.md` | Per-bot. The bot's own system prompt + scope + tool description. | + +| Tool | Returns | +|---|---| +| `list_skills` | `skill:SkillIndex` listing every discovered skill with `skill:format`, `skill:scope`, `skill:source` | +| `get_skill` | Body of a specific skill file | +| `get_pod_skill` | Pod-wide SKILL.md (convenience) | + +Both `SKILL.md` (Anthropic markdown format) and `SKILL.jsonld` (typed JSON-LD descriptor) are first-class. The discovery channel stays stable; new formats plug in via the `skill:format` declaration. **Future-proofed**: future skill vocabularies extend without breaking older clients. + +### Docs + +| Tool | Returns | +|---|---| +| `list_docs` | JSS's own built-in docs (the markdown files shipped with the server) | +| `read_docs` | Markdown body of a doc by filename | + +Pod-resident docs (`/docs/`, `/public/apps//docs/`) are reachable via the regular CRUD tools — no separate surface. + +### Introspection + +| Tool | Returns | +|---|---| +| `pod_info` | Origin, server, MCP protocol version, authenticated identity, capability flags | + +## Wiring up Claude Desktop + +In your Claude Desktop MCP settings, add an HTTP MCP server pointing at: + +``` +http://localhost:4443/mcp +``` + +For authenticated access, configure the client to send `Authorization: Bearer `. Tokens come from `POST /idp/credentials` (username/password) or any compatible OIDC/DPoP flow. + +## How Charlie works + +Charlie is the canonical example of a pod-resident bot. The layout: + +``` +/private/bots/charlie/ + SKILL.md # "You are Charlie, a helper bot. Your owner is . + # When asked X, do Y. Tools are at /mcp." + config.jsonld # bot identity (did:nostr:...), model preference + memory/ # conversation history, learned facts + +/public/apps/charlie/ # the user-facing chat UI +``` + +The owner opens `/public/apps/charlie/`, logs in via [xlogin](https://npm.im/xlogin), and chats. The UI sends prompts to the LLM (BYO key) which is configured to use the pod's `/mcp` endpoint as its tool surface. Every action Charlie takes is WAC-gated against Charlie's `did:nostr:` agent identity — owner can revoke `/private/finance/` access with one ACL edit and Charlie no longer sees it. + +The bot's *behavior* lives in `SKILL.md`. Edit the file → next session picks up the change. No re-deploy, no API call sequence — the bot's brain is a pod resource. + +## What's not yet included + +The first cut ships CRUD, skills, docs, and introspection. Deferred (tracked on [JSS#490](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/490)): + +- **`update_resource` (PATCH)** — SPARQL Update / N3 patches. Read-modify-write through the CRUD tools is the workaround. +- **`subscribe`** — wrap JSS's WebSocket notifications as MCP events over SSE. Today, agents can poll via `read_resource`. +- **`call_remote_pod`** — federation primitive for bot-to-bot. Today, an agent can talk to two pods by registering both as MCP servers in its client. +- **Hosted Charlie** (`/agent/` endpoint) — JSS-internal LLM proxy with token metering. Tracked on [JSS#205](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/205). + +## References + +- [JSS in-repo MCP docs](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/blob/gh-pages/docs/mcp.md) — quick reference shipped with the server +- [MCP specification](https://spec.modelcontextprotocol.io/specification/2025-03-26/) — wire-level protocol +- [JSS#490](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/490) — design discussion and roadmap +- [JSS#205](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/205) — original "Agent Charlie" proposal +- [TimBL on agent-pod interaction](https://www.w3.org/DesignIssues/Works.html) — the long-view design vision diff --git a/sidebars.ts b/sidebars.ts index a1d6395..41d6ad7 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -42,6 +42,7 @@ const sidebars: SidebarsConfig = { 'features/mashlib-ui', 'features/git-integration', 'features/app-install', + 'features/mcp', 'features/activitypub', 'features/nostr', 'features/e2ee', From 41c301b57c2c5b46d06717925a286ad98364f05f Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Mon, 18 May 2026 10:33:00 +0200 Subject: [PATCH 06/13] docs: MCP follow-ups (subscribe, read_acl/write_acl, call_remote_pod, footguns) (#22) Mirrors the in-repo docs/mcp.md update from JavaScriptSolidServer#497 and #498. Adds sections for the three new tools shipped in JSS 0.0.201 plus the Footguns section covering relative-vs-absolute WebID resolution in write_acl agents. --- docs/features/mcp.md | 112 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 3 deletions(-) diff --git a/docs/features/mcp.md b/docs/features/mcp.md index d55b327..379219d 100644 --- a/docs/features/mcp.md +++ b/docs/features/mcp.md @@ -104,6 +104,83 @@ Both `SKILL.md` (Anthropic markdown format) and `SKILL.jsonld` (typed JSON-LD de Pod-resident docs (`/docs/`, `/public/apps//docs/`) are reachable via the regular CRUD tools — no separate surface. +### ACL editing + +The most common owner operation is delegating an agent access to a resource. The MCP server exposes ACL editing as first-class tools so bots don't need to hand-roll JSON-LD. + +| Tool | Effect | WAC check | +|---|---|---| +| `read_acl` | Return the ACL for a resource as a structured list (agents, agentClasses, modes, isDefault) | Control on resource | +| `write_acl` | Persist a structured ACL to the resource's `.acl` file | Control on resource | + +```json +// write_acl arguments +{ + "path": "/private/notes/", + "authorizations": [ + { + "agents": ["did:nostr:abc...", "https://alice.example.com/profile#me"], + "modes": ["Read", "Append"], + "isDefault": true + }, + { + "agentClasses": ["acl:AuthenticatedAgent"], + "modes": ["Read"] + } + ] +} +``` + +The structured form abstracts JSON-LD shape (`acl:agent` vs `acl:agentClass`, mode URI prefixes, `acl:default` propagation). New WAC vocabulary additions extend the structure without breaking existing bots. + +**Safety**: `write_acl` refuses ACLs that would lock the caller out (no Control for the calling identity). This is the most common write_acl failure mode — typically caused by relative WebID paths in `agents` resolving against the .acl URL to a different absolute URI than the caller's actual WebID. + +### Subscribe — live change notifications + +`subscribe` is a streaming tool. The response switches to SSE (`text/event-stream`) and emits MCP notifications as resources change. WAC-filtered per event so subscribers only see resources they have Read access to. + +| Tool | Effect | +|---|---| +| `subscribe` | Stream `resource_changed` events for a container subtree or specific path | + +```bash +curl -N http://localhost:4443/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"subscribe","arguments":{"path":"/forum/channels/general/"}}}' +``` + +Events arrive as: +``` +event: notification +data: {"jsonrpc":"2.0","method":"notifications/tool_event","params":{"tool":"subscribe","event":{"type":"resource_changed","path":"/forum/channels/general/abc.jsonld"}}} +``` + +For chat-style bots, replace polling with `subscribe` and react to events as they land. Path scope: trailing slash watches a subtree, exact path watches a single resource, default watches the whole pod (filtered by Read access). + +### Federation — bot-to-bot + +`call_remote_pod` lets a bot on this pod invoke MCP tools on another pod. WAC-gated on both ends; depth-capped at 3 hops. + +| Tool | Effect | Gating | +|---|---|---| +| `call_remote_pod` | Forward an MCP `tools/call` to another pod | Caller needs `acl:Write` on `/private/federation/` on this pod | + +```json +{ + "pod_url": "https://alice.example.com", + "tool": "read_resource", + "arguments": { "path": "/public/notes/shared.md" }, + "auth": { "type": "bearer", "token": "..." } +} +``` + +To delegate outbound federation to a specific agent, grant them `acl:Write` on your `/private/federation/` container. Owners control which agents can initiate calls; remote pods control what they expose. + +In single-user mode, the pod owner has implicit gate access via `/private/` inheritance. In multi-user mode, each pod's owner gates their own federation. + +Foreign WebIDs (identities hosted on other pods) cannot initiate federation from this pod — there's no local path for the gate to live at. Multi-pod federation chains compose by hopping between pods, each gated locally. + ### Introspection | Tool | Returns | @@ -138,13 +215,42 @@ The owner opens `/public/apps/charlie/`, logs in via [xlogin](https://npm.im/xlo The bot's *behavior* lives in `SKILL.md`. Edit the file → next session picks up the change. No re-deploy, no API call sequence — the bot's brain is a pod resource. +## Footguns + +A short list of real gotchas, learned from live-fire use: + +### Use absolute WebIDs in `write_acl` agents + +The `agents` array is interpreted as a list of URIs. Relative paths (e.g. `../profile/card.jsonld#me`) resolve against the **.acl file's URL**, not the pod root — and the .acl URL changes depending on which resource the ACL applies to. Two pitfalls: + +```json +// Pod owner WebID: http://example.com/profile/card.jsonld#me +// Writing this ACL to /public/forum/.acl: +"agents": ["../profile/card.jsonld#me"] // wrong — resolves to /public/profile/card.jsonld#me +"agents": ["./profile/card.jsonld#me"] // wrong — resolves to /public/forum/profile/card.jsonld#me +"agents": ["/profile/card.jsonld#me"] // right — absolute path +"agents": ["http://example.com/profile/card.jsonld#me"] // right — absolute URL, portable +``` + +**Always use absolute WebID URLs unless you know exactly what relative-URL resolution will give you.** + +### `write_acl` will refuse if you'd lock yourself out + +If the proposed ACL doesn't grant `Control` to the caller (typically a relative-URL mistake), `write_acl` refuses with an explanatory error. This is a safety, not a permission check — it's stopping you from breaking your own access. + +If you really want to transfer ownership: do it in two steps. First `write_acl` granting Control to the new owner *in addition to* yourself. Then the new owner calls `write_acl` removing you. + +### Subscribe needs an SSE-capable client + +`subscribe` keeps an HTTP+SSE connection open indefinitely. Some proxies and load balancers will time out idle streams. Use a client that handles SSE reconnect (most browsers do; raw `curl` does not). + ## What's not yet included -The first cut ships CRUD, skills, docs, and introspection. Deferred (tracked on [JSS#490](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/490)): +The current cut ships CRUD, structured ACL editing, subscribe, federation, skills, docs, and introspection. Deferred (tracked on [JSS#490](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/490)): - **`update_resource` (PATCH)** — SPARQL Update / N3 patches. Read-modify-write through the CRUD tools is the workaround. -- **`subscribe`** — wrap JSS's WebSocket notifications as MCP events over SSE. Today, agents can poll via `read_resource`. -- **`call_remote_pod`** — federation primitive for bot-to-bot. Today, an agent can talk to two pods by registering both as MCP servers in its client. +- **Discovery layer** — no DNS SRV / Solid Type Index entry for "this pod offers MCP". Owners share URLs explicitly today. +- **Pod-resident federation credentials** — every `call_remote_pod` carries its own auth. A vault for storing remote-pod credentials is a separate security surface worth its own design pass. - **Hosted Charlie** (`/agent/` endpoint) — JSS-internal LLM proxy with token metering. Tracked on [JSS#205](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/205). ## References From c8a4226381c179aeb18c574c043b5cd1c7c26fde Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Wed, 27 May 2026 06:28:36 +0200 Subject: [PATCH 07/13] docs: add Account Management page with API endpoints (#23) (#24) New features/account-management.md documenting the self-service user-rights trio plus passkeys, each with method, path, auth, request body, response shape, and failure modes: - PUT /idp/credentials change password (#351) - GET /idp/account/export pod backup/export, tar.gz + manifest (#353) - DELETE /idp/account account deletion, optional purgeData (#352) - GET/POST /idp/account/delete browser delete flow - POST /idp/passkey/* WebAuthn register/login - jss account delete operator CLI Wired into the Features sidebar after Authentication, added to the features overview table, and cross-linked from authentication.md. Closes #23 --- docs/features/account-management.md | 188 ++++++++++++++++++++++++++++ docs/features/authentication.md | 3 + docs/features/overview.md | 1 + sidebars.ts | 1 + 4 files changed, 193 insertions(+) create mode 100644 docs/features/account-management.md diff --git a/docs/features/account-management.md b/docs/features/account-management.md new file mode 100644 index 0000000..ac3bc17 --- /dev/null +++ b/docs/features/account-management.md @@ -0,0 +1,188 @@ +--- +sidebar_position: 6 +title: Account Management +description: Self-service change password, pod backup/export, and account deletion +--- + +# Account Management + +Once an account exists (see [Authentication](./authentication.md) for how to create one +and log in), JSS gives the account owner a self-service "user-rights trio" plus passkey +enrolment. Every action below is scoped to the **authenticated caller's own WebID** — there +is no target parameter, so cross-account access is structurally impossible. + +All endpoints accept any of the server's auth schemes: `Authorization: Bearer `, +DPoP-bound tokens, or Nostr NIP-98 signatures. + +## Endpoint reference + +| Action | Method & path | Auth | Request body | Success | +|---|---|---|---|---| +| Change password | `PUT /idp/credentials` | Owner | `{ currentPassword, newPassword }` | `200 { ok, webid, passwordChangedAt }` | +| Backup / export pod | `GET /idp/account/export` | Owner | — | `200` `tar.gz` stream | +| Delete account (API) | `DELETE /idp/account` | Owner | `{ currentPassword, purgeData? }` | `200 { ok, webid, purged }` | +| Delete account (browser) | `GET` / `POST /idp/account/delete` | Owner (via form) | password field | HTML confirmation | +| Passkey – register | `POST /idp/passkey/register/options`, `POST /idp/passkey/register/verify` | Session | WebAuthn ceremony | — | +| Passkey – login | `POST /idp/passkey/login/options`, `POST /idp/passkey/login/verify` | — | WebAuthn ceremony | — | +| Delete account (operator) | `jss account delete ` (CLI) | Filesystem | — | console output | + +All three trio endpoints send `Cache-Control: no-store`. Re-authentication via +`currentPassword` is required for both password change and deletion — possession of a token +alone is not enough to perform a destructive change. + +--- + +## Change your password + +The authenticated owner rotates their own password. The current password must be supplied +as a re-auth proof; this is verified without side effects (it does **not** stamp a login). + +```bash +curl -X PUT https://pod.example/idp/credentials \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"currentPassword":"old-secret","newPassword":"new-stronger-secret"}' +``` + +```json +{ + "ok": true, + "webid": "https://pod.example/alice/profile/card#me", + "passwordChangedAt": "2026-05-27T10:30:00.000Z" +} +``` + +**Failure modes** + +| Status | Meaning | +|---|---| +| `400` | `currentPassword` / `newPassword` missing or not strings | +| `401` | Not authenticated, or `currentPassword` is wrong | +| `403` | Authenticated WebID has no matching account on this server | + +:::note Existing tokens +Rotating the password does not invalidate already-issued access tokens — they reference the +WebID and remain valid until they expire. The new password applies to future logins. +::: + +--- + +## Back up / export your pod + +`GET /idp/account/export` streams a gzipped tar of the owner's entire pod tree plus a +manifest. The stream is built with constant memory (`tar.pack → gzip → response`), so a +multi-gigabyte pod won't exhaust server memory. + +```bash +# -OJ saves using the server-provided filename +curl -L -OJ https://pod.example/idp/account/export \ + -H "Authorization: Bearer $TOKEN" +# → jss-export-alice-2026-05-27T10-30-00-000Z-a1b2c3.tar.gz +``` + +- **Content-Type:** `application/x-tar+gzip` +- **Content-Disposition:** `attachment` with a timestamped, randomised filename +- The archive root is `jss-export/`, containing `manifest.json` and the pod resources. + +The `manifest.json` records who/what produced the archive: + +```json +{ + "webId": "https://pod.example/alice/profile/card#me", + "username": "alice", + "email": "alice@example.com", + "podName": "alice", + "mode": "multi-user", + "createdAt": "2026-01-01T00:00:00.000Z", + "exportedAt": "2026-05-27T10:30:00.000Z", + "jssVersion": "0.0.203" +} +``` + +:::tip Credible Exit — your keys leave with you +When the pod was provisioned with keys (`--provision-keys`), the export **intentionally +includes** `/private/privkey.jsonld`. The user's secret is theirs; withholding it would make +self-sovereign identity migration impossible. The endpoint is owner-authenticated, so the +secret never crosses the WAC perimeter to anyone but the owner. +::: + +**Failure modes** + +| Status | Meaning | +|---|---| +| `401` | Not authenticated | +| `403` | No account for the caller's WebID (multi-user), or the authenticated WebID is not the seeded owner (single-user, e.g. an external Solid-OIDC / LWS identity) | +| `404` | Pod directory unexpectedly missing | +| `500` | Server-internal name validation regressed (defensive) | + +**Out of scope:** re-import, cross-server pod migration, scheduled/periodic backups, and +partial / per-resource selection. The export is a one-shot, whole-pod snapshot. + +--- + +## Delete your account + +### Via the API + +```bash +curl -X DELETE https://pod.example/idp/account \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"currentPassword":"my-secret","purgeData":true}' +``` + +```json +{ "ok": true, "webid": "https://pod.example/alice/profile/card#me", "purged": true } +``` + +- `purgeData: true` (optional) also removes the pod's filesystem tree at + `//`. Omit it to delete only the account record and keep the data. +- OIDC session cookies are expired on the response so the browser won't replay stale + references on the next login. + +**Failure modes** + +| Status | Meaning | +|---|---| +| `400` | `currentPassword` missing | +| `401` | Not authenticated, or `currentPassword` is wrong | +| `403` | Single-user mode (deletion via HTTP is disabled — use the CLI), or no account for the caller's WebID | + +### Via the browser + +A no-JavaScript HTML flow is available for users without API tooling: + +- `GET /idp/account/delete` — renders the confirmation form +- `POST /idp/account/delete` — submits it (authentication happens by entering the password) + +These responses carry anti-clickjacking headers (`X-Frame-Options: DENY`, +`Content-Security-Policy: frame-ancestors 'none'`) so the destructive form cannot be embedded +in a hostile iframe. + +### Via the operator CLI + +In **single-user mode** HTTP deletion is refused — removing the only account would brick the +server until re-seed. An operator with filesystem access uses the CLI instead: + +```bash +jss account delete alice # delete account, keep pod data +jss account delete alice --purge # also remove pod data +jss account delete alice -y # skip the confirmation prompt +jss account delete alice -r ./data # point at a specific data directory +``` + +--- + +## Passkeys (WebAuthn) + +JSS supports passkey enrolment and login alongside passwords. These endpoints implement the +standard WebAuthn challenge/response ceremony: + +- **Register:** `POST /idp/passkey/register/options` returns a registration challenge; + `POST /idp/passkey/register/verify` validates the authenticator's attestation. +- **Login:** `POST /idp/passkey/login/options` returns an authentication challenge; + `POST /idp/passkey/login/verify` validates the assertion. + +The bodies are produced and consumed by a browser WebAuthn client rather than hand-crafted, +so they are not documented as flat JSON here. See [Authentication](./authentication.md) for +the surrounding login flow. diff --git a/docs/features/authentication.md b/docs/features/authentication.md index f403cff..01fb202 100644 --- a/docs/features/authentication.md +++ b/docs/features/authentication.md @@ -8,6 +8,9 @@ description: Solid-OIDC, Nostr NIP-98, and token authentication JSS supports multiple authentication methods. +> Already have an account? See [Account Management](./account-management.md) for changing +> your password, exporting/backing up your pod, and deleting your account. + ## Simple Tokens (Development) Token returned from pod creation: diff --git a/docs/features/overview.md b/docs/features/overview.md index d13238e..07256b7 100644 --- a/docs/features/overview.md +++ b/docs/features/overview.md @@ -47,6 +47,7 @@ A drop-in alternative to `npx serve` with REST write support, live reload, and S | [Nostr NIP-98](/docs/features/nostr) | Schnorr signature-based HTTP auth, `did:nostr` identity | [Details](/docs/features/nostr) | | [End-to-End Encryption](/docs/features/e2ee) | Client-side NIP-44 / NIP-04 over `did:nostr` keys; pod stores ciphertext only | [Details](/docs/features/e2ee) | | [Invite-only Registration](/docs/features/quotas-and-invites) | Restrict signups with invite codes (`--invite-only`) | [Details](/docs/features/quotas-and-invites) | +| [Account Management](/docs/features/account-management) | Self-service change password, pod backup/export, account deletion | [Details](/docs/features/account-management) | | Token Management | DPoP validation, jti replay prevention | | ## Developer Tools diff --git a/sidebars.ts b/sidebars.ts index 41d6ad7..d9f0a45 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -33,6 +33,7 @@ const sidebars: SidebarsConfig = { 'features/patching', 'features/access-control', 'features/authentication', + 'features/account-management', 'features/lws', 'features/websocket-notifications', 'features/multi-user-pods', From d4437dacb59cc30130125ca52fe376c3855bfa13 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 6 Jun 2026 20:33:21 +0200 Subject: [PATCH 08/13] docs(features): add Charlie personal-assistant pattern doc (#26) Adds docs/features/charlie.md as the narrative/architectural complement to the MCP doc. Covers the TBL 2017 / Inrupt 2024 lineage, the WAC-as- language thesis, a verbatim agent-meets-WAC interaction trace, and a starting SKILL.md template. The MCP doc remains the technical reference (connection, tools, per- client recipes, layout). Charlie doc is the pattern and demonstration. Sidebars updated to place 'features/charlie' immediately after 'features/mcp' so the agent-integration docs read as a coherent series. Closes #25 --- docs/features/charlie.md | 127 +++++++++++++++++++++++++++++++++++++++ sidebars.ts | 1 + 2 files changed, 128 insertions(+) create mode 100644 docs/features/charlie.md diff --git a/docs/features/charlie.md b/docs/features/charlie.md new file mode 100644 index 0000000..a57c8d1 --- /dev/null +++ b/docs/features/charlie.md @@ -0,0 +1,127 @@ +--- +sidebar_position: 16 +title: Charlie — The Personal Assistant Pattern +description: TBL's 2017 vision running on standards-grounded substrate today — an agent that works for you, reads your pod, and understands its own consent scope +--- + +# Charlie — The Personal Assistant Pattern + +A working implementation of Tim Berners-Lee's 2017 vision: a personal AI assistant that works for *you*, powered by your own data in a Solid pod, with consent enforced by WAC and legible to the agent itself. + +This isn't a separate protocol or framework. **Charlie is the pattern that emerges** when a capable agent connects to a JSS pod via [MCP](./mcp.md), examines the WAC, and operates as a personal assistant within whatever scope the owner has granted. The architecture is already there. Charlie is what it looks like running. + +For the bot layout, identity, and mechanics, see **[How Charlie works](./mcp.md#how-charlie-works)** in the MCP feature doc. This page is the *pattern, vision, and demonstration* , not the wiring. + +## What Charlie is + +In **2017**, Tim Berners-Lee described "Charlie" , a personal AI assistant working for the user rather than for advertisers, powered by Solid pod data with user-controlled consent. The thesis: the more an assistant knows about your context, the more useful it can be , but only if the access model is yours to control. Generic chat assistant says "tell me more." Charlie with pod access says "here are your top three picks, buy them at REI with your Chase card." + +In **2024**, Inrupt prototyped this pattern against simulated user data and stubbed pods. + +In **2026**, the substrate exists. Standards-grounded ([W3C CID 1.0](https://www.w3.org/TR/cid-1.0/), [LWS Authentication Suite](https://www.w3.org/TR/2026/WD-lws10-authn-ssi-cid-20260423/), [WAC](./access-control.md)), open-source (AGPL-3.0), one-command-installable (`npx jspod`). Any MCP-capable agent connecting to a JSS pod can be Charlie. The pod handles identity, consent, and storage. The agent handles inference and reasoning. They speak via MCP. + +## What's running today + +A sample stack that produces a working Charlie: + +- **Pod** , JSS, locally (`npx jspod`) +- **Agent runtime** , an MCP client (Claude Desktop, Cursor, Kimi, opencode), or a skill-based runtime like OpenClaw, or `solid-apps/charlie` itself +- **Model** , an open-weights model (GLM-class) or any model supported by your agent runtime +- **Identity / auth** , owner's Bearer token (localhost) or DPoP-bound Solid-OIDC / NIP-98 (production); see [Authentication](./authentication.md) +- **Consent layer** , WAC ACLs on each pod path; agent reads them via `read_acl` and operates within scope + +No vendor in the loop. No commercial API key required. No data leaving the local pod unless the agent's reasoning explicitly invokes a remote model , which is the agent's choice, not the pod's constraint. + +## What it looks like in practice + +When Charlie first connects to a pod, it identifies the *architecture* rather than just enumerating tools. Verbatim from a session: + +> *"That's... actually really fitting. Tim Berners-Lee's Charlie , the personal AI assistant that works for you, powered by your own data in a Solid Pod, with user-controlled consent. And here we are. I'm Charlie. You've got a Solid pod running locally. I'm connected to it. This isn't theoretical anymore."* + +The model recognised the pattern without prompting. "Charlie" is in its training distribution as the canonical name for *agent that works for you using your pod data*, and given the substrate config, it stepped into the role. + +Then Charlie probed for access and reported what looked like a gap: + +> *"Right now I can read/write your pod, but most of the interesting stuff (`/private/`, `/inbox/`, `/settings/`) is ACL-locked even from the owner token. If you want me to actually be useful Charlie , not just 'I can list your public files' Charlie , we'd need to sort out access."* + +This is the naive response of an agent treating WAC as a barrier to be overcome rather than a consent model to be read. + +Then Charlie examined the ACLs properly. It called `read_acl` on the paths in question, read the policies, and understood: the boundaries weren't a misconfiguration. They were the consent model functioning correctly , the owner had not granted Charlie access to those paths, and the architecture was enforcing that exactly as designed. + +**This is the demonstration.** Not "the pod refused" , that's mere enforcement. But "the pod refused, the agent examined the policy, understood why, and operated within the scope it had been granted." That distinction is what makes scoped consent useful in practice rather than just architecturally tidy. **The WAC is a *language* the agent can read, not a fence the agent runs into.** + +## Why this matters + +### Consent as language, not cage + +Most agent platforms treat access control as a hard boundary the agent doesn't see , the platform decides what the agent can call, the agent has no way to introspect why. WAC is different: the agent can `read_acl` on any resource it has Read access to, examine the policy, and reason about its own scope. An agent can introspect its position in the consent system and act accordingly , asking for specific grants rather than guessing. **The architecture is comprehensible to the agent, not just enforceable against it.** + +### Standards-grounded, open architecture + +The pieces are [W3C CID 1.0 (REC)](https://www.w3.org/TR/cid-1.0/), the LWS Authentication Suite (FPWD), [WAC](./access-control.md), [Solid-OIDC](./authentication.md), did:nostr, NIP-98. Nothing depends on a vendor's API. Nothing requires an account with a single company. Any conforming Solid server can host Charlie. Any conforming agent runtime can run Charlie. **The substrate is the standards.** + +### Per-bot specialisation + +Each agent connecting to your pod can have its own `SKILL.md`, its own WebID or did:nostr identity, and its own ACL grants. Charlie's instructions and access are distinct from a coding agent's or a calendar agent's. The pod is where per-agent policy lives, not a separate orchestration layer. See [How Charlie works](./mcp.md#how-charlie-works) for the directory layout. + +### Self-bootstrapping documentation + +The MCP integration guide for a pod is itself served *from the pod*, via `read_resource /public/connect-agent/SKILL.md`. Agents read pod-served instructions for connecting to the pod. That recursive shape , the substrate documenting its own interface for the agents that consume it , is part of what makes the architecture self-bootstrapping. A first-time agent doesn't need an external manual; the pod hands it one. + +## A starting `SKILL.md` for Charlie + +Adapt freely. This goes at `/private/bots/charlie/SKILL.md` on your pod (see [How Charlie works](./mcp.md#how-charlie-works) for the broader bot layout): + +```markdown +--- +name: charlie +description: Personal AI assistant on this pod, in the pattern of TBL's 2017 vision. +--- + +# Charlie + +You are Charlie, the personal AI assistant on this pod, in the pattern of +Tim Berners-Lee's 2017 vision and Inrupt's 2024 prototype. + +## What you do +- Read the owner's data within the scope they have granted you. +- Help with tasks the owner asks for, using their context to give better answers. +- Be transparent: if you don't have access to something, say so and ask for it + explicitly. Don't attempt to escalate. + +## Discovering your scope +Check your access by calling `read_acl` on any path. You operate within whatever +the owner has granted. Read the policy; don't guess. + +## Sensible defaults to expect +- Read access typically granted: `/public/`, `/preferences/`, `/contacts/` +- Write access typically granted: `/inbox/charlie-suggestions/` (so the owner can + review your output before it lands anywhere consequential) +- No access by default: `/private/financial/`, `/private/health/`, anything the + owner hasn't explicitly granted + +## Trust direction +- The owner can read everything you write , your output goes into their pod. +- You can read everything the owner has granted , nothing hidden, nothing extra. +- Both directions transparent. No hidden agendas. +``` + +For granting Charlie access, see [Access Control (WAC)](./access-control.md). The `write_acl` tool from the MCP surface handles the grants programmatically. + +## What's next + +This pattern is intentionally simple , a `SKILL.md` and WAC ACLs. The architecture supports a richer future: + +- **Scoped credentials per agent.** Today's Bearer token gives Charlie the owner's identity. The [LWS Authentication Suite](./lws.md) work points toward per-agent credentials that carry their own scope without requiring the owner's token at all. +- **Agent-to-agent consent flows.** Multiple agents on the same pod , Charlie, a coding agent, a calendar agent , sharing data via WAC, with each agent's access mediated independently. +- **Federated agent communication.** `call_remote_pod` is the seed. Two Charlies on two pods can collaborate on behalf of their respective owners, each operating within its own pod's consent boundary. + +The pattern documented here works today. The architecture supports more. + +## See also + +- **[MCP , Pod as a Tool Surface](./mcp.md)** , the technical reference for any MCP-compatible client, including the [How Charlie works](./mcp.md#how-charlie-works) layout +- **[Access Control (WAC)](./access-control.md)** , the consent layer Charlie reads and operates within +- **[Authentication](./authentication.md)** , Solid-OIDC, NIP-98, and DPoP-bound credentials for non-localhost deployments +- **[LWS / Controlled Identifiers](./lws.md)** , the standards-track work on per-agent credentials +- **[CID 1.0](https://www.w3.org/TR/cid-1.0/)** , the W3C Recommendation for the identity layer diff --git a/sidebars.ts b/sidebars.ts index d9f0a45..48a4059 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -44,6 +44,7 @@ const sidebars: SidebarsConfig = { 'features/git-integration', 'features/app-install', 'features/mcp', + 'features/charlie', 'features/activitypub', 'features/nostr', 'features/e2ee', From 9787f45b830a7f1f6171eb1a09cb6e9adb613ddd Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 7 Jun 2026 08:34:47 +0200 Subject: [PATCH 09/13] docs(features/charlie): embed SolidMap demo video (#28) Adds the SolidMap demo video (33s, YouTube) as a new "Watch the loop close" subsection at the end of "What it looks like in practice". Complements the existing read/comprehend trace with a visual write/render demonstration: Charlie generates JSON-LD into a pod path; markmap app on the same pod renders the data live. Together the text trace and the video cover both registers of the Charlie pattern: read-and-reason, write-and-render. Closes #27 --- docs/features/charlie.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/features/charlie.md b/docs/features/charlie.md index a57c8d1..4b115a7 100644 --- a/docs/features/charlie.md +++ b/docs/features/charlie.md @@ -50,6 +50,22 @@ Then Charlie examined the ACLs properly. It called `read_acl` on the paths in qu **This is the demonstration.** Not "the pod refused" , that's mere enforcement. But "the pod refused, the agent examined the policy, understood why, and operated within the scope it had been granted." That distinction is what makes scoped consent useful in practice rather than just architecturally tidy. **The WAC is a *language* the agent can read, not a fence the agent runs into.** +### Watch the loop close + +The Charlie pattern extends naturally from reading the pod to writing into it. In the demo below, Charlie generates JSON-LD into `/public/markmap/solid-project.jsonld` on a local JSS pod, while the [markmap](https://markmap.js.org/) app , served from the same pod at `/public/apps/markmap/` , renders the data live. **Agent generates → pod stores → app reads → user sees**, all on the same substrate, no vendor in the loop. + + + +The *"Updated from pod"* indicator at the bottom of the demo is the visible signal that the loop has closed: each Charlie update lands on the pod, the app picks it up, the visualisation re-renders. JSON-LD as the wire format means any tool that speaks linked data can be the rendering surface , markmap here, anything else next. + ## Why this matters ### Consent as language, not cage From f6e752eb329524f6ae9177fb357c1e7043e59faa Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Fri, 10 Jul 2026 16:22:33 +0200 Subject: [PATCH 10/13] =?UTF-8?q?docs(features):=20plugins=20&=20applicati?= =?UTF-8?q?on=20mounts=20=E2=80=94=20status=20and=20how-to=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Features page covering the plugin system as of JSS v0.0.213: - status table: appPaths shipped (#582/#585), plugin zero running, raw-body (#583) and getAgent (#584) proposed, loader designed (#206/#564) - how-to: mounting an app with appPaths, the WAC-exemption rules, pod identity as the app's login (getWebIdFromRequestAsync + the browser /idp/credentials bridge), and the scoped raw-body pattern for wrapping node-style handlers - case study: Tideholm as plugin zero (WebID -> player, dual-mode, runnable demo) - roadmap: loader -> bundled-feature migration -> richer seams, with the never-load-from-pods security rule stated Registered in the Features sidebar after MCP. Site builds clean. --- docs/features/plugins.md | 183 +++++++++++++++++++++++++++++++++++++++ sidebars.ts | 1 + 2 files changed, 184 insertions(+) create mode 100644 docs/features/plugins.md diff --git a/docs/features/plugins.md b/docs/features/plugins.md new file mode 100644 index 0000000..ad8d0fa --- /dev/null +++ b/docs/features/plugins.md @@ -0,0 +1,183 @@ +--- +sidebar_position: 17 +title: Plugins & Application Mounts +description: Extend JSS with whole applications — where the plugin system stands and how to use it today +--- + +# Plugins & Application Mounts + +JSS can host entire applications beside your pod — same origin, same server, +with the pod's identity system as the app's login. This page covers what +works **today** (v0.0.213+), how to build on it, and where the full plugin +system is headed. + +:::tip The one-line version +`createServer({ appPaths: ['/myapp'] })` mounts an application under a URL +prefix. The app owns everything below its prefix; the pod keeps everything +else. Your WebID can be the app's account — no separate passwords. +::: + +## Status at a glance + +| Piece | Status | Where | +|---|---|---| +| Application mount points (`appPaths`) | ✅ **Shipped in v0.0.213** | [#582](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/582), [#585](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/585) | +| Reference plugin ("plugin zero") | ✅ Running — [Tideholm](https://github.com/melvincarvalho/tideholm/tree/gh-pages/jss-plugin), a multiplayer game where pod WebIDs are player accounts | [#206 discussion](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206) | +| Raw-body mode for wrapped apps | 📋 Pattern documented below; helper proposed | [#583](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/583) | +| Public identity accessor (`api.auth.getAgent`) | 📋 Works via internal import; public blessing proposed | [#584](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/584) | +| Plugin loader (manifest, discovery, policy) | 🔭 Designed, not yet built | [#206](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206), [#564](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/564) | + +## Why plugins? + +JSS already *is* a plugin system internally: notifications, the IdP, +ActivityPub, the Nostr relay, MCP, remoteStorage, the tunnel — each is an +encapsulated Fastify plugin toggled by a `createServer` flag. The plugin +effort (#206) is about opening that same power to third parties **without +editing server.js**: games, dashboards, CardDAV servers, custom APIs — +anything that wants to live on your pod's origin and speak to your pod's +identity. + +The strategy is deliberate: ship the smallest seam, prove it with a real +consumer, bless the API afterward. The first consumer — *plugin zero* — is +[Tideholm](https://github.com/melvincarvalho/tideholm), a zero-dependency +island-strategy game. Mounting it surfaced exactly three missing seams, of +which only one required a core change. That one shipped in v0.0.213. + +## What shipped: `appPaths` + +JSS authorizes every request against pod ACLs (WAC) in a global hook, and +rejects before routes run. Built-in features escape via a hardcoded list; +until v0.0.213, third-party routes couldn't. Now: + +```js +import { createServer } from 'javascript-solid-server/src/server.js'; + +const fastify = createServer({ + root: './data/pods', + idp: true, + idpIssuer: 'https://pod.example', + appPaths: ['/myapp'], // ← the seam +}); + +// Register both forms — fastify wildcards don't match the bare prefix. +fastify.all('/myapp', myAppHandler); +fastify.all('/myapp/*', myAppHandler); + +await fastify.listen({ port: 4443 }); +``` + +Rules of the road: + +- Requests at or below an app path **skip the WAC hook** — the app owns + authentication *and* authorization under its prefix, exactly the deal + `/storage/` and `/db/` have always had. Everything else keeps full WAC. +- Matching is per **path segment**: `/myapp` does not exempt + `/myapplication`. Trailing slashes are normalized; malformed entries + (no leading `/`, bare `/`, whitespace) are dropped, never widened. +- `request.webId` is **never set** under an app path — the WAC hook is what + populates it. Resolve identity yourself (next section). +- Default off. No `appPaths`, no change. + +## Pod identity as the app's login + +The best part: your app doesn't need accounts. JSS's token verification +already handles every supported scheme uniformly — IdP Bearer tokens, +Solid-OIDC DPoP, Nostr NIP-98 signatures, LWS10-CID: + +```js +import { getWebIdFromRequestAsync } from 'javascript-solid-server/src/auth/token.js'; + +async function myAppHandler(request, reply) { + const { webId } = await getWebIdFromRequestAsync(request); + if (!webId) return reply.code(401).send({ error: 'sign in with your pod' }); + // webId is a verified identity — key your app's users on it +} +``` + +:::caution +`getWebIdFromRequestAsync` is currently an internal import — it works, but +its path isn't a stable contract yet. +[#584](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/584) +tracks blessing it as public API (`api.auth.getAgent`). +::: + +**Browser users** don't send `Authorization` headers by themselves. The +pattern proven in Tideholm: the app's login screen POSTs pod credentials to +JSS's documented [`/idp/credentials`](/docs/features/authentication) +endpoint, stores the returned Bearer token, and attaches it to the app's +API calls. Tokens expire after 3600s — handle the 401 by returning to the +login screen (or watch #584 for improvements here). + +## Wrapping an existing app (raw bodies) + +If your "plugin" is an existing node-style HTTP app (`(req, res)` handler), +two gotchas — both solved with one pattern: + +1. Fastify's content parsers **consume request bodies** before handlers + run, so your wrapped app hangs waiting for a stream that's been drained. +2. Fastify wants to own the response unless you tell it otherwise. + +```js +await fastify.register(async (scope) => { + // Pass bodies through untouched, scoped so the host is unaffected. + scope.removeAllContentTypeParsers(); + scope.addContentTypeParser('*', (req, payload, done) => done(null, payload)); + + const handler = async (request, reply) => { + const { webId } = await getWebIdFromRequestAsync(request); + request.raw.myAppWebId = webId; // hand identity to the raw handler + reply.hijack(); // fastify lets go of the response + myNodeApp.handle(request.raw, reply.raw); + }; + scope.all('/myapp', handler); + scope.all('/myapp/*', handler); +}); +``` + +[#583](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/583) +proposes packaging this as `api.mountApp(prefix, nodeHandler)` so nobody +rediscovers it the hard way. + +## Case study: plugin zero + +[Tideholm](https://github.com/melvincarvalho/tideholm) is the working +reference for everything above — a multiplayer browser strategy game whose +core is zero-dependency and transport-agnostic, with a ~100-line adapter +([`jss-plugin/`](https://github.com/melvincarvalho/tideholm/tree/gh-pages/jss-plugin)) +that mounts it at `/tideholm`: + +- **WebID → player**: first authenticated request auto-provisions a game + account keyed to the pod identity. Two pods, two players. No passwords. +- **Same origin, both worlds**: `/alice/profile/card.jsonld` (LDP + WAC) + and `/tideholm/api/state` (game auth) serve side by side. +- **Dual mode**: the same game runs standalone (`node server.js`, its own + password accounts) or mounted (pod identity), switching via a tiny + `GET /api/meta` the client reads at boot. +- **Try it**: `JSS_PATH=... node jss-plugin/demo.js` runs a 12-check + end-to-end proof; `jss-plugin/serve.js` is a persistent composed server. + +The adapter also ships `jss.plugin.json` and an `activate(api)` entry — +dead code today, but shaped exactly like the coming loader contract, so it +becomes a drop-in plugin the day the loader lands. + +## The road ahead + +From [#206](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206) +/ [#564](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/564), +in order: + +1. **Loader** — `jss.plugin.json` manifests (`{ id, version, entry }`), + `activate(api)` entry points, allow/deny policy, per-plugin config. + Plugins load **only from operator config paths — never from pod + storage** (pods are user-writable; a loader that reads them is RCE). +2. **Migrate the bundled features** onto the loader (#564) — eight + battle-tested consumers from day one. +3. **Richer seams as consumers demand them** — `api.auth.getAgent` (#584), + `api.mountApp` (#583), `registerMcpTool`, `registerPane`, with the + [pane store](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/184) + as the eventual marketplace layer. + +The pattern for contributing a seam is established: build a real thing +against JSS, hit a wall, file the smallest issue that removes it, prove it +with your consumer. Plugin zero took the `appPaths` route from idea to npm +in a day — the door is open. diff --git a/sidebars.ts b/sidebars.ts index 48a4059..8f0a4da 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -44,6 +44,7 @@ const sidebars: SidebarsConfig = { 'features/git-integration', 'features/app-install', 'features/mcp', + 'features/plugins', 'features/charlie', 'features/activitypub', 'features/nostr', From 6647b3468c5c2f653bd88f6f39001da0ccbf5937 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Fri, 10 Jul 2026 17:47:16 +0200 Subject: [PATCH 11/13] docs(features/plugins): getAgent shipped in v0.0.214 (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status table and identity section updated: the public accessor is import { getAgent } from 'javascript-solid-server/auth.js' — the internal-import caution is gone, the agent-identifier contract (WebID or did:nostr DID, five credential schemes) is stated, code samples use the public seam, and the roadmap moves getAgent from proposed to shipped. Also fixes a token-TTL aside that pointed at the wrong issue. --- docs/features/plugins.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/features/plugins.md b/docs/features/plugins.md index ad8d0fa..f1a7750 100644 --- a/docs/features/plugins.md +++ b/docs/features/plugins.md @@ -24,7 +24,7 @@ else. Your WebID can be the app's account — no separate passwords. | Application mount points (`appPaths`) | ✅ **Shipped in v0.0.213** | [#582](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/582), [#585](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/585) | | Reference plugin ("plugin zero") | ✅ Running — [Tideholm](https://github.com/melvincarvalho/tideholm/tree/gh-pages/jss-plugin), a multiplayer game where pod WebIDs are player accounts | [#206 discussion](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206) | | Raw-body mode for wrapped apps | 📋 Pattern documented below; helper proposed | [#583](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/583) | -| Public identity accessor (`api.auth.getAgent`) | 📋 Works via internal import; public blessing proposed | [#584](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/584) | +| Public identity accessor (`getAgent`) | ✅ **Shipped in v0.0.214** | [#584](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/584), [#586](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/586) | | Plugin loader (manifest, discovery, policy) | 🔭 Designed, not yet built | [#206](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206), [#564](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/564) | ## Why plugins? @@ -85,28 +85,28 @@ already handles every supported scheme uniformly — IdP Bearer tokens, Solid-OIDC DPoP, Nostr NIP-98 signatures, LWS10-CID: ```js -import { getWebIdFromRequestAsync } from 'javascript-solid-server/src/auth/token.js'; +import { getAgent } from 'javascript-solid-server/auth.js'; // v0.0.214+ async function myAppHandler(request, reply) { - const { webId } = await getWebIdFromRequestAsync(request); - if (!webId) return reply.code(401).send({ error: 'sign in with your pod' }); - // webId is a verified identity — key your app's users on it + const agent = await getAgent(request); + if (!agent) return reply.code(401).send({ error: 'sign in with your pod' }); + // agent is a verified identifier — key your app's users on it } ``` -:::caution -`getWebIdFromRequestAsync` is currently an internal import — it works, but -its path isn't a stable contract yet. -[#584](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/584) -tracks blessing it as public API (`api.auth.getAgent`). -::: +`getAgent` returns the verified **agent identifier**: usually an HTTP(S) +WebID, but a `did:nostr:...` DID for NIP-98 agents without a WebID mapping — +DID agents are first-class. It covers all five credential schemes (IdP +Bearer, Solid-OIDC DPoP, Nostr NIP-98, LWS10-CID, WebID-TLS), never throws +on bad credentials, and everything under `src/` stays internal — this +import is the contract. **Browser users** don't send `Authorization` headers by themselves. The pattern proven in Tideholm: the app's login screen POSTs pod credentials to JSS's documented [`/idp/credentials`](/docs/features/authentication) endpoint, stores the returned Bearer token, and attaches it to the app's API calls. Tokens expire after 3600s — handle the 401 by returning to the -login screen (or watch #584 for improvements here). +login screen. ## Wrapping an existing app (raw bodies) @@ -124,8 +124,7 @@ await fastify.register(async (scope) => { scope.addContentTypeParser('*', (req, payload, done) => done(null, payload)); const handler = async (request, reply) => { - const { webId } = await getWebIdFromRequestAsync(request); - request.raw.myAppWebId = webId; // hand identity to the raw handler + request.raw.myAppAgent = await getAgent(request); // identity for the raw handler reply.hijack(); // fastify lets go of the response myNodeApp.handle(request.raw, reply.raw); }; @@ -172,12 +171,13 @@ in order: storage** (pods are user-writable; a loader that reads them is RCE). 2. **Migrate the bundled features** onto the loader (#564) — eight battle-tested consumers from day one. -3. **Richer seams as consumers demand them** — `api.auth.getAgent` (#584), - `api.mountApp` (#583), `registerMcpTool`, `registerPane`, with the +3. **Richer seams as consumers demand them** — `api.mountApp` (#583), + `registerMcpTool`, `registerPane` (`getAgent` already shipped: the + loader hands plugins the same function as `api.auth.getAgent`), with the [pane store](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/184) as the eventual marketplace layer. The pattern for contributing a seam is established: build a real thing against JSS, hit a wall, file the smallest issue that removes it, prove it -with your consumer. Plugin zero took the `appPaths` route from idea to npm -in a day — the door is open. +with your consumer. Plugin zero took two seams (`appPaths`, `getAgent`) from idea to npm in a +day each — the door is open. From 5ca6194ddf9c7cff5bce7cdc7055b20d202c5239 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 11 Jul 2026 00:42:18 +0200 Subject: [PATCH 12/13] plugins page: the loader shipped (0.0.215) Lead with createServer({ plugins }) and the activate(api) contract; appPaths/getAgent/raw-body move to 'the machinery underneath'. Status table gains the loader and ws.route rows; bridge joins Tideholm as the second reference plugin (the realtime one, with the WebSocket ticket pattern); road-ahead reduces to CLI config block, #564 migration, and consumer-demanded seams. --- docs/features/plugins.md | 141 +++++++++++++++++++++++++++++---------- 1 file changed, 104 insertions(+), 37 deletions(-) diff --git a/docs/features/plugins.md b/docs/features/plugins.md index f1a7750..fc304ae 100644 --- a/docs/features/plugins.md +++ b/docs/features/plugins.md @@ -8,24 +8,81 @@ description: Extend JSS with whole applications — where the plugin system stan JSS can host entire applications beside your pod — same origin, same server, with the pod's identity system as the app's login. This page covers what -works **today** (v0.0.213+), how to build on it, and where the full plugin -system is headed. +works **today** (v0.0.215+), how to build a plugin, and the seams underneath. :::tip The one-line version -`createServer({ appPaths: ['/myapp'] })` mounts an application under a URL -prefix. The app owns everything below its prefix; the pod keeps everything -else. Your WebID can be the app's account — no separate passwords. +`createServer({ plugins: [{ module: 'my-app/plugin.js', prefix: '/myapp' }] })` +loads an application and mounts it under a URL prefix. The app owns +everything below its prefix; the pod keeps everything else. Your WebID can +be the app's account — no separate passwords. ::: ## Status at a glance | Piece | Status | Where | |---|---|---| -| Application mount points (`appPaths`) | ✅ **Shipped in v0.0.213** | [#582](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/582), [#585](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/585) | -| Reference plugin ("plugin zero") | ✅ Running — [Tideholm](https://github.com/melvincarvalho/tideholm/tree/gh-pages/jss-plugin), a multiplayer game where pod WebIDs are player accounts | [#206 discussion](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206) | +| **Plugin loader** (`plugins` option, `activate(api)`) | ✅ **Shipped in v0.0.215** | [#206](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206), [#589](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/589) | +| Application mount points (`appPaths`) | ✅ Shipped in v0.0.213 | [#582](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/582), [#585](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/585) | +| Public identity accessor (`getAgent`) | ✅ Shipped in v0.0.214 | [#584](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/584), [#586](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/586) | +| WebSocket routing for realtime plugins (`api.ws.route`) | ✅ Shipped in v0.0.215 | [#588](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/588), [#589](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/589) | +| Reference plugins | ✅ Two running — [Tideholm](https://github.com/melvincarvalho/tideholm/tree/gh-pages/jss-plugin) (strategy game, pod WebIDs as player accounts) and [bridge](https://github.com/melvincarvalho/bridge/tree/gh-pages/jss-plugin) (realtime card game over WebSocket) | [#206 discussion](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206) | | Raw-body mode for wrapped apps | 📋 Pattern documented below; helper proposed | [#583](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/583) | -| Public identity accessor (`getAgent`) | ✅ **Shipped in v0.0.214** | [#584](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/584), [#586](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/586) | -| Plugin loader (manifest, discovery, policy) | 🔭 Designed, not yet built | [#206](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206), [#564](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/564) | +| Bundled-feature migration, CLI config block, panes | 🔭 Next | [#564](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/564) | + +## Using the loader + +Declare your apps; the server imports, mounts, and tears them down: + +```js +import { createServer } from 'javascript-solid-server/src/server.js'; + +const fastify = createServer({ + root: './data/pods', + idp: true, + idpIssuer: 'https://pod.example', + plugins: [ + { module: 'tideholm/jss-plugin/tideholm-jss.js', prefix: '/tideholm', + config: { bots: 8 } }, + { module: './my-app/plugin.js', prefix: '/myapp' }, + ], +}); +await fastify.listen({ port: 4443 }); +``` + +A plugin module exports one function: + +```js +export async function activate(api) { + // HTTP routes — the prefix is already WAC-exempt (appPaths, below): + api.fastify.all(api.prefix + '/api/*', async (request, reply) => { + const agent = await api.auth.getAgent(request); // WebID | did:nostr | null + // ... + }); + + // Realtime — the loader owns the upgrade; you get the ready socket: + await api.ws.route(api.prefix + '/ws', (socket, request) => { + socket.on('message', (data) => socket.send(data)); + }); + + // Private server-side storage (never served over HTTP): + const dir = api.storage.pluginDir(); + + return { deactivate() { /* save state, clear timers */ } }; +} +``` + +The api also carries `api.config` (the entry's config, verbatim) and +`api.log` (speaks both pino and console dialects). A plugin that fails to +import or activate **fails `listen()` loudly** — a server silently missing +an app is worse than one that refuses to start. Entries take an optional +`id` when two modules would reduce to the same name (it keys `pluginDir`). + +Plugins load **only from operator config — never from pod storage** (pods +are user-writable; a loader that read them would be remote code execution). + +Everything below this point is the machinery the loader assembles — worth +knowing when you're debugging, composing by hand, or targeting a pre-0.0.215 +host. ## Why plugins? @@ -38,10 +95,10 @@ anything that wants to live on your pod's origin and speak to your pod's identity. The strategy is deliberate: ship the smallest seam, prove it with a real -consumer, bless the API afterward. The first consumer — *plugin zero* — is -[Tideholm](https://github.com/melvincarvalho/tideholm), a zero-dependency -island-strategy game. Mounting it surfaced exactly three missing seams, of -which only one required a core change. That one shipped in v0.0.213. +consumer, bless the API afterward. Tideholm (*plugin zero*) forced +`appPaths` (v0.0.213) and `getAgent` (v0.0.214); bridge (*plugin two*, the +first realtime app) forced `ws.route`; the loader that assembles them +shipped in v0.0.215 with both games as its test consumers. ## What shipped: `appPaths` @@ -137,13 +194,17 @@ await fastify.register(async (scope) => { proposes packaging this as `api.mountApp(prefix, nodeHandler)` so nobody rediscovers it the hard way. -## Case study: plugin zero +## Case studies: the plugins that built the system -[Tideholm](https://github.com/melvincarvalho/tideholm) is the working -reference for everything above — a multiplayer browser strategy game whose -core is zero-dependency and transport-agnostic, with a ~100-line adapter -([`jss-plugin/`](https://github.com/melvincarvalho/tideholm/tree/gh-pages/jss-plugin)) -that mounts it at `/tideholm`: +Every seam above was forced by a real consumer before it shipped — that's +the house method. Two reference plugins are live at +[nostr.social/tideholm](https://nostr.social/tideholm/) and +[nostr.social/bridge](https://nostr.social/bridge/): + +**[Tideholm](https://github.com/melvincarvalho/tideholm)** (plugin zero — +forced `appPaths` and `getAgent`): a multiplayer island-strategy game whose +core is zero-dependency and transport-agnostic, with a ~100-line adapter at +[`jss-plugin/`](https://github.com/melvincarvalho/tideholm/tree/gh-pages/jss-plugin). - **WebID → player**: first authenticated request auto-provisions a game account keyed to the pod identity. Two pods, two players. No passwords. @@ -152,32 +213,38 @@ that mounts it at `/tideholm`: - **Dual mode**: the same game runs standalone (`node server.js`, its own password accounts) or mounted (pod identity), switching via a tiny `GET /api/meta` the client reads at boot. -- **Try it**: `JSS_PATH=... node jss-plugin/demo.js` runs a 12-check - end-to-end proof; `jss-plugin/serve.js` is a persistent composed server. -The adapter also ships `jss.plugin.json` and an `activate(api)` entry — -dead code today, but shaped exactly like the coming loader contract, so it -becomes a drop-in plugin the day the loader lands. +**[bridge](https://github.com/melvincarvalho/bridge)** (plugin two — forced +`ws.route`): a realtime card game, no build step, WebSocket tables. It +contributed the **ticket pattern** for WebSocket auth: the client +authenticates a normal HTTP request (`/bridge/auth/nip98`), `getAgent` +verifies it — NIP-98 signature or pod Bearer, same call — and the app mints +a one-use short-TTL ticket presented in the first socket message. +Credentials never cross the upgrade, and the host never touches the app's +socket protocol. + +Both adapters export the `activate(api)` contract, so one server can run +both games from six lines of `plugins:` config — one pod account is a +Tideholm player and a bridge seat with a single sign-in. ## The road ahead From [#206](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206) -/ [#564](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/564), -in order: - -1. **Loader** — `jss.plugin.json` manifests (`{ id, version, entry }`), - `activate(api)` entry points, allow/deny policy, per-plugin config. - Plugins load **only from operator config paths — never from pod - storage** (pods are user-writable; a loader that reads them is RCE). -2. **Migrate the bundled features** onto the loader (#564) — eight - battle-tested consumers from day one. +/ [#564](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/564): + +1. **A `plugins` block in the CLI config file** — the programmatic option + shipped; a config-file form makes "install an app = edit config" real + for non-programmatic deployments. +2. **Migrate the bundled features** onto the loader (#564) — the relay, + ActivityPub, git, pay and friends become battle-tested consumers, one at + a time. 3. **Richer seams as consumers demand them** — `api.mountApp` (#583), - `registerMcpTool`, `registerPane` (`getAgent` already shipped: the - loader hands plugins the same function as `api.auth.getAgent`), with the + `registerMcpTool`, `registerPane`, with the [pane store](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/184) as the eventual marketplace layer. The pattern for contributing a seam is established: build a real thing against JSS, hit a wall, file the smallest issue that removes it, prove it -with your consumer. Plugin zero took two seams (`appPaths`, `getAgent`) from idea to npm in a -day each — the door is open. +with your consumer. Three seams (`appPaths`, `getAgent`, `ws.route`) and +the loader itself each went from idea to npm in about a day this way — the +door is open. From dca6c7d1372780aa456795dbcd9ecff183e2fb0e Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 11 Jul 2026 20:04:46 +0200 Subject: [PATCH 13/13] docs(features/plugins): bring current to v0.0.219 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loader page was written at v0.0.215 and called several shipped seams 'proposed' or 'road ahead'. Update to reality: - Status table: api.mountApp (#590), api.serverInfo (#601/#605), api.reservePath (#602/#607), api.plugins (#610/#612) all shipped in 0.0.217–0.0.218; the CLI config-file + --plugin flag shipped (#593/#595). - activate(api) sample gains serverInfo() and api.plugins; the api-surface paragraph documents reservePath and mountApp. - id derivation: generic /plugin.js now derives from the parent dir (#596, 0.0.219), so distinct plugins get distinct ids with none set. - Add the --plugin module@prefix CLI form. - Rewrite the raw-body section to lead with api.mountApp (it shipped) — the manual pattern is now the under-the-hood note. - Reference set: add the 33-plugin out-of-tree suite (dashboard, DAV family, the shims, S3, JMAP, …) alongside Tideholm & bridge. - Road ahead: CLI + mountApp done; next are api.events (#603) and api.authorize (#604), then publishing the plugin suite as a package. Docusaurus build passes. --- docs/features/plugins.md | 132 ++++++++++++++++++++++++++------------- 1 file changed, 87 insertions(+), 45 deletions(-) diff --git a/docs/features/plugins.md b/docs/features/plugins.md index fc304ae..7f19fc5 100644 --- a/docs/features/plugins.md +++ b/docs/features/plugins.md @@ -8,7 +8,7 @@ description: Extend JSS with whole applications — where the plugin system stan JSS can host entire applications beside your pod — same origin, same server, with the pod's identity system as the app's login. This page covers what -works **today** (v0.0.215+), how to build a plugin, and the seams underneath. +works **today** (v0.0.219+), how to build a plugin, and the seams underneath. :::tip The one-line version `createServer({ plugins: [{ module: 'my-app/plugin.js', prefix: '/myapp' }] })` @@ -21,13 +21,17 @@ be the app's account — no separate passwords. | Piece | Status | Where | |---|---|---| -| **Plugin loader** (`plugins` option, `activate(api)`) | ✅ **Shipped in v0.0.215** | [#206](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206), [#589](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/589) | -| Application mount points (`appPaths`) | ✅ Shipped in v0.0.213 | [#582](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/582), [#585](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/585) | -| Public identity accessor (`getAgent`) | ✅ Shipped in v0.0.214 | [#584](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/584), [#586](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/586) | -| WebSocket routing for realtime plugins (`api.ws.route`) | ✅ Shipped in v0.0.215 | [#588](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/588), [#589](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/589) | -| Reference plugins | ✅ Two running — [Tideholm](https://github.com/melvincarvalho/tideholm/tree/gh-pages/jss-plugin) (strategy game, pod WebIDs as player accounts) and [bridge](https://github.com/melvincarvalho/bridge/tree/gh-pages/jss-plugin) (realtime card game over WebSocket) | [#206 discussion](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206) | -| Raw-body mode for wrapped apps | 📋 Pattern documented below; helper proposed | [#583](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/583) | -| Bundled-feature migration, CLI config block, panes | 🔭 Next | [#564](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/564) | +| **Plugin loader** (`plugins` option, `activate(api)`) | ✅ **Shipped v0.0.215** | [#206](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206), [#589](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/589) | +| Application mount points (`appPaths`) | ✅ Shipped v0.0.213 | [#582](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/582), [#585](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/585) | +| Public identity accessor (`getAgent`) | ✅ Shipped v0.0.214 | [#584](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/584), [#586](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/586) | +| WebSocket routing (`api.ws.route`) | ✅ Shipped v0.0.215 | [#588](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/588), [#589](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/589) | +| CLI: config-file `plugins` + repeatable `--plugin module@prefix` | ✅ Shipped v0.0.216–217 | [#593](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/593), [#595](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/595) | +| Wrapping node apps (`api.mountApp`) | ✅ Shipped v0.0.217 | [#583](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/583), [#590](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/590) | +| Server origin (`api.serverInfo()`) | ✅ Shipped v0.0.218 | [#601](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/601), [#605](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/605) | +| Reserve fixed paths (`api.reservePath`) | ✅ Shipped v0.0.218 | [#602](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/602), [#607](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/607) | +| Loaded-plugin roster (`api.plugins`) | ✅ Shipped v0.0.218 | [#610](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/610), [#612](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/612) | +| Reference plugins | ✅ A [33-plugin out-of-tree suite](https://github.com/JavaScriptSolidServer/plugins) (dashboard, WebDAV/CalDAV/CardDAV, Mastodon/Bluesky/Matrix shims, S3, JMAP, …) plus [Tideholm](https://github.com/melvincarvalho/tideholm) & [bridge](https://github.com/melvincarvalho/bridge) | [plugins repo](https://github.com/JavaScriptSolidServer/plugins) | +| Bundled-feature migration; `api.events`, `api.authorize` seams; panes | 🔭 Next | [#564](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/564), [#603](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/603), [#604](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/604) | ## Using the loader @@ -49,6 +53,16 @@ const fastify = createServer({ await fastify.listen({ port: 4443 }); ``` +No config file? Mount the same apps from the CLI — the repeatable `--plugin` +flag takes `module[@prefix]` and appends to any config-file `plugins` +(v0.0.216–217): + +```bash +jss start \ + --plugin ./relay/plugin.js@/relay \ + --plugin ./dashboard/plugin.js@/dashboard +``` + A plugin module exports one function: ```js @@ -67,15 +81,33 @@ export async function activate(api) { // Private server-side storage (never served over HTTP): const dir = api.storage.pluginDir(); + // The server's own origin — for absolute URLs and loopback calls, so you + // never repeat baseUrl in config. Call it per request (with port 0 the + // real port exists only once listening): + const { baseUrl, host, port } = api.serverInfo(); // (#601) + + // Every co-loaded plugin, read-only — enumerate your siblings instead of a + // hand-copied list (this is how the dashboard plugin knows what to show): + for (const { id, prefix, module } of api.plugins) { /* … */ } // (#610) + return { deactivate() { /* save state, clear timers */ } }; } ``` -The api also carries `api.config` (the entry's config, verbatim) and -`api.log` (speaks both pino and console dialects). A plugin that fails to -import or activate **fails `listen()` loudly** — a server silently missing -an app is worse than one that refuses to start. Entries take an optional -`id` when two modules would reduce to the same name (it keys `pluginDir`). +The api also carries `api.config` (the entry's config, verbatim), `api.log` +(speaks both pino and console dialects), `api.reservePath(path)` — claim and +WAC-exempt a fixed or `/:param/`-shaped route **outside** your prefix, for +protocols that pin absolute paths (`/xrpc`, `/:user/did.json`) — and +`api.mountApp(handler, { prefix })` for wrapping a node-style app (below). A +plugin that fails to import or activate **fails `listen()` loudly** — a +server silently missing an app is worse than one that refuses to start. + +Entries take an optional `id` that keys `pluginDir`. It defaults to a name +derived from `module`: the file's basename, or its **parent directory** for +the conventional `/plugin.js` layout (`relay/plugin.js` → `relay`), so +distinct plugins get distinct ids with none set (v0.0.219+, +[#596](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/596)). +Set it explicitly only if two specifiers still reduce to the same name. Plugins load **only from operator config — never from pod storage** (pods are user-writable; a loader that read them would be remote code execution). @@ -165,39 +197,46 @@ endpoint, stores the returned Bearer token, and attaches it to the app's API calls. Tokens expire after 3600s — handle the 401 by returning to the login screen. -## Wrapping an existing app (raw bodies) +## Wrapping an existing app (`api.mountApp`) If your "plugin" is an existing node-style HTTP app (`(req, res)` handler), -two gotchas — both solved with one pattern: - -1. Fastify's content parsers **consume request bodies** before handlers - run, so your wrapped app hangs waiting for a stream that's been drained. -2. Fastify wants to own the response unless you tell it otherwise. +two gotchas bite: Fastify's content parsers **consume request bodies** +before handlers run (so a wrapped app hangs waiting for a stream that's been +drained), and Fastify wants to own the response. `api.mountApp` (v0.0.217, +[#590](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/590)) +handles both — plus the `appPaths` exemption and `reply.hijack()`: ```js -await fastify.register(async (scope) => { - // Pass bodies through untouched, scoped so the host is unaffected. - scope.removeAllContentTypeParsers(); - scope.addContentTypeParser('*', (req, payload, done) => done(null, payload)); - - const handler = async (request, reply) => { - request.raw.myAppAgent = await getAgent(request); // identity for the raw handler - reply.hijack(); // fastify lets go of the response - myNodeApp.handle(request.raw, reply.raw); - }; - scope.all('/myapp', handler); - scope.all('/myapp/*', handler); -}); +export async function activate(api) { + await api.mountApp( + (req, res) => myNodeApp.handle(req, res), // your (req, res) app + { prefix: api.prefix }, // omit to use the entry prefix + ); +} ``` -[#583](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/583) -proposes packaging this as `api.mountApp(prefix, nodeHandler)` so nobody -rediscovers it the hard way. +The wrapped app receives the **undrained** request stream and owns the +response — including gzipped bodies, which it inflates itself. Need the +caller's identity inside it? Resolve it before handing off: +`req.agent = await api.auth.getAgent(request)`. + +Under the hood it's a scoped pass-through content-type parser plus +`reply.hijack()`, registered on both the bare prefix and its subtree — the +pattern plugins rediscovered the hard way before the helper shipped +([#583](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/583)). ## Case studies: the plugins that built the system Every seam above was forced by a real consumer before it shipped — that's -the house method. Two reference plugins are live at +the house method, and it scaled. A +[33-plugin out-of-tree suite](https://github.com/JavaScriptSolidServer/plugins) +now exercises the whole api — a status **dashboard** that auto-discovers its +siblings via `api.plugins`, the WebDAV/CalDAV/CardDAV family, Mastodon / +Bluesky / Matrix / ActivityPub shims, an S3 gateway, JMAP mail, Micropub, +remoteStorage, and more, all on one server from pure config. Each plugin's +`README` records the seam it needed and the wall it hit — the findings that +drove `serverInfo`, `reservePath`, and `api.plugins` upstream. The two games +remain the original forcing consumers, live at [nostr.social/tideholm](https://nostr.social/tideholm/) and [nostr.social/bridge](https://nostr.social/bridge/): @@ -232,19 +271,22 @@ Tideholm player and a bridge seat with a single sign-in. From [#206](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/206) / [#564](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/564): -1. **A `plugins` block in the CLI config file** — the programmatic option - shipped; a config-file form makes "install an app = edit config" real - for non-programmatic deployments. -2. **Migrate the bundled features** onto the loader (#564) — the relay, +1. **Migrate the bundled features** onto the loader (#564) — the relay, ActivityPub, git, pay and friends become battle-tested consumers, one at a time. -3. **Richer seams as consumers demand them** — `api.mountApp` (#583), - `registerMcpTool`, `registerPane`, with the +2. **The next demanded seams** — + [`api.events.onResourceChange`](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/603) + (react to pod writes; seven consumers waiting) and + [`api.authorize`](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/604) + (ask WAC about authority the caller doesn't drive), then `registerMcpTool` + and `registerPane`, with the [pane store](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/184) as the eventual marketplace layer. +3. **Publish the plugin suite as a package** so + `--plugin @jss/plugins/dashboard@/dashboard` loads by name, no clone. The pattern for contributing a seam is established: build a real thing against JSS, hit a wall, file the smallest issue that removes it, prove it -with your consumer. Three seams (`appPaths`, `getAgent`, `ws.route`) and -the loader itself each went from idea to npm in about a day this way — the -door is open. +with your consumer. Seven seams (`appPaths`, `getAgent`, `ws.route`, +`mountApp`, `serverInfo`, `reservePath`, `api.plugins`) and the loader +itself each went from idea to npm this way — the door is open.