From 338f0ae1e571395e8a50d83cbd6df79e8a14ff81 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 10 Mar 2026 06:18:55 +0100 Subject: [PATCH 1/7] Add remoteStorage protocol support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements draft-dejong-remotestorage-22 on top of existing storage infrastructure. No new dependencies — reuses filesystem storage, OAuth flow (#161), and WebFinger. Endpoints: - GET /storage/:user/* — read file or folder (RS JSON-LD listing) - HEAD /storage/:user/* — metadata only - PUT /storage/:user/* — write file (with If-Match/If-None-Match) - DELETE /storage/:user/* — delete file (with If-Match) Features: - Public folder (/storage/:user/public/*) readable without auth - Conditional requests (ETags, If-Match, If-None-Match) - WebFinger discovery (RS link relation added to existing response) - Bearer token auth via existing OAuth flow - Always on — no flag needed Refs #106 --- src/ap/index.js | 11 ++ src/remotestorage.js | 234 +++++++++++++++++++++++++++++++++++++++++++ src/server.js | 8 ++ 3 files changed, 253 insertions(+) create mode 100644 src/remotestorage.js diff --git a/src/ap/index.js b/src/ap/index.js index 60756c2..971605e 100644 --- a/src/ap/index.js +++ b/src/ap/index.js @@ -108,6 +108,17 @@ export async function activityPubPlugin(fastify, options = {}) { { profileUrl } ) + // Add remoteStorage link relation + response.links.push({ + rel: 'http://tools.ietf.org/id/draft-dejong-remotestorage', + href: `${baseUrl}/storage/${config.username}`, + properties: { + 'http://remotestorage.io/spec/version': 'draft-dejong-remotestorage-22', + 'http://tools.ietf.org/html/rfc6749#section-4.2': `${baseUrl}/oauth/authorize`, + 'http://tools.ietf.org/html/rfc6750#section-2.3': 'Bearer' + } + }) + return reply .header('Content-Type', 'application/jrd+json') .header('Access-Control-Allow-Origin', '*') diff --git a/src/remotestorage.js b/src/remotestorage.js new file mode 100644 index 0000000..54402c1 --- /dev/null +++ b/src/remotestorage.js @@ -0,0 +1,234 @@ +/** + * remoteStorage plugin for JSS + * Implements draft-dejong-remotestorage protocol on top of existing storage + * + * No new dependencies — reuses filesystem storage, OAuth, and WebFinger. + * Always on — no flag needed. + * + * Ref: https://remotestorage.io/spec/draft-dejong-remotestorage-22 + * Related: #106, #160 (OAuth), #159 (Mastodon API) + */ + +import * as storage from './storage/filesystem.js' +import { getContentType } from './utils/url.js' +import { getWebIdFromRequestAsync } from './auth/token.js' + +/** + * remoteStorage Fastify plugin + * @param {FastifyInstance} fastify + * @param {object} options + * @param {string} options.username - Storage owner username + * @param {string} options.ownerWebId - WebID of the storage owner + */ +export async function remoteStoragePlugin (fastify, options = {}) { + const username = options.username || 'me' + const ownerWebId = options.ownerWebId || null + + /** + * Extract the storage path from the URL + * /storage/me/photos/vacation.jpg → /photos/vacation.jpg + */ + function getStoragePath (request) { + const wildcard = request.params['*'] || '' + return '/' + wildcard + } + + /** + * Check if request is authorized for the given method + * Public folder is readable without auth + */ + async function checkAuth (request, method) { + const storagePath = getStoragePath(request) + + // Public folder: readable without auth + if (storagePath.startsWith('/public/') && (method === 'GET' || method === 'HEAD')) { + return { authorized: true, webId: null } + } + + const { webId, error } = await getWebIdFromRequestAsync(request) + if (!webId) { + return { authorized: false, webId: null, error: error || 'Unauthorized' } + } + + // If ownerWebId is set, only the owner can access storage + if (ownerWebId && webId !== ownerWebId) { + return { authorized: false, webId, error: 'Forbidden' } + } + + return { authorized: true, webId } + } + + // GET /storage/:user/* — read file or folder + fastify.get('/storage/:user/*', async (request, reply) => { + const storagePath = getStoragePath(request) + + const { authorized, error } = await checkAuth(request, 'GET') + if (!authorized) { + return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error }) + } + + const info = await storage.stat(storagePath) + if (!info) { + return reply.code(404).send({ error: 'Not found' }) + } + + // Conditional GET + const ifNoneMatch = request.headers['if-none-match'] + if (ifNoneMatch && ifNoneMatch === info.etag) { + return reply.code(304).send() + } + + // Directory listing + if (info.isDirectory) { + const entries = await storage.listContainer(storagePath) + if (!entries) { + return reply.code(404).send({ error: 'Not found' }) + } + + const items = {} + for (const entry of entries) { + // Skip hidden files (ACLs, metadata) + if (entry.name.startsWith('.')) continue + + const childPath = storagePath.endsWith('/') ? storagePath + entry.name : storagePath + '/' + entry.name + const childStat = await storage.stat(entry.isDirectory ? childPath + '/' : childPath) + + if (entry.isDirectory) { + items[entry.name + '/'] = { + ETag: childStat?.etag?.replace(/"/g, '') || '' + } + } else { + items[entry.name] = { + ETag: childStat?.etag?.replace(/"/g, '') || '', + 'Content-Type': getContentType(entry.name), + 'Content-Length': childStat?.size || 0 + } + } + } + + return reply + .header('Content-Type', 'application/ld+json') + .header('ETag', info.etag) + .header('Cache-Control', 'no-cache') + .send({ + '@context': 'http://remotestorage.io/spec/folder-description', + items + }) + } + + // File + const content = await storage.read(storagePath) + if (content === null) { + return reply.code(404).send({ error: 'Not found' }) + } + + return reply + .header('Content-Type', getContentType(storagePath)) + .header('Content-Length', content.length) + .header('ETag', info.etag) + .header('Cache-Control', 'no-cache') + .send(content) + }) + + // HEAD /storage/:user/* — metadata only + fastify.head('/storage/:user/*', async (request, reply) => { + const storagePath = getStoragePath(request) + + const { authorized, error } = await checkAuth(request, 'HEAD') + if (!authorized) { + return reply.code(401).header('WWW-Authenticate', 'Bearer').send() + } + + const info = await storage.stat(storagePath) + if (!info) { + return reply.code(404).send() + } + + reply + .header('Content-Type', info.isDirectory ? 'application/ld+json' : getContentType(storagePath)) + .header('ETag', info.etag) + .header('Cache-Control', 'no-cache') + + if (!info.isDirectory) { + reply.header('Content-Length', info.size) + } + + return reply.code(200).send() + }) + + // PUT /storage/:user/* — write file + fastify.put('/storage/:user/*', async (request, reply) => { + const storagePath = getStoragePath(request) + + const { authorized, error } = await checkAuth(request, 'PUT') + if (!authorized) { + return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error }) + } + + // Directories end with / — can't PUT to a directory + if (storagePath.endsWith('/')) { + return reply.code(400).send({ error: 'Cannot PUT to a folder path' }) + } + + // Conditional write + const ifMatch = request.headers['if-match'] + const ifNoneMatch = request.headers['if-none-match'] + const existing = await storage.stat(storagePath) + + if (ifMatch && (!existing || existing.etag !== ifMatch)) { + return reply.code(412).send({ error: 'Precondition failed' }) + } + if (ifNoneMatch === '*' && existing) { + return reply.code(412).send({ error: 'Resource already exists' }) + } + + const content = Buffer.isBuffer(request.body) ? request.body : Buffer.from(request.body || '') + const success = await storage.write(storagePath, content) + if (!success) { + return reply.code(500).send({ error: 'Write failed' }) + } + + const newStat = await storage.stat(storagePath) + const statusCode = existing ? 200 : 201 + + return reply + .code(statusCode) + .header('ETag', newStat?.etag || '') + .send() + }) + + // DELETE /storage/:user/* — delete file + fastify.delete('/storage/:user/*', async (request, reply) => { + const storagePath = getStoragePath(request) + + const { authorized, error } = await checkAuth(request, 'DELETE') + if (!authorized) { + return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error }) + } + + const existing = await storage.stat(storagePath) + if (!existing) { + return reply.code(404).send({ error: 'Not found' }) + } + + // Conditional delete + const ifMatch = request.headers['if-match'] + if (ifMatch && existing.etag !== ifMatch) { + return reply.code(412).send({ error: 'Precondition failed' }) + } + + const success = await storage.remove(storagePath) + if (!success) { + return reply.code(500).send({ error: 'Delete failed' }) + } + + return reply + .code(200) + .header('ETag', existing.etag) + .send() + }) + + fastify.log.info(`remoteStorage enabled for user: ${username}`) +} + +export default remoteStoragePlugin diff --git a/src/server.js b/src/server.js index e1d7748..0343e13 100644 --- a/src/server.js +++ b/src/server.js @@ -15,6 +15,7 @@ import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js' import { AccessMode } from './wac/parser.js'; import { registerNostrRelay } from './nostr/relay.js'; import { activityPubPlugin, getActorHandler } from './ap/index.js'; +import { remoteStoragePlugin } from './remotestorage.js'; import { dbPlugin } from './db/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -234,6 +235,12 @@ export function createServer(options = {}) { }); } + // Register remoteStorage plugin (always on — no flag needed) + fastify.register(remoteStoragePlugin, { + username: apUsername || 'me', + ownerWebId: singleUser ? null : undefined // single-user: any authenticated user; multi-user: check WebID + }); + // Register MongoDB /db/ route if enabled if (mongoEnabled) { fastify.register(dbPlugin, { mongoUrl, mongoDatabase, singleUser }); @@ -370,6 +377,7 @@ export function createServer(options = {}) { (gitEnabled && isGitRequest(request.url)) || (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) || isProfileAP || + request.url.startsWith('/storage/') || (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) || mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) { return; From f5e1fcc688f8a1c80a5581c6eb4aac2ddcb31532 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 10 Mar 2026 06:30:41 +0100 Subject: [PATCH 2/7] Fix Copilot review: harden remoteStorage security and consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enforce username match (request.params.user === configured username) - Return 403 (not 401) when authenticated but forbidden - Use shared conditional utilities (checkIfMatch, checkIfNoneMatchForGet/Write) - Block dotfile access (.acl, .meta, etc.) — security fix - Use createReadStream() for file reads instead of buffering entire files - Add conditional request handling to HEAD - Respect readOnly mode for PUT/DELETE - Fix WebFinger RS href trailing slash --- src/ap/index.js | 2 +- src/remotestorage.js | 133 +++++++++++++++++++++++++++++++++---------- 2 files changed, 104 insertions(+), 31 deletions(-) diff --git a/src/ap/index.js b/src/ap/index.js index 971605e..7769378 100644 --- a/src/ap/index.js +++ b/src/ap/index.js @@ -111,7 +111,7 @@ export async function activityPubPlugin(fastify, options = {}) { // Add remoteStorage link relation response.links.push({ rel: 'http://tools.ietf.org/id/draft-dejong-remotestorage', - href: `${baseUrl}/storage/${config.username}`, + href: `${baseUrl}/storage/${config.username}/`, properties: { 'http://remotestorage.io/spec/version': 'draft-dejong-remotestorage-22', 'http://tools.ietf.org/html/rfc6749#section-4.2': `${baseUrl}/oauth/authorize`, diff --git a/src/remotestorage.js b/src/remotestorage.js index 54402c1..93480da 100644 --- a/src/remotestorage.js +++ b/src/remotestorage.js @@ -12,6 +12,10 @@ import * as storage from './storage/filesystem.js' import { getContentType } from './utils/url.js' import { getWebIdFromRequestAsync } from './auth/token.js' +import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from './utils/conditional.js' + +// Dotfiles that must never be exposed via remoteStorage +const BLOCKED_NAMES = new Set(['.acl', '.meta', '.pods']) /** * remoteStorage Fastify plugin @@ -33,6 +37,25 @@ export async function remoteStoragePlugin (fastify, options = {}) { return '/' + wildcard } + /** + * Check if the :user param matches the configured username + */ + function checkUsername (request, reply) { + if (request.params.user !== username) { + reply.code(404).send({ error: 'Unknown user' }) + return false + } + return true + } + + /** + * Check if any path segment is a blocked dotfile + */ + function hasDotfile (storagePath) { + const segments = storagePath.split('/') + return segments.some(s => s.startsWith('.') && s.length > 1) + } + /** * Check if request is authorized for the given method * Public folder is readable without auth @@ -47,12 +70,12 @@ export async function remoteStoragePlugin (fastify, options = {}) { const { webId, error } = await getWebIdFromRequestAsync(request) if (!webId) { - return { authorized: false, webId: null, error: error || 'Unauthorized' } + return { authorized: false, webId: null, error: error || 'Unauthorized', status: 401 } } // If ownerWebId is set, only the owner can access storage if (ownerWebId && webId !== ownerWebId) { - return { authorized: false, webId, error: 'Forbidden' } + return { authorized: false, webId, error: 'Forbidden', status: 403 } } return { authorized: true, webId } @@ -60,11 +83,20 @@ export async function remoteStoragePlugin (fastify, options = {}) { // GET /storage/:user/* — read file or folder fastify.get('/storage/:user/*', async (request, reply) => { + if (!checkUsername(request, reply)) return + const storagePath = getStoragePath(request) - const { authorized, error } = await checkAuth(request, 'GET') + // Block dotfile access + if (hasDotfile(storagePath)) { + return reply.code(404).send({ error: 'Not found' }) + } + + const { authorized, error, status } = await checkAuth(request, 'GET') if (!authorized) { - return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error }) + const code = status || 401 + if (code === 401) reply.header('WWW-Authenticate', 'Bearer') + return reply.code(code).send({ error }) } const info = await storage.stat(storagePath) @@ -72,9 +104,9 @@ export async function remoteStoragePlugin (fastify, options = {}) { return reply.code(404).send({ error: 'Not found' }) } - // Conditional GET - const ifNoneMatch = request.headers['if-none-match'] - if (ifNoneMatch && ifNoneMatch === info.etag) { + // Conditional GET — use shared utility + const cond = checkIfNoneMatchForGet(request.headers['if-none-match'], info.etag) + if (!cond.ok) { return reply.code(304).send() } @@ -87,7 +119,7 @@ export async function remoteStoragePlugin (fastify, options = {}) { const items = {} for (const entry of entries) { - // Skip hidden files (ACLs, metadata) + // Skip dotfiles (ACLs, metadata, etc.) if (entry.name.startsWith('.')) continue const childPath = storagePath.endsWith('/') ? storagePath + entry.name : storagePath + '/' + entry.name @@ -116,27 +148,35 @@ export async function remoteStoragePlugin (fastify, options = {}) { }) } - // File - const content = await storage.read(storagePath) - if (content === null) { + // File — stream instead of buffering + const result = storage.createReadStream(storagePath) + if (!result) { return reply.code(404).send({ error: 'Not found' }) } return reply .header('Content-Type', getContentType(storagePath)) - .header('Content-Length', content.length) + .header('Content-Length', info.size) .header('ETag', info.etag) .header('Cache-Control', 'no-cache') - .send(content) + .send(result.stream) }) // HEAD /storage/:user/* — metadata only fastify.head('/storage/:user/*', async (request, reply) => { + if (!checkUsername(request, reply)) return + const storagePath = getStoragePath(request) - const { authorized, error } = await checkAuth(request, 'HEAD') + if (hasDotfile(storagePath)) { + return reply.code(404).send() + } + + const { authorized, error, status } = await checkAuth(request, 'HEAD') if (!authorized) { - return reply.code(401).header('WWW-Authenticate', 'Bearer').send() + const code = status || 401 + if (code === 401) reply.header('WWW-Authenticate', 'Bearer') + return reply.code(code).send() } const info = await storage.stat(storagePath) @@ -144,6 +184,12 @@ export async function remoteStoragePlugin (fastify, options = {}) { return reply.code(404).send() } + // Conditional HEAD + const cond = checkIfNoneMatchForGet(request.headers['if-none-match'], info.etag) + if (!cond.ok) { + return reply.code(304).send() + } + reply .header('Content-Type', info.isDirectory ? 'application/ld+json' : getContentType(storagePath)) .header('ETag', info.etag) @@ -158,11 +204,24 @@ export async function remoteStoragePlugin (fastify, options = {}) { // PUT /storage/:user/* — write file fastify.put('/storage/:user/*', async (request, reply) => { + if (!checkUsername(request, reply)) return + + // Respect readOnly mode + if (request.config?.readOnly) { + return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' }) + } + const storagePath = getStoragePath(request) - const { authorized, error } = await checkAuth(request, 'PUT') + if (hasDotfile(storagePath)) { + return reply.code(403).send({ error: 'Cannot write to dotfiles' }) + } + + const { authorized, error, status } = await checkAuth(request, 'PUT') if (!authorized) { - return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error }) + const code = status || 401 + if (code === 401) reply.header('WWW-Authenticate', 'Bearer') + return reply.code(code).send({ error }) } // Directories end with / — can't PUT to a directory @@ -170,16 +229,17 @@ export async function remoteStoragePlugin (fastify, options = {}) { return reply.code(400).send({ error: 'Cannot PUT to a folder path' }) } - // Conditional write - const ifMatch = request.headers['if-match'] - const ifNoneMatch = request.headers['if-none-match'] + // Conditional write — use shared utilities const existing = await storage.stat(storagePath) - if (ifMatch && (!existing || existing.etag !== ifMatch)) { - return reply.code(412).send({ error: 'Precondition failed' }) + const ifMatchResult = checkIfMatch(request.headers['if-match'], existing?.etag || null) + if (!ifMatchResult.ok) { + return reply.code(ifMatchResult.status).send({ error: ifMatchResult.error }) } - if (ifNoneMatch === '*' && existing) { - return reply.code(412).send({ error: 'Resource already exists' }) + + const ifNoneMatchResult = checkIfNoneMatchForWrite(request.headers['if-none-match'], existing?.etag || null) + if (!ifNoneMatchResult.ok) { + return reply.code(ifNoneMatchResult.status).send({ error: ifNoneMatchResult.error }) } const content = Buffer.isBuffer(request.body) ? request.body : Buffer.from(request.body || '') @@ -199,11 +259,24 @@ export async function remoteStoragePlugin (fastify, options = {}) { // DELETE /storage/:user/* — delete file fastify.delete('/storage/:user/*', async (request, reply) => { + if (!checkUsername(request, reply)) return + + // Respect readOnly mode + if (request.config?.readOnly) { + return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' }) + } + const storagePath = getStoragePath(request) - const { authorized, error } = await checkAuth(request, 'DELETE') + if (hasDotfile(storagePath)) { + return reply.code(403).send({ error: 'Cannot delete dotfiles' }) + } + + const { authorized, error, status } = await checkAuth(request, 'DELETE') if (!authorized) { - return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error }) + const code = status || 401 + if (code === 401) reply.header('WWW-Authenticate', 'Bearer') + return reply.code(code).send({ error }) } const existing = await storage.stat(storagePath) @@ -211,10 +284,10 @@ export async function remoteStoragePlugin (fastify, options = {}) { return reply.code(404).send({ error: 'Not found' }) } - // Conditional delete - const ifMatch = request.headers['if-match'] - if (ifMatch && existing.etag !== ifMatch) { - return reply.code(412).send({ error: 'Precondition failed' }) + // Conditional delete — use shared utility + const ifMatchResult = checkIfMatch(request.headers['if-match'], existing.etag) + if (!ifMatchResult.ok) { + return reply.code(ifMatchResult.status).send({ error: ifMatchResult.error }) } const success = await storage.remove(storagePath) From aff948af9a691f98b522f93f26282b2c9b8083cf Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 10 Mar 2026 06:33:17 +0100 Subject: [PATCH 3/7] Add Mastodon API, OAuth 2.0, and remoteStorage docs to README Document the three new features from PRs #159, #161, #162: - Mastodon-compatible API endpoints and OAuth 2.0 flow - remoteStorage protocol (always on, no flag needed) - Updated project structure with new files --- README.md | 104 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f7668d..dcf48b2 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ A minimal, fast, JSON-LD native Solid server. - **HTTP Range Requests** - Partial content delivery for large files and media streaming - **Single-User Mode** - Simplified setup for personal pod servers - **ActivityPub Federation** - Fediverse integration with WebFinger, inbox/outbox, HTTP signatures +- **Mastodon-compatible API** - Dynamic client registration, instance info, account verification +- **OAuth 2.0 Authorization** - Shared auth flow for Mastodon clients, remoteStorage apps, and third-party panes +- **remoteStorage Protocol** - [draft-dejong-remotestorage-22](https://remotestorage.io/spec/) file sync — always on, no flag needed - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD - **N3 Patch** - Solid's native patch format for RDF updates - **SPARQL Update** - Standard SPARQL UPDATE protocol for PATCH @@ -554,6 +557,102 @@ curl -H "Accept: application/activity+json" http://localhost:3000/profile/card curl http://localhost:3000/.well-known/nodeinfo/2.1 ``` +## Mastodon-compatible API + +JSS exposes Mastodon API endpoints so that Mastodon clients (Elk, Phanpy, Ice Cubes) can connect: + +```bash +jss start --activitypub --idp +``` + +### Endpoints + +| Endpoint | Description | +|----------|-------------| +| `POST /api/v1/apps` | Dynamic client registration | +| `GET /api/v1/accounts/verify_credentials` | Current user profile | +| `GET /api/v1/instance` | Instance metadata | +| `GET /oauth/authorize` | OAuth authorize page | +| `POST /oauth/authorize` | Process login | +| `POST /oauth/token` | Exchange code for Bearer token | + +### OAuth 2.0 Flow + +The OAuth layer is shared between Mastodon clients, remoteStorage apps, and third-party Solid panes: + +1. Client registers via `POST /api/v1/apps` (gets `client_id` + `client_secret`) +2. Client redirects user to `GET /oauth/authorize?client_id=...&redirect_uri=...&response_type=code` +3. User logs in, JSS redirects back with `?code=...` +4. Client exchanges code for Bearer token via `POST /oauth/token` +5. Bearer token works with all JSS endpoints (Solid, ActivityPub, remoteStorage) + +Supports out-of-band (OOB) redirect for CLI/desktop clients. + +### Testing + +```bash +# Register a client +curl -X POST http://localhost:3000/api/v1/apps \ + -H "Content-Type: application/json" \ + -d '{"client_name": "Test App", "redirect_uris": "urn:ietf:wg:oauth:2.0:oob"}' + +# Check instance info +curl http://localhost:3000/api/v1/instance +``` + +## remoteStorage + +JSS implements the [remoteStorage protocol](https://remotestorage.io/spec/draft-dejong-remotestorage-22) — always on, no flag needed. Any remoteStorage-compatible app can store and sync data on your pod. + +### Discovery + +remoteStorage clients discover the storage endpoint via WebFinger: + +```bash +curl "http://localhost:3000/.well-known/webfinger?resource=acct:me@localhost:3000" +``` + +The response includes a `remotestorage` link relation pointing to `/storage/me/`. + +### Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/storage/:user/*` | Read file or list folder (JSON-LD) | +| `HEAD` | `/storage/:user/*` | Get metadata (ETag, Content-Type, size) | +| `PUT` | `/storage/:user/*` | Write file (creates parent folders) | +| `DELETE` | `/storage/:user/*` | Delete file | + +### How It Works + +- **Auth**: Bearer token via OAuth 2.0 (same flow as Mastodon clients) +- **Public folder**: `/storage/me/public/*` is readable without auth +- **Conditional requests**: If-Match, If-None-Match (uses shared ETag utilities) +- **Dotfile protection**: `.acl`, `.meta`, and other dotfiles are blocked +- **Read-only mode**: Respects `--read-only` flag +- **Streaming**: Large files are streamed, not buffered + +### Testing + +```bash +# Write a file (needs Bearer token from OAuth flow) +curl -X PUT http://localhost:3000/storage/me/documents/hello.txt \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: text/plain" \ + -d "Hello, remoteStorage!" + +# Read it back +curl -H "Authorization: Bearer YOUR_TOKEN" \ + http://localhost:3000/storage/me/documents/hello.txt + +# List a folder +curl -H "Authorization: Bearer YOUR_TOKEN" \ + http://localhost:3000/storage/me/documents/ + +# Read from public folder (no auth needed) +curl http://localhost:3000/storage/me/public/readme.txt +``` + ### Linking Nostr to WebID (did:nostr) Bridge your Nostr identity to a Solid WebID for seamless authentication: @@ -1097,7 +1196,10 @@ src/ │ ├── actor.js # Actor JSON-LD │ ├── inbox.js # Receive activities │ ├── outbox.js # User's activities -│ └── collections.js # Followers/following +│ ├── collections.js # Followers/following +│ ├── mastodon.js # Mastodon API (apps, instance, verify_credentials) +│ └── oauth.js # OAuth 2.0 authorize/token flow +├── remotestorage.js # remoteStorage protocol (draft-dejong-remotestorage-22) ├── rdf/ │ ├── turtle.js # Turtle <-> JSON-LD │ └── conneg.js # Content negotiation From 7548004035bf6c9e1c4fabcd19bc4dc7022ac27c Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 10 Mar 2026 06:33:57 +0100 Subject: [PATCH 4/7] Bump version to 0.0.97 Adds Mastodon API, OAuth 2.0, and remoteStorage protocol. Also fixes stale 0.0.67 version strings in nodeinfo and instance endpoint. --- package.json | 2 +- src/ap/index.js | 2 +- src/ap/routes/mastodon.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 2c8782a..da270ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "javascript-solid-server", - "version": "0.0.96", + "version": "0.0.97", "description": "A minimal, fast Solid server", "main": "src/index.js", "type": "module", diff --git a/src/ap/index.js b/src/ap/index.js index 7769378..cb14fb1 100644 --- a/src/ap/index.js +++ b/src/ap/index.js @@ -148,7 +148,7 @@ export async function activityPubPlugin(fastify, options = {}) { version: '2.1', software: { name: 'jss', - version: '0.0.67', + version: '0.0.97', repository: 'https://github.com/JavaScriptSolidServer/JavaScriptSolidServer' }, protocols: ['activitypub', 'solid'], diff --git a/src/ap/routes/mastodon.js b/src/ap/routes/mastodon.js index 5289404..4bcf9d2 100644 --- a/src/ap/routes/mastodon.js +++ b/src/ap/routes/mastodon.js @@ -115,7 +115,7 @@ export function createInstanceHandler (config) { title: config.displayName || 'JSS', description: 'SAND Stack: Solid + ActivityPub + Nostr + DID', short_description: 'Solid pod with Mastodon-compatible API', - version: '4.0.0 (compatible; JSS 0.0.67)', + version: '4.0.0 (compatible; JSS 0.0.97)', urls: { streaming_api: `${wsProtocol}://${host}` }, From e704adc23f8ea0555434794750863fbb4d1f4afb Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 10 Mar 2026 06:37:33 +0100 Subject: [PATCH 5/7] Wire remoteStorage username to singleUserName, not apUsername The RS plugin username should match the pod username, not the ActivityPub username which may differ or be unset. --- src/server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server.js b/src/server.js index 0343e13..78aee3a 100644 --- a/src/server.js +++ b/src/server.js @@ -237,8 +237,8 @@ export function createServer(options = {}) { // Register remoteStorage plugin (always on — no flag needed) fastify.register(remoteStoragePlugin, { - username: apUsername || 'me', - ownerWebId: singleUser ? null : undefined // single-user: any authenticated user; multi-user: check WebID + username: singleUserName || 'me', + ownerWebId: null // single-user: any authenticated user can access }); // Register MongoDB /db/ route if enabled From 33e069b04f2d3ddb3398b9ee8dcce66feb4653dc Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 10 Mar 2026 06:50:45 +0100 Subject: [PATCH 6/7] Remove unused BLOCKED_NAMES constant --- src/remotestorage.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/remotestorage.js b/src/remotestorage.js index 93480da..0da4d88 100644 --- a/src/remotestorage.js +++ b/src/remotestorage.js @@ -14,9 +14,6 @@ import { getContentType } from './utils/url.js' import { getWebIdFromRequestAsync } from './auth/token.js' import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from './utils/conditional.js' -// Dotfiles that must never be exposed via remoteStorage -const BLOCKED_NAMES = new Set(['.acl', '.meta', '.pods']) - /** * remoteStorage Fastify plugin * @param {FastifyInstance} fastify From a2899653bb1c134941efbc5f85ecbdf8e7ea16c6 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 10 Mar 2026 06:51:56 +0100 Subject: [PATCH 7/7] Update docs: RS requires --activitypub for discovery and OAuth Storage routes are always on, but WebFinger and OAuth are currently inside the AP plugin. Tracked in #164 for standalone extraction. --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dcf48b2..c4c547c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A minimal, fast, JSON-LD native Solid server. - **ActivityPub Federation** - Fediverse integration with WebFinger, inbox/outbox, HTTP signatures - **Mastodon-compatible API** - Dynamic client registration, instance info, account verification - **OAuth 2.0 Authorization** - Shared auth flow for Mastodon clients, remoteStorage apps, and third-party panes -- **remoteStorage Protocol** - [draft-dejong-remotestorage-22](https://remotestorage.io/spec/) file sync — always on, no flag needed +- **remoteStorage Protocol** - [draft-dejong-remotestorage-22](https://remotestorage.io/spec/) file sync (requires `--activitypub` for WebFinger discovery + OAuth) - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD - **N3 Patch** - Solid's native patch format for RDF updates - **SPARQL Update** - Standard SPARQL UPDATE protocol for PATCH @@ -602,7 +602,11 @@ curl http://localhost:3000/api/v1/instance ## remoteStorage -JSS implements the [remoteStorage protocol](https://remotestorage.io/spec/draft-dejong-remotestorage-22) — always on, no flag needed. Any remoteStorage-compatible app can store and sync data on your pod. +JSS implements the [remoteStorage protocol](https://remotestorage.io/spec/draft-dejong-remotestorage-22). The storage routes are always available, but WebFinger discovery and OAuth require `--activitypub` (which provides the WebFinger and OAuth endpoints). Any remoteStorage-compatible app can store and sync data on your pod. + +```bash +jss start --activitypub --idp +``` ### Discovery