This is a JSON-LD native implementation. Unlike traditional Solid servers that treat Turtle as the primary format and convert to/from it, this server:
{ conneg: true } when neededEnable conneg: true when:
import { createServer } from './src/server.js';
// Default: JSON-LD only (fast)
const server = createServer();
// With Turtle support (for interoperability)
const serverWithConneg = createServer({ conneg: true });
createServer({
logger: true, // Enable Fastify logging (default: true)
conneg: false, // Enable content negotiation (default: false)
notifications: false, // Enable WebSocket notifications (default: false)
subdomains: false, // Enable subdomain-based pods (default: false)
baseDomain: null, // Base domain for subdomains (e.g., "example.com")
mashlib: false, // Enable Mashlib data browser - local mode (default: false)
mashlibCdn: false, // Enable Mashlib data browser - CDN mode (default: false)
mashlibVersion: '2.0.0', // Mashlib version for CDN mode
});
Enable the SolidOS Mashlib data browser for RDF resources. Two modes are available:
CDN Mode (recommended for getting started):
jss start --mashlib-cdn --conneg
Loads mashlib from unpkg.com CDN. Zero footprint - no local files needed.
Local Mode (for production/offline):
jss start --mashlib --conneg
Serves mashlib from src/mashlib-local/dist/. Requires building mashlib locally:
cd src/mashlib-local
npm install && npm run build
ES Module Mode (for custom or next-gen mashlib builds):
jss start --mashlib-module https://example.com/mashlib.js
Loads an ES module-based data browser from any URL. Uses <script type="module"> and <div id="mashlib"> (self-initializing). CSS is auto-derived by replacing .js with .css. Content negotiation is auto-enabled.
How it works:
/alice/public/data.ttl with Accept: text/htmlNote: Mashlib works best with --conneg enabled for Turtle support.
Modern UI (SolidOS UI):
jss start --mashlib --solidos-ui --conneg
Serves a modern Nextcloud-style UI shell while reusing mashlib’s data layer. The --solidos-ui flag swaps the classic databrowser interface for a cleaner, mobile-friendly design with:
Requires solidos-ui dist files in src/mashlib-local/dist/solidos-ui/. See solidos-ui for details.
Pod profiles (/alice/) use HTML with embedded JSON-LD data islands and are rendered using:
getPod() fix for path-based podsThis allows profiles to work without server-side content negotiation while still providing full SolidOS editing capabilities.
Enable real-time notifications for resource changes:
const server = createServer({ notifications: true });
Clients discover the WebSocket URL via the Updates-Via header:
curl -I http://localhost:4443/alice/public/
# Updates-Via: ws://localhost:4443/.notifications
Protocol (solid-0.1, compatible with SolidOS):
Server: protocol solid-0.1
Client: sub http://localhost:4443/alice/public/data.json
Server: ack http://localhost:4443/alice/public/data.json
Server: pub http://localhost:4443/alice/public/data.json (on change)
| Option | Description | Default |
|---|---|---|
-p, --port <n> |
Port to listen on | 4443 |
-h, --host <addr> |
Host to bind to | 0.0.0.0 |
-r, --root <path> |
Data directory | ./data |
-c, --config <file> |
Config file path | - |
--ssl-key <path> |
SSL private key (PEM) | - |
--ssl-cert <path> |
SSL certificate (PEM) | - |
--conneg |
Enable Turtle support | false |
--notifications |
Enable WebSocket | false |
--idp |
Enable built-in IdP | false |
--idp-issuer <url> |
IdP issuer URL | (auto) |
--subdomains |
Enable subdomain-based pods | false |
--base-domain <domain> |
Base domain for subdomains | - |
--mashlib |
Enable Mashlib (local mode) | false |
--mashlib-cdn |
Enable Mashlib (CDN mode) | false |
--mashlib-module <url> |
Enable ES module data browser from a URL | - |
--mashlib-version <ver> |
Mashlib CDN version | 2.0.0 |
--solidos-ui |
Enable modern SolidOS UI (requires –mashlib) | false |
--git |
Enable Git HTTP backend | false |
--nostr |
Enable Nostr relay | false |
--nostr-path <path> |
Nostr relay WebSocket path | /relay |
--nostr-max-events <n> |
Max events in relay memory | 1000 |
--invite-only |
Require invite code for registration | false |
--webid-tls |
Enable WebID-TLS client certificate auth | false |
--default-quota <size> |
Default storage quota per pod (e.g., 50MB) | 50MB |
--activitypub |
Enable ActivityPub federation | false |
--ap-username <name> |
ActivityPub username | me |
--ap-display-name <name> |
ActivityPub display name | (username) |
--ap-summary <text> |
ActivityPub bio/summary | - |
--ap-nostr-pubkey <hex> |
Nostr pubkey for identity linking | - |
--public |
Allow unauthenticated access (skip WAC); never writes into the served directory (no landing-page seed) | false |
--read-only |
Disable PUT/DELETE/PATCH methods | false |
--live-reload |
Auto-refresh browser on file changes | false |
--pay |
Enable HTTP 402 paid access for /pay/* | false |
--pay-cost <n> |
Cost per request in satoshis | 1 |
--pay-mempool-url <url> |
Mempool API URL for deposit verification | (testnet4) |
--pay-address <addr> |
Address for receiving deposits | - |
--pay-token <ticker> |
Token to sell (enables primary market + withdrawal) | - |
--pay-rate <n> |
Sats per token for buy/withdraw | 1 |
--pay-chains <ids> |
Multi-chain deposits + AMM (e.g. “tbtc3,tbtc4”) | - |
--mongo |
Enable MongoDB-backed /db/ route | false |
--mongo-url <url> |
MongoDB connection URL | mongodb://localhost:27017 |
--mongo-database <name> |
MongoDB database name | solid |
--webrtc |
Enable WebRTC signaling server | false |
--webrtc-path <path> |
WebRTC signaling WebSocket path | /.webrtc |
--tunnel |
Enable tunnel proxy (decentralized ngrok) | false |
--tunnel-path <path> |
Tunnel WebSocket path | /.tunnel |
--terminal |
Enable WebSocket shell at /.terminal |
false |
-q, --quiet |
Suppress logs | false |
All options can be set via environment variables with JSS_ prefix:
export JSS_PORT=8443
export JSS_SSL_KEY=/path/to/key.pem
export JSS_SSL_CERT=/path/to/cert.pem
export JSS_CONNEG=true
export JSS_SUBDOMAINS=true
export JSS_BASE_DOMAIN=example.com
export JSS_MASHLIB=true
export JSS_MASHLIB_MODULE=https://example.com/mashlib.js
export JSS_NOSTR=true
export JSS_INVITE_ONLY=true
export JSS_WEBID_TLS=true
export JSS_DEFAULT_QUOTA=100MB
export JSS_ACTIVITYPUB=true
export JSS_AP_USERNAME=alice
export JSS_PUBLIC=true
export JSS_READ_ONLY=true
export JSS_SINGLE_USER=true
export JSS_SINGLE_USER_NAME=me
export JSS_SINGLE_USER_PASSWORD=choose-a-good-one # seeds IDP account on first start
export JSS_LIVE_RELOAD=true
export JSS_SOLIDOS_UI=true
export JSS_PAY=true
export JSS_PAY_COST=10
export JSS_PAY_ADDRESS=your-address
export JSS_PAY_TOKEN=PODS
export JSS_PAY_RATE=10
export JSS_MONGO=true
export JSS_MONGO_URL=mongodb://localhost:27017
export JSS_MONGO_DATABASE=solid
export JSS_WEBRTC=true
jss start
Create config.json:
{
"port": 8443,
"root": "./data",
"sslKey": "./ssl/key.pem",
"sslCert": "./ssl/cert.pem",
"conneg": true,
"notifications": true
}
Then: jss start --config config.json
curl -X POST http://localhost:4443/.pods \
-H "Content-Type: application/json" \
-d '{"name": "alice"}'
Response:
{
"name": "alice",
"webId": "http://localhost:4443/alice/#me",
"podUri": "http://localhost:4443/alice/",
"token": "eyJ..."
}
For personal pod servers where only one user needs access:
# Default: pod served at server root (#348). WebID is
# /profile/card.jsonld#me; the IDP login username is "me". On first
# run JSS will prompt for an initial password (TTY only).
jss start --single-user --idp
# Provide the initial IDP password non-interactively (systemd, containers, CI):
jss start --single-user --idp --single-user-password 'choose-a-good-one'
JSS_SINGLE_USER_PASSWORD='choose-a-good-one' jss start --single-user --idp
# Mount the pod at a named path instead of the origin. WebID becomes
# /alice/profile/card.jsonld#me; login as "alice".
jss start --single-user --single-user-name alice --idp
# Legacy /me/ pod — same as the old default before #348.
jss start --single-user --single-user-name me --idp
# Via environment
JSS_SINGLE_USER=true jss start --idp
Features:
POST /idp/credentials) or any other configured methodUpgrading from a pre-#348 install: if your existing pod was created with the old default (data lives under <root>/me/), JSS no longer auto-detects it — restarting plain jss start --single-user will start seeding a fresh empty root pod alongside your legacy /me/ data, and your existing IDP account will keep authenticating against /me/. Pick one path on the next restart:
--single-user-name me to your launch command. No data movement needed.Migrate to root pod: move the entire contents of <root>/me/ (including dotfiles like .acl, .meta, .quota.json — a plain mv <root>/me/* <root>/ skips them) to <root>/, delete the IDP account for me (so the new root pod’s me account can be seeded), then restart without the name flag. Use one of:
# Option A: rsync handles dotfiles correctly with the trailing slash.
rsync -a <root>/me/ <root>/ && rm -rf <root>/me
# Option B: bash with dotglob enabled so * matches dotfiles too.
shopt -s dotglob && mv <root>/me/* <root>/ && rmdir <root>/me
Initial password sources, in priority order:
--single-user-password <pw> CLI flagJSS_SINGLE_USER_PASSWORD env var--single-user-password <pw>, set JSS_SINGLE_USER_PASSWORD, or run on a TTY to be prompted. (jss passwd <user> does not work here — it returns “User not found” until an account exists.)The password is only consulted on the first start — once an account exists, subsequent restarts skip the seed step and never overwrite it. The password is never written to the saved config file (.jss/config).
Control who can create accounts by requiring invite codes:
jss start --idp --invite-only
# Create a single-use invite
jss invite create
# Created invite code: ABCD1234
# Create multi-use invite with note
jss invite create -u 5 -n "For team members"
# List all active invites
jss invite list
# CODE USES CREATED NOTE
# -------------------------------------------------------
# ABCD1234 0/1 2026-01-03
# EFGH5678 2/5 2026-01-03 For team members
# Revoke an invite
jss invite revoke ABCD1234
| Mode | Registration | Pod Creation |
|---|---|---|
| Open (default) | Anyone can register | Anyone can create pods |
| Invite-only | Requires valid invite code | Via registration only |
When --invite-only is enabled:
Invite codes are stored in .server/invites.json in your data directory.
Programmatic compositions can mount whole applications beside the pod (the plugin-zero pattern from #206):
import { createServer } from 'javascript-solid-server/src/server.js';
const fastify = createServer({ appPaths: ['/myapp'] });
// Register both forms: fastify wildcards don't match the bare prefix.
fastify.all('/myapp', myAppHandler);
fastify.all('/myapp/*', myAppHandler); // the app owns auth below its prefix
await fastify.listen({ port: 4443 });
Requests at or below an app path skip the WAC authorization hook — the
application authenticates and authorizes its own traffic, exactly like the
built-in /storage/ and /db/ routes. Everything outside the declared
prefixes keeps full WAC enforcement. Entries must start with / and be
longer than /; anything else is ignored.
Because the WAC hook is also what populates request.webId, requests under
an app path never carry it — don’t rely on request.webId in app handlers.
Resolve identity with the public accessor
(#584):
import { getAgent } from 'javascript-solid-server/auth.js';
const agent = await getAgent(request); // WebID or did:nostr DID, null if anonymous
See #582 for the design discussion and melvincarvalho/tideholm for a complete example (a multiplayer game where pod WebIDs are the player accounts).
The plugin loader (#206) does the appPaths wiring for you: declare the apps in config and the server imports, mounts, and tears them down itself.
const fastify = createServer({
root: './data',
idp: true,
plugins: [
{ module: 'tideholm/jss-plugin/tideholm-jss.js', prefix: '/tideholm',
config: { bots: 8 } },
{ module: './my-app/plugin.js', prefix: '/myapp' },
],
});
Each entry:
module — import specifier: a package path (resolved from JSS’s module
graph) or a file path (./… or absolute, resolved from the process cwd).
The module exports activate(api), called during startup.prefix — the app’s mount point. Added to appPaths automatically, so
the app owns authentication below it (see the section above). Must start
with /; invalid prefixes fail startup.config — passed to the plugin verbatim as api.config.id — optional stable identifier that names the plugin’s private data
dir. Defaults to a name derived from module: the file’s basename, or —
for a generic basename like plugin.js/index.js — its parent directory
(relay/plugin.js → relay), so the conventional <name>/plugin.js
layout yields distinct ids with none set. Set it explicitly only if two
specifiers still reduce to the same name.activate(api) receives: api.fastify (register routes here),
api.prefix, api.config, api.log, api.auth.getAgent(request)
(identity, as above), api.storage.pluginDir() (a private server-side
directory under the data root, never served over HTTP),
api.serverInfo() → { baseUrl, protocol, host, port, listening } (the
server’s own origin, for minting absolute URLs and loopback calls — call
it lazily, e.g. per request: with port: 0 the real port exists only once
the server is listening, and an explicit idpIssuer wins as baseUrl),
api.plugins → [{ id, prefix, module }] for every loaded entry (a
read-only, frozen boot-time snapshot, so a plugin can enumerate its
co-loaded siblings instead of being handed a copy of the operator’s
plugins array — it includes the plugin itself, so consumers filter),
and api.ws.route(path, (socket, request) => {}) for WebSocket endpoints —
routed through the same upgrade path as the built-in realtime features, so
plugins never attach their own 'upgrade' listener. Return
{ deactivate } to run teardown (state saves, timers) on server close.
A plugin whose protocol pins absolute paths outside its prefix can
claim them with api.reservePath(path):
api.reservePath('/xrpc', { methods: ['GET', 'POST'] }); // fixed root — subtree, methods opted in
api.reservePath('/:user/did.json'); // pinned document — exact shape, read-only
Both kinds are read-only by default (GET/HEAD/OPTIONS; widen with
{ methods: [...] }) — a reserved path is WAC-exempt and the LDP write
wildcards sit beneath it, so exempting a write method the plugin hasn’t
implemented would let that write fall through to storage unauthenticated.
Literal reservations claim their whole subtree; parameterized reservations
(:name matches one segment) match only the exact path shape, since they
exist for pinned documents inside the pod’s WAC-governed namespace. Claims
are cross-plugin: a second plugin reserving the same path fails the boot
naming both claimants. Registering routes on the reserved paths remains the
plugin’s job via api.fastify.
To mount an existing node-style app — a (req, res) handler, a reverse
proxy, or a framework adapter — use api.mountApp(handler, { prefix }):
export async function activate(api) {
const app = createMyNodeApp();
await api.mountApp((req, res) => app.handle(req, res));
}
mountApp bundles the four things a wrapped-app plugin needs and would
otherwise rediscover: the appPaths WAC exemption, a scoped pass-through
content parser (so the wrapped app receives an unconsumed body stream
instead of one Fastify already drained — the failure that hangs any
body-reading app), reply.hijack() so Fastify releases the response, and
registration on both the bare prefix and its subtree. It defaults to the
entry’s prefix; pass { prefix } to mount a second app elsewhere (that
prefix is WAC-exempted too).
A plugin that fails to import or activate fails listen() loudly rather
than booting a server silently missing an app.
The simple module + prefix case also works straight from the CLI, no config file needed (#594):
jss start --root ./data --public \
--plugin './chat/plugin.js@/chat' \
--plugin '@scope/pkg/plugin.js@/app'
--plugin is repeatable; the prefix separator is the last @ followed by
/, so scoped package names parse unambiguously. CLI entries append to
any plugins array from the config file (they don’t replace it). Per-plugin
config objects and explicit ids remain config-file-only.
Limit storage per pod to prevent abuse and manage resources:
jss start --default-quota 50MB
# Set quota for a user (overrides default)
jss quota set alice 100MB
# Show quota info
jss quota show alice
# alice:
# Used: 12.5 MB
# Limit: 100 MB
# Free: 87.5 MB
# Usage: 12%
# Recalculate from actual disk usage
jss quota reconcile alice
/{pod}/.quota.jsonreconcile to fix quota drift from manual file changesSupported formats: 50MB, 1GB, 500KB, 1TB
Enable the SolidOS Mashlib data browser for RDF resources. Two modes are available:
CDN Mode (recommended for getting started):
jss start --mashlib-cdn --conneg
Loads mashlib from unpkg.com CDN. Zero footprint - no local files needed.
Local Mode (for production/offline):
jss start --mashlib --conneg
Serves mashlib from src/mashlib-local/dist/. Requires building mashlib locally:
cd src/mashlib-local
npm install && npm run build
ES Module Mode (for custom or next-gen mashlib builds):
jss start --mashlib-module https://example.com/mashlib.js
Loads an ES module-based data browser from any URL. Uses <script type="module"> and <div id="mashlib"> (self-initializing). CSS is auto-derived by replacing .js with .css. Content negotiation is auto-enabled.
How it works:
/alice/public/data.ttl with Accept: text/htmlNote: Mashlib works best with --conneg enabled for Turtle support.
Modern UI (SolidOS UI):
jss start --mashlib --solidos-ui --conneg
Serves a modern Nextcloud-style UI shell while reusing mashlib’s data layer. The --solidos-ui flag swaps the classic databrowser interface for a cleaner, mobile-friendly design with:
Requires solidos-ui dist files in src/mashlib-local/dist/solidos-ui/. See solidos-ui for details.
Pod profiles (/alice/) use HTML with embedded JSON-LD data islands and are rendered using:
getPod() fix for path-based podsThis allows profiles to work without server-side content negotiation while still providing full SolidOS editing capabilities.
Enable real-time notifications for resource changes:
const server = createServer({ notifications: true });
Clients discover the WebSocket URL via the Updates-Via header:
curl -I http://localhost:4443/alice/public/
# Updates-Via: ws://localhost:4443/.notifications
Protocol (solid-0.1, compatible with SolidOS):
Server: protocol solid-0.1
Client: sub http://localhost:4443/alice/public/data.json
Server: ack http://localhost:4443/alice/public/data.json
Server: pub http://localhost:4443/alice/public/data.json (on change)