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:
- Stores everything as JSON-LD - No RDF parsing overhead for standard operations
- Serves JSON-LD by default - Modern web applications can consume responses directly
- Content negotiation is optional - Enable Turtle support with
{ conneg: true }when needed - Fast by design - Skip the RDF parsing tax when you don't need it
- Performance: JSON parsing is native to JavaScript - no external RDF libraries needed for basic operations
- Simplicity: JSON-LD is valid JSON - works with any JSON tooling
- Web-native: Browsers and web apps understand JSON natively
- Semantic web ready: JSON-LD is a W3C standard RDF serialization
Enable conneg: true when:
- Interoperating with Turtle-based Solid apps
- Serving data to legacy Solid clients
- Running conformance tests that require Turtle support
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 --connegLoads mashlib from unpkg.com CDN. Zero footprint - no local files needed.
Local Mode (for production/offline):
jss start --mashlib --connegServes mashlib from src/mashlib-local/dist/. Requires building mashlib locally:
cd src/mashlib-local
npm install && npm run buildES Module Mode (for custom or next-gen mashlib builds):
jss start --mashlib-module https://example.com/mashlib.jsLoads 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:
- Browser requests
/alice/public/data.ttlwithAccept: text/html - Server returns Mashlib HTML wrapper
- Mashlib fetches the actual data via content negotiation
- Mashlib renders an interactive, editable view
Note: Mashlib works best with --conneg enabled for Turtle support.
Modern UI (SolidOS UI):
jss start --mashlib --solidos-ui --connegServes 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:
- Modern file browser with breadcrumb navigation
- Profile, Contacts, Sharing, and Settings views
- Path-based URLs (browser URL reflects current resource)
- Responsive design for mobile devices
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:
- mashlib-jss - A fork of mashlib with
getPod()fix for path-based pods - solidos-lite - Parses JSON-LD data islands into the RDF store
This 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/.notificationsProtocol (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 startCreate 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 --idpFeatures:
- Pod auto-created on first startup with full structure (inbox, public, private, profile)
- IDP account auto-seeded so the operator can log in immediately
- Registration endpoint disabled (returns 403)
- Login works for the single user via password (
POST /idp/credentials) or any other configured method - Proper ACLs generated automatically
Upgrading 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:
-
Keep the legacy layout: add
--single-user-name meto 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 plainmv <root>/me/* <root>/skips them) to<root>/, delete the IDP account forme(so the new root pod'smeaccount 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_PASSWORDenv var- Interactive no-echo prompt (TTY only)
- None — the server starts and warns; the pod is created but isn't loggable until you restart with
--single-user-password <pw>, setJSS_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:
- The registration page shows an "Invite Code" field
- Invalid or expired codes are rejected with an error
- Each use decrements the invite's remaining uses
- Depleted invites are automatically removed
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 anonymousSee #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 exportsactivate(api), called during startup.prefix— the app's mount point. Added toappPathsautomatically, so the app owns authentication below it (see the section above). Must start with/; invalid prefixes fail startup.config— passed to the plugin verbatim asapi.config.id— optional stable identifier that names the plugin's private data dir. Defaults to a name derived frommodule: the file's basename, or — for a generic basename likeplugin.js/index.js— its parent directory (relay/plugin.js→relay), so the conventional<name>/plugin.jslayout 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-onlyBoth 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- Quotas are tracked incrementally on PUT, POST, and DELETE operations
- When quota is exceeded, the server returns HTTP 507 Insufficient Storage
- Each pod stores its quota in
/{pod}/.quota.json - Use
reconcileto fix quota drift from manual file changes
Supported 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 --connegLoads mashlib from unpkg.com CDN. Zero footprint - no local files needed.
Local Mode (for production/offline):
jss start --mashlib --connegServes mashlib from src/mashlib-local/dist/. Requires building mashlib locally:
cd src/mashlib-local
npm install && npm run buildES Module Mode (for custom or next-gen mashlib builds):
jss start --mashlib-module https://example.com/mashlib.jsLoads 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:
- Browser requests
/alice/public/data.ttlwithAccept: text/html - Server returns Mashlib HTML wrapper
- Mashlib fetches the actual data via content negotiation
- Mashlib renders an interactive, editable view
Note: Mashlib works best with --conneg enabled for Turtle support.
Modern UI (SolidOS UI):
jss start --mashlib --solidos-ui --connegServes 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:
- Modern file browser with breadcrumb navigation
- Profile, Contacts, Sharing, and Settings views
- Path-based URLs (browser URL reflects current resource)
- Responsive design for mobile devices
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:
- mashlib-jss - A fork of mashlib with
getPod()fix for path-based pods - solidos-lite - Parses JSON-LD data islands into the RDF store
This 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/.notificationsProtocol (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)