javascript-solid-server 0.0.96 → 0.0.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,9 @@ A minimal, fast, JSON-LD native Solid server.
18
18
  - **HTTP Range Requests** - Partial content delivery for large files and media streaming
19
19
  - **Single-User Mode** - Simplified setup for personal pod servers
20
20
  - **ActivityPub Federation** - Fediverse integration with WebFinger, inbox/outbox, HTTP signatures
21
+ - **Mastodon-compatible API** - Dynamic client registration, instance info, account verification
22
+ - **OAuth 2.0 Authorization** - Shared auth flow for Mastodon clients, remoteStorage apps, and third-party panes
23
+ - **remoteStorage Protocol** - [draft-dejong-remotestorage-22](https://remotestorage.io/spec/) file sync (requires `--activitypub` for WebFinger discovery + OAuth)
21
24
  - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
22
25
  - **N3 Patch** - Solid's native patch format for RDF updates
23
26
  - **SPARQL Update** - Standard SPARQL UPDATE protocol for PATCH
@@ -554,6 +557,106 @@ curl -H "Accept: application/activity+json" http://localhost:3000/profile/card
554
557
  curl http://localhost:3000/.well-known/nodeinfo/2.1
555
558
  ```
556
559
 
560
+ ## Mastodon-compatible API
561
+
562
+ JSS exposes Mastodon API endpoints so that Mastodon clients (Elk, Phanpy, Ice Cubes) can connect:
563
+
564
+ ```bash
565
+ jss start --activitypub --idp
566
+ ```
567
+
568
+ ### Endpoints
569
+
570
+ | Endpoint | Description |
571
+ |----------|-------------|
572
+ | `POST /api/v1/apps` | Dynamic client registration |
573
+ | `GET /api/v1/accounts/verify_credentials` | Current user profile |
574
+ | `GET /api/v1/instance` | Instance metadata |
575
+ | `GET /oauth/authorize` | OAuth authorize page |
576
+ | `POST /oauth/authorize` | Process login |
577
+ | `POST /oauth/token` | Exchange code for Bearer token |
578
+
579
+ ### OAuth 2.0 Flow
580
+
581
+ The OAuth layer is shared between Mastodon clients, remoteStorage apps, and third-party Solid panes:
582
+
583
+ 1. Client registers via `POST /api/v1/apps` (gets `client_id` + `client_secret`)
584
+ 2. Client redirects user to `GET /oauth/authorize?client_id=...&redirect_uri=...&response_type=code`
585
+ 3. User logs in, JSS redirects back with `?code=...`
586
+ 4. Client exchanges code for Bearer token via `POST /oauth/token`
587
+ 5. Bearer token works with all JSS endpoints (Solid, ActivityPub, remoteStorage)
588
+
589
+ Supports out-of-band (OOB) redirect for CLI/desktop clients.
590
+
591
+ ### Testing
592
+
593
+ ```bash
594
+ # Register a client
595
+ curl -X POST http://localhost:3000/api/v1/apps \
596
+ -H "Content-Type: application/json" \
597
+ -d '{"client_name": "Test App", "redirect_uris": "urn:ietf:wg:oauth:2.0:oob"}'
598
+
599
+ # Check instance info
600
+ curl http://localhost:3000/api/v1/instance
601
+ ```
602
+
603
+ ## remoteStorage
604
+
605
+ 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.
606
+
607
+ ```bash
608
+ jss start --activitypub --idp
609
+ ```
610
+
611
+ ### Discovery
612
+
613
+ remoteStorage clients discover the storage endpoint via WebFinger:
614
+
615
+ ```bash
616
+ curl "http://localhost:3000/.well-known/webfinger?resource=acct:me@localhost:3000"
617
+ ```
618
+
619
+ The response includes a `remotestorage` link relation pointing to `/storage/me/`.
620
+
621
+ ### Endpoints
622
+
623
+ | Method | Endpoint | Description |
624
+ |--------|----------|-------------|
625
+ | `GET` | `/storage/:user/*` | Read file or list folder (JSON-LD) |
626
+ | `HEAD` | `/storage/:user/*` | Get metadata (ETag, Content-Type, size) |
627
+ | `PUT` | `/storage/:user/*` | Write file (creates parent folders) |
628
+ | `DELETE` | `/storage/:user/*` | Delete file |
629
+
630
+ ### How It Works
631
+
632
+ - **Auth**: Bearer token via OAuth 2.0 (same flow as Mastodon clients)
633
+ - **Public folder**: `/storage/me/public/*` is readable without auth
634
+ - **Conditional requests**: If-Match, If-None-Match (uses shared ETag utilities)
635
+ - **Dotfile protection**: `.acl`, `.meta`, and other dotfiles are blocked
636
+ - **Read-only mode**: Respects `--read-only` flag
637
+ - **Streaming**: Large files are streamed, not buffered
638
+
639
+ ### Testing
640
+
641
+ ```bash
642
+ # Write a file (needs Bearer token from OAuth flow)
643
+ curl -X PUT http://localhost:3000/storage/me/documents/hello.txt \
644
+ -H "Authorization: Bearer YOUR_TOKEN" \
645
+ -H "Content-Type: text/plain" \
646
+ -d "Hello, remoteStorage!"
647
+
648
+ # Read it back
649
+ curl -H "Authorization: Bearer YOUR_TOKEN" \
650
+ http://localhost:3000/storage/me/documents/hello.txt
651
+
652
+ # List a folder
653
+ curl -H "Authorization: Bearer YOUR_TOKEN" \
654
+ http://localhost:3000/storage/me/documents/
655
+
656
+ # Read from public folder (no auth needed)
657
+ curl http://localhost:3000/storage/me/public/readme.txt
658
+ ```
659
+
557
660
  ### Linking Nostr to WebID (did:nostr)
558
661
 
559
662
  Bridge your Nostr identity to a Solid WebID for seamless authentication:
@@ -1097,7 +1200,10 @@ src/
1097
1200
  │ ├── actor.js # Actor JSON-LD
1098
1201
  │ ├── inbox.js # Receive activities
1099
1202
  │ ├── outbox.js # User's activities
1100
- └── collections.js # Followers/following
1203
+ ├── collections.js # Followers/following
1204
+ │ ├── mastodon.js # Mastodon API (apps, instance, verify_credentials)
1205
+ │ └── oauth.js # OAuth 2.0 authorize/token flow
1206
+ ├── remotestorage.js # remoteStorage protocol (draft-dejong-remotestorage-22)
1101
1207
  ├── rdf/
1102
1208
  │ ├── turtle.js # Turtle <-> JSON-LD
1103
1209
  │ └── conneg.js # Content negotiation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.96",
3
+ "version": "0.0.97",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/ap/index.js CHANGED
@@ -10,6 +10,8 @@ import { createInboxHandler } from './routes/inbox.js'
10
10
  import { createOutboxHandler, createOutboxPostHandler } from './routes/outbox.js'
11
11
  import { createCollectionsHandler } from './routes/collections.js'
12
12
  import { createActorHandler } from './routes/actor.js'
13
+ import { createAppsHandler, createVerifyCredentialsHandler, createInstanceHandler } from './routes/mastodon.js'
14
+ import { createAuthorizeHandler, createAuthorizePostHandler, createTokenHandler } from './routes/oauth.js'
13
15
 
14
16
  // Shared state for actor handler (accessed by server.js)
15
17
  let sharedActorHandler = null
@@ -106,6 +108,17 @@ export async function activityPubPlugin(fastify, options = {}) {
106
108
  { profileUrl }
107
109
  )
108
110
 
111
+ // Add remoteStorage link relation
112
+ response.links.push({
113
+ rel: 'http://tools.ietf.org/id/draft-dejong-remotestorage',
114
+ href: `${baseUrl}/storage/${config.username}/`,
115
+ properties: {
116
+ 'http://remotestorage.io/spec/version': 'draft-dejong-remotestorage-22',
117
+ 'http://tools.ietf.org/html/rfc6749#section-4.2': `${baseUrl}/oauth/authorize`,
118
+ 'http://tools.ietf.org/html/rfc6750#section-2.3': 'Bearer'
119
+ }
120
+ })
121
+
109
122
  return reply
110
123
  .header('Content-Type', 'application/jrd+json')
111
124
  .header('Access-Control-Allow-Origin', '*')
@@ -135,7 +148,7 @@ export async function activityPubPlugin(fastify, options = {}) {
135
148
  version: '2.1',
136
149
  software: {
137
150
  name: 'jss',
138
- version: '0.0.67',
151
+ version: '0.0.97',
139
152
  repository: 'https://github.com/JavaScriptSolidServer/JavaScriptSolidServer'
140
153
  },
141
154
  protocols: ['activitypub', 'solid'],
@@ -173,6 +186,32 @@ export async function activityPubPlugin(fastify, options = {}) {
173
186
  const collectionsHandler = createCollectionsHandler(config)
174
187
  fastify.get('/profile/card/followers', (req, reply) => collectionsHandler(req, reply, 'followers'))
175
188
  fastify.get('/profile/card/following', (req, reply) => collectionsHandler(req, reply, 'following'))
189
+
190
+ // Mastodon-compatible API endpoints
191
+ fastify.post('/api/v1/apps', createAppsHandler())
192
+ fastify.get('/api/v1/accounts/verify_credentials', createVerifyCredentialsHandler(config))
193
+ fastify.get('/api/v1/instance', createInstanceHandler(config))
194
+
195
+ // OAuth 2.0 authorize/token flow (Mastodon clients, remoteStorage, third-party panes)
196
+ fastify.get('/oauth/authorize', createAuthorizeHandler())
197
+ fastify.post('/oauth/authorize', {
198
+ config: {
199
+ rateLimit: {
200
+ max: 10,
201
+ timeWindow: '1 minute',
202
+ keyGenerator: (request) => request.ip
203
+ }
204
+ }
205
+ }, createAuthorizePostHandler())
206
+ fastify.post('/oauth/token', {
207
+ config: {
208
+ rateLimit: {
209
+ max: 10,
210
+ timeWindow: '1 minute',
211
+ keyGenerator: (request) => request.ip
212
+ }
213
+ }
214
+ }, createTokenHandler())
176
215
  }
177
216
 
178
217
  export default activityPubPlugin
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Mastodon-compatible API endpoints
3
+ * Allows Mastodon clients (Elk, Phanpy, Ice Cubes) to connect to JSS
4
+ *
5
+ * Step 1: Dynamic client registration + account verification
6
+ * Refs: https://docs.joinmastodon.org/methods/apps/
7
+ * https://docs.joinmastodon.org/methods/accounts/#verify_credentials
8
+ */
9
+
10
+ // In-memory client store (replace with persistent storage later)
11
+ const clients = new Map()
12
+
13
+ // Stable instance start time (used for created_at)
14
+ const startedAt = new Date().toISOString()
15
+
16
+ /**
17
+ * Parse request body — handles both JSON and form-urlencoded
18
+ * (JSS uses raw buffer parser for all content types)
19
+ */
20
+ function parseBody (request) {
21
+ if (request.body && typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
22
+ return request.body
23
+ }
24
+ const raw = Buffer.isBuffer(request.body) ? request.body.toString() : String(request.body || '')
25
+ const ct = request.headers['content-type'] || ''
26
+ if (ct.includes('application/json')) {
27
+ try { return JSON.parse(raw) } catch { return {} }
28
+ }
29
+ // Default: parse as form-urlencoded
30
+ return Object.fromEntries(new URLSearchParams(raw))
31
+ }
32
+
33
+ /**
34
+ * POST /api/v1/apps — Dynamic client registration
35
+ * Mastodon clients call this to register before OAuth
36
+ */
37
+ export function createAppsHandler () {
38
+ return async (request, reply) => {
39
+ const body = parseBody(request)
40
+ const { client_name, redirect_uris, scopes, website } = body
41
+
42
+ if (!client_name || !redirect_uris) {
43
+ return reply.code(422).send({ error: 'client_name and redirect_uris are required' })
44
+ }
45
+
46
+ const clientId = crypto.randomUUID()
47
+ const clientSecret = crypto.randomUUID()
48
+
49
+ const client = {
50
+ id: clientId,
51
+ name: client_name,
52
+ redirect_uri: redirect_uris,
53
+ client_id: clientId,
54
+ client_secret: clientSecret,
55
+ scopes: scopes || 'read',
56
+ website: website || null
57
+ }
58
+
59
+ clients.set(clientId, client)
60
+
61
+ return reply.send(client)
62
+ }
63
+ }
64
+
65
+ /**
66
+ * GET /api/v1/accounts/verify_credentials — Who am I?
67
+ * Returns the authenticated user's profile as a Mastodon Account object
68
+ */
69
+ export function createVerifyCredentialsHandler (config) {
70
+ return async (request, reply) => {
71
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
72
+ const host = request.headers['x-forwarded-host'] || request.hostname
73
+ const baseUrl = `${protocol}://${host}`
74
+
75
+ const account = {
76
+ id: '1',
77
+ username: config.username,
78
+ acct: config.username,
79
+ display_name: config.displayName,
80
+ note: config.summary ? `<p>${escapeHtml(config.summary)}</p>` : '',
81
+ url: `${baseUrl}/profile/card`,
82
+ uri: `${baseUrl}/profile/card#me`,
83
+ avatar: `${baseUrl}/profile/avatar.png`,
84
+ header: '',
85
+ locked: false,
86
+ bot: false,
87
+ created_at: startedAt,
88
+ followers_count: 0,
89
+ following_count: 0,
90
+ statuses_count: 0,
91
+ source: {
92
+ privacy: 'public',
93
+ sensitive: false,
94
+ language: 'en',
95
+ note: config.summary || ''
96
+ }
97
+ }
98
+
99
+ return reply.send(account)
100
+ }
101
+ }
102
+
103
+ /**
104
+ * GET /api/v1/instance — Instance information
105
+ * Required by most Mastodon clients before login
106
+ */
107
+ export function createInstanceHandler (config) {
108
+ return async (request, reply) => {
109
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
110
+ const host = request.headers['x-forwarded-host'] || request.hostname
111
+ const wsProtocol = protocol === 'https' ? 'wss' : 'ws'
112
+
113
+ return reply.send({
114
+ uri: host,
115
+ title: config.displayName || 'JSS',
116
+ description: 'SAND Stack: Solid + ActivityPub + Nostr + DID',
117
+ short_description: 'Solid pod with Mastodon-compatible API',
118
+ version: '4.0.0 (compatible; JSS 0.0.97)',
119
+ urls: {
120
+ streaming_api: `${wsProtocol}://${host}`
121
+ },
122
+ stats: {
123
+ user_count: 1,
124
+ status_count: 0,
125
+ domain_count: 1
126
+ },
127
+ languages: ['en'],
128
+ registrations: false,
129
+ approval_required: false,
130
+ configuration: {
131
+ statuses: { max_characters: 5000 },
132
+ media_attachments: { supported_mime_types: [] }
133
+ }
134
+ })
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Look up a registered client
140
+ */
141
+ export function getClient (clientId) {
142
+ return clients.get(clientId) || null
143
+ }
144
+
145
+ function escapeHtml (str) {
146
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
147
+ }
148
+
149
+ export default {
150
+ createAppsHandler,
151
+ createVerifyCredentialsHandler,
152
+ createInstanceHandler,
153
+ getClient
154
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * OAuth 2.0 authorize/token flow
3
+ * Shared infrastructure for Mastodon clients, remoteStorage apps, and third-party panes
4
+ *
5
+ * Refs: https://docs.joinmastodon.org/methods/oauth/
6
+ * https://datatracker.ietf.org/doc/html/rfc6749
7
+ *
8
+ * Related: #158, #159 (Mastodon API), #106 (remoteStorage), #160 (this)
9
+ */
10
+
11
+ import crypto from 'crypto'
12
+ import { getClient } from './mastodon.js'
13
+ import { authenticate } from '../../idp/accounts.js'
14
+ import { createToken } from '../../auth/token.js'
15
+
16
+ // Mastodon OOB redirect — display code instead of redirecting
17
+ const OOB_REDIRECT = 'urn:ietf:wg:oauth:2.0:oob'
18
+
19
+ // Auth codes: code → { clientId, redirectUri, webId, scope, expiresAt }
20
+ const authCodes = new Map()
21
+
22
+ // Clean up expired codes every 60s
23
+ setInterval(() => {
24
+ const now = Date.now()
25
+ for (const [code, data] of authCodes) {
26
+ if (data.expiresAt < now) authCodes.delete(code)
27
+ }
28
+ }, 60000).unref()
29
+
30
+ /**
31
+ * Parse request body — handles JSON and form-urlencoded
32
+ */
33
+ function parseBody (request) {
34
+ if (request.body && typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
35
+ return request.body
36
+ }
37
+ const raw = Buffer.isBuffer(request.body) ? request.body.toString() : String(request.body || '')
38
+ const ct = request.headers['content-type'] || ''
39
+ if (ct.includes('application/json')) {
40
+ try { return JSON.parse(raw) } catch { return {} }
41
+ }
42
+ return Object.fromEntries(new URLSearchParams(raw))
43
+ }
44
+
45
+ /**
46
+ * Validate client_id and redirect_uri against registered client
47
+ * Returns { client, error } — client is null if validation fails
48
+ */
49
+ function validateClient (clientId, redirectUri) {
50
+ if (!clientId || !redirectUri) {
51
+ return { client: null, error: 'Missing client_id or redirect_uri' }
52
+ }
53
+
54
+ const client = getClient(clientId)
55
+ if (!client) {
56
+ return { client: null, error: 'Unknown client_id. Register via POST /api/v1/apps first.' }
57
+ }
58
+
59
+ // Validate redirect_uri matches registered value (RFC 6749 §10.6)
60
+ if (redirectUri !== OOB_REDIRECT && redirectUri !== client.redirect_uri) {
61
+ return { client: null, error: 'redirect_uri does not match registered value' }
62
+ }
63
+
64
+ return { client, error: null }
65
+ }
66
+
67
+ /**
68
+ * GET /oauth/authorize — Show login/consent page
69
+ */
70
+ export function createAuthorizeHandler () {
71
+ return async (request, reply) => {
72
+ const { client_id, redirect_uri, response_type, scope, state } = request.query
73
+
74
+ if (response_type && response_type !== 'code') {
75
+ return reply.code(400).send({ error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' })
76
+ }
77
+
78
+ const { client, error } = validateClient(client_id, redirect_uri)
79
+ if (!client) {
80
+ return reply.code(400).send({ error: 'invalid_client', error_description: error })
81
+ }
82
+
83
+ return reply.type('text/html').send(
84
+ loginPage({ clientId: client_id, redirectUri: redirect_uri, scope: scope || 'read', state, clientName: client.name })
85
+ )
86
+ }
87
+ }
88
+
89
+ /**
90
+ * POST /oauth/authorize — Process login form
91
+ */
92
+ export function createAuthorizePostHandler () {
93
+ return async (request, reply) => {
94
+ const body = parseBody(request)
95
+ const { username, password, client_id, redirect_uri, scope, state } = body
96
+
97
+ // Validate client + redirect_uri (prevent open redirect via form tampering)
98
+ const { client, error: clientError } = validateClient(client_id, redirect_uri)
99
+ if (!client) {
100
+ return reply.code(400).send({ error: 'invalid_client', error_description: clientError })
101
+ }
102
+
103
+ if (!username || !password) {
104
+ return reply.type('text/html').send(
105
+ loginPage({ clientId: client_id, redirectUri: redirect_uri, scope, state, clientName: client.name, error: 'Username and password are required' })
106
+ )
107
+ }
108
+
109
+ const account = await authenticate(username, password)
110
+ if (!account) {
111
+ return reply.type('text/html').send(
112
+ loginPage({ clientId: client_id, redirectUri: redirect_uri, scope, state, clientName: client.name, error: 'Invalid username or password' })
113
+ )
114
+ }
115
+
116
+ // Generate one-time auth code (10 min TTL)
117
+ const code = crypto.randomUUID()
118
+ authCodes.set(code, {
119
+ clientId: client_id,
120
+ redirectUri: redirect_uri,
121
+ webId: account.webId,
122
+ scope: scope || 'read',
123
+ expiresAt: Date.now() + 600_000
124
+ })
125
+
126
+ // Handle OOB redirect — display code to user instead of redirecting
127
+ if (redirect_uri === OOB_REDIRECT) {
128
+ return reply.type('text/html').send(oobPage(code))
129
+ }
130
+
131
+ // Redirect back to client with code + state (RFC 6749 §4.1.2)
132
+ const url = new URL(redirect_uri)
133
+ url.searchParams.set('code', code)
134
+ if (state) url.searchParams.set('state', state)
135
+ return reply.redirect(url.toString())
136
+ }
137
+ }
138
+
139
+ /**
140
+ * POST /oauth/token — Exchange auth code for Bearer token
141
+ */
142
+ export function createTokenHandler () {
143
+ return async (request, reply) => {
144
+ const body = parseBody(request)
145
+ const { grant_type, code, client_id, client_secret, redirect_uri } = body
146
+
147
+ if (grant_type !== 'authorization_code') {
148
+ return reply.code(400).send({ error: 'unsupported_grant_type' })
149
+ }
150
+
151
+ if (!code) {
152
+ return reply.code(400).send({ error: 'invalid_request', error_description: 'Missing code' })
153
+ }
154
+
155
+ // Validate client credentials (RFC 6749 §2.3)
156
+ const client = getClient(client_id)
157
+ if (!client) {
158
+ return reply.code(401).send({ error: 'invalid_client', error_description: 'Unknown client_id' })
159
+ }
160
+ try {
161
+ if (!crypto.timingSafeEqual(Buffer.from(client.client_secret), Buffer.from(client_secret || ''))) {
162
+ return reply.code(401).send({ error: 'invalid_client', error_description: 'Invalid client_secret' })
163
+ }
164
+ } catch {
165
+ return reply.code(401).send({ error: 'invalid_client', error_description: 'Invalid client_secret' })
166
+ }
167
+
168
+ // Look up auth code and consume immediately (RFC 6749 §10.5 — one-time use)
169
+ const authCode = authCodes.get(code)
170
+ authCodes.delete(code)
171
+
172
+ if (!authCode || authCode.expiresAt < Date.now()) {
173
+ return reply.code(400).send({ error: 'invalid_grant', error_description: 'Code expired or invalid' })
174
+ }
175
+
176
+ if (authCode.clientId !== client_id) {
177
+ return reply.code(400).send({ error: 'invalid_client' })
178
+ }
179
+
180
+ if (authCode.redirectUri !== redirect_uri) {
181
+ return reply.code(400).send({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' })
182
+ }
183
+
184
+ // Generate Bearer token using existing token infrastructure
185
+ const accessToken = createToken(authCode.webId)
186
+
187
+ return reply.send({
188
+ access_token: accessToken,
189
+ token_type: 'Bearer',
190
+ scope: authCode.scope,
191
+ created_at: Math.floor(Date.now() / 1000)
192
+ })
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Minimal login page HTML
198
+ */
199
+ function loginPage ({ clientId, redirectUri, scope, state, clientName, error }) {
200
+ const escapedError = error ? escapeHtml(error) : ''
201
+ const escapedName = escapeHtml(clientName || clientId || 'Unknown app')
202
+
203
+ return `<!DOCTYPE html>
204
+ <html lang="en">
205
+ <head>
206
+ <meta charset="utf-8">
207
+ <meta name="viewport" content="width=device-width, initial-scale=1">
208
+ <title>Authorize ${escapedName}</title>
209
+ <style>
210
+ * { box-sizing: border-box; margin: 0; padding: 0; }
211
+ body { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
212
+ .card { background: white; border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
213
+ h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
214
+ .subtitle { color: #666; margin-bottom: 1.5rem; font-size: 0.9rem; }
215
+ .scope { background: #f0f0f0; padding: 0.5rem 0.75rem; border-radius: 6px; margin-bottom: 1.5rem; font-size: 0.85rem; color: #444; }
216
+ label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.25rem; color: #333; }
217
+ input[type="text"], input[type="password"] { width: 100%; padding: 0.6rem; border: 1px solid #ddd; border-radius: 6px; font-size: 1rem; margin-bottom: 1rem; }
218
+ input:focus { outline: none; border-color: #4a9eff; box-shadow: 0 0 0 2px rgba(74,158,255,0.2); }
219
+ button { width: 100%; padding: 0.7rem; background: #4a9eff; color: white; border: none; border-radius: 6px; font-size: 1rem; font-weight: 500; cursor: pointer; }
220
+ button:hover { background: #3a8eef; }
221
+ .error { background: #fee; color: #c00; padding: 0.6rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.85rem; }
222
+ </style>
223
+ </head>
224
+ <body>
225
+ <div class="card">
226
+ <h1>Authorize</h1>
227
+ <p class="subtitle"><strong>${escapedName}</strong> wants access to your account</p>
228
+ <div class="scope">Scope: ${escapeHtml(scope || 'read')}</div>
229
+ ${escapedError ? `<div class="error">${escapedError}</div>` : ''}
230
+ <form method="POST" action="/oauth/authorize">
231
+ <input type="hidden" name="client_id" value="${escapeHtml(clientId || '')}">
232
+ <input type="hidden" name="redirect_uri" value="${escapeHtml(redirectUri || '')}">
233
+ <input type="hidden" name="scope" value="${escapeHtml(scope || 'read')}">
234
+ ${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ''}
235
+ <label for="username">Username</label>
236
+ <input type="text" id="username" name="username" required autocomplete="username">
237
+ <label for="password">Password</label>
238
+ <input type="password" id="password" name="password" required autocomplete="current-password">
239
+ <button type="submit">Authorize</button>
240
+ </form>
241
+ </div>
242
+ </body>
243
+ </html>`
244
+ }
245
+
246
+ /**
247
+ * OOB (out-of-band) code display page
248
+ * Used when redirect_uri is urn:ietf:wg:oauth:2.0:oob
249
+ */
250
+ function oobPage (code) {
251
+ return `<!DOCTYPE html>
252
+ <html lang="en">
253
+ <head>
254
+ <meta charset="utf-8">
255
+ <meta name="viewport" content="width=device-width, initial-scale=1">
256
+ <title>Authorization Code</title>
257
+ <style>
258
+ * { box-sizing: border-box; margin: 0; padding: 0; }
259
+ body { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
260
+ .card { background: white; border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; }
261
+ h1 { font-size: 1.25rem; margin-bottom: 1rem; }
262
+ .code { background: #f0f0f0; padding: 1rem; border-radius: 6px; font-family: monospace; font-size: 0.9rem; word-break: break-all; user-select: all; }
263
+ p { color: #666; margin-top: 1rem; font-size: 0.85rem; }
264
+ </style>
265
+ </head>
266
+ <body>
267
+ <div class="card">
268
+ <h1>Authorization Successful</h1>
269
+ <div class="code">${escapeHtml(code)}</div>
270
+ <p>Copy this code and paste it into your application.</p>
271
+ </div>
272
+ </body>
273
+ </html>`
274
+ }
275
+
276
+ function escapeHtml (str) {
277
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
278
+ }
279
+
280
+ export default {
281
+ createAuthorizeHandler,
282
+ createAuthorizePostHandler,
283
+ createTokenHandler
284
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * remoteStorage plugin for JSS
3
+ * Implements draft-dejong-remotestorage protocol on top of existing storage
4
+ *
5
+ * No new dependencies — reuses filesystem storage, OAuth, and WebFinger.
6
+ * Always on — no flag needed.
7
+ *
8
+ * Ref: https://remotestorage.io/spec/draft-dejong-remotestorage-22
9
+ * Related: #106, #160 (OAuth), #159 (Mastodon API)
10
+ */
11
+
12
+ import * as storage from './storage/filesystem.js'
13
+ import { getContentType } from './utils/url.js'
14
+ import { getWebIdFromRequestAsync } from './auth/token.js'
15
+ import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from './utils/conditional.js'
16
+
17
+ /**
18
+ * remoteStorage Fastify plugin
19
+ * @param {FastifyInstance} fastify
20
+ * @param {object} options
21
+ * @param {string} options.username - Storage owner username
22
+ * @param {string} options.ownerWebId - WebID of the storage owner
23
+ */
24
+ export async function remoteStoragePlugin (fastify, options = {}) {
25
+ const username = options.username || 'me'
26
+ const ownerWebId = options.ownerWebId || null
27
+
28
+ /**
29
+ * Extract the storage path from the URL
30
+ * /storage/me/photos/vacation.jpg → /photos/vacation.jpg
31
+ */
32
+ function getStoragePath (request) {
33
+ const wildcard = request.params['*'] || ''
34
+ return '/' + wildcard
35
+ }
36
+
37
+ /**
38
+ * Check if the :user param matches the configured username
39
+ */
40
+ function checkUsername (request, reply) {
41
+ if (request.params.user !== username) {
42
+ reply.code(404).send({ error: 'Unknown user' })
43
+ return false
44
+ }
45
+ return true
46
+ }
47
+
48
+ /**
49
+ * Check if any path segment is a blocked dotfile
50
+ */
51
+ function hasDotfile (storagePath) {
52
+ const segments = storagePath.split('/')
53
+ return segments.some(s => s.startsWith('.') && s.length > 1)
54
+ }
55
+
56
+ /**
57
+ * Check if request is authorized for the given method
58
+ * Public folder is readable without auth
59
+ */
60
+ async function checkAuth (request, method) {
61
+ const storagePath = getStoragePath(request)
62
+
63
+ // Public folder: readable without auth
64
+ if (storagePath.startsWith('/public/') && (method === 'GET' || method === 'HEAD')) {
65
+ return { authorized: true, webId: null }
66
+ }
67
+
68
+ const { webId, error } = await getWebIdFromRequestAsync(request)
69
+ if (!webId) {
70
+ return { authorized: false, webId: null, error: error || 'Unauthorized', status: 401 }
71
+ }
72
+
73
+ // If ownerWebId is set, only the owner can access storage
74
+ if (ownerWebId && webId !== ownerWebId) {
75
+ return { authorized: false, webId, error: 'Forbidden', status: 403 }
76
+ }
77
+
78
+ return { authorized: true, webId }
79
+ }
80
+
81
+ // GET /storage/:user/* — read file or folder
82
+ fastify.get('/storage/:user/*', async (request, reply) => {
83
+ if (!checkUsername(request, reply)) return
84
+
85
+ const storagePath = getStoragePath(request)
86
+
87
+ // Block dotfile access
88
+ if (hasDotfile(storagePath)) {
89
+ return reply.code(404).send({ error: 'Not found' })
90
+ }
91
+
92
+ const { authorized, error, status } = await checkAuth(request, 'GET')
93
+ if (!authorized) {
94
+ const code = status || 401
95
+ if (code === 401) reply.header('WWW-Authenticate', 'Bearer')
96
+ return reply.code(code).send({ error })
97
+ }
98
+
99
+ const info = await storage.stat(storagePath)
100
+ if (!info) {
101
+ return reply.code(404).send({ error: 'Not found' })
102
+ }
103
+
104
+ // Conditional GET — use shared utility
105
+ const cond = checkIfNoneMatchForGet(request.headers['if-none-match'], info.etag)
106
+ if (!cond.ok) {
107
+ return reply.code(304).send()
108
+ }
109
+
110
+ // Directory listing
111
+ if (info.isDirectory) {
112
+ const entries = await storage.listContainer(storagePath)
113
+ if (!entries) {
114
+ return reply.code(404).send({ error: 'Not found' })
115
+ }
116
+
117
+ const items = {}
118
+ for (const entry of entries) {
119
+ // Skip dotfiles (ACLs, metadata, etc.)
120
+ if (entry.name.startsWith('.')) continue
121
+
122
+ const childPath = storagePath.endsWith('/') ? storagePath + entry.name : storagePath + '/' + entry.name
123
+ const childStat = await storage.stat(entry.isDirectory ? childPath + '/' : childPath)
124
+
125
+ if (entry.isDirectory) {
126
+ items[entry.name + '/'] = {
127
+ ETag: childStat?.etag?.replace(/"/g, '') || ''
128
+ }
129
+ } else {
130
+ items[entry.name] = {
131
+ ETag: childStat?.etag?.replace(/"/g, '') || '',
132
+ 'Content-Type': getContentType(entry.name),
133
+ 'Content-Length': childStat?.size || 0
134
+ }
135
+ }
136
+ }
137
+
138
+ return reply
139
+ .header('Content-Type', 'application/ld+json')
140
+ .header('ETag', info.etag)
141
+ .header('Cache-Control', 'no-cache')
142
+ .send({
143
+ '@context': 'http://remotestorage.io/spec/folder-description',
144
+ items
145
+ })
146
+ }
147
+
148
+ // File — stream instead of buffering
149
+ const result = storage.createReadStream(storagePath)
150
+ if (!result) {
151
+ return reply.code(404).send({ error: 'Not found' })
152
+ }
153
+
154
+ return reply
155
+ .header('Content-Type', getContentType(storagePath))
156
+ .header('Content-Length', info.size)
157
+ .header('ETag', info.etag)
158
+ .header('Cache-Control', 'no-cache')
159
+ .send(result.stream)
160
+ })
161
+
162
+ // HEAD /storage/:user/* — metadata only
163
+ fastify.head('/storage/:user/*', async (request, reply) => {
164
+ if (!checkUsername(request, reply)) return
165
+
166
+ const storagePath = getStoragePath(request)
167
+
168
+ if (hasDotfile(storagePath)) {
169
+ return reply.code(404).send()
170
+ }
171
+
172
+ const { authorized, error, status } = await checkAuth(request, 'HEAD')
173
+ if (!authorized) {
174
+ const code = status || 401
175
+ if (code === 401) reply.header('WWW-Authenticate', 'Bearer')
176
+ return reply.code(code).send()
177
+ }
178
+
179
+ const info = await storage.stat(storagePath)
180
+ if (!info) {
181
+ return reply.code(404).send()
182
+ }
183
+
184
+ // Conditional HEAD
185
+ const cond = checkIfNoneMatchForGet(request.headers['if-none-match'], info.etag)
186
+ if (!cond.ok) {
187
+ return reply.code(304).send()
188
+ }
189
+
190
+ reply
191
+ .header('Content-Type', info.isDirectory ? 'application/ld+json' : getContentType(storagePath))
192
+ .header('ETag', info.etag)
193
+ .header('Cache-Control', 'no-cache')
194
+
195
+ if (!info.isDirectory) {
196
+ reply.header('Content-Length', info.size)
197
+ }
198
+
199
+ return reply.code(200).send()
200
+ })
201
+
202
+ // PUT /storage/:user/* — write file
203
+ fastify.put('/storage/:user/*', async (request, reply) => {
204
+ if (!checkUsername(request, reply)) return
205
+
206
+ // Respect readOnly mode
207
+ if (request.config?.readOnly) {
208
+ return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' })
209
+ }
210
+
211
+ const storagePath = getStoragePath(request)
212
+
213
+ if (hasDotfile(storagePath)) {
214
+ return reply.code(403).send({ error: 'Cannot write to dotfiles' })
215
+ }
216
+
217
+ const { authorized, error, status } = await checkAuth(request, 'PUT')
218
+ if (!authorized) {
219
+ const code = status || 401
220
+ if (code === 401) reply.header('WWW-Authenticate', 'Bearer')
221
+ return reply.code(code).send({ error })
222
+ }
223
+
224
+ // Directories end with / — can't PUT to a directory
225
+ if (storagePath.endsWith('/')) {
226
+ return reply.code(400).send({ error: 'Cannot PUT to a folder path' })
227
+ }
228
+
229
+ // Conditional write — use shared utilities
230
+ const existing = await storage.stat(storagePath)
231
+
232
+ const ifMatchResult = checkIfMatch(request.headers['if-match'], existing?.etag || null)
233
+ if (!ifMatchResult.ok) {
234
+ return reply.code(ifMatchResult.status).send({ error: ifMatchResult.error })
235
+ }
236
+
237
+ const ifNoneMatchResult = checkIfNoneMatchForWrite(request.headers['if-none-match'], existing?.etag || null)
238
+ if (!ifNoneMatchResult.ok) {
239
+ return reply.code(ifNoneMatchResult.status).send({ error: ifNoneMatchResult.error })
240
+ }
241
+
242
+ const content = Buffer.isBuffer(request.body) ? request.body : Buffer.from(request.body || '')
243
+ const success = await storage.write(storagePath, content)
244
+ if (!success) {
245
+ return reply.code(500).send({ error: 'Write failed' })
246
+ }
247
+
248
+ const newStat = await storage.stat(storagePath)
249
+ const statusCode = existing ? 200 : 201
250
+
251
+ return reply
252
+ .code(statusCode)
253
+ .header('ETag', newStat?.etag || '')
254
+ .send()
255
+ })
256
+
257
+ // DELETE /storage/:user/* — delete file
258
+ fastify.delete('/storage/:user/*', async (request, reply) => {
259
+ if (!checkUsername(request, reply)) return
260
+
261
+ // Respect readOnly mode
262
+ if (request.config?.readOnly) {
263
+ return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' })
264
+ }
265
+
266
+ const storagePath = getStoragePath(request)
267
+
268
+ if (hasDotfile(storagePath)) {
269
+ return reply.code(403).send({ error: 'Cannot delete dotfiles' })
270
+ }
271
+
272
+ const { authorized, error, status } = await checkAuth(request, 'DELETE')
273
+ if (!authorized) {
274
+ const code = status || 401
275
+ if (code === 401) reply.header('WWW-Authenticate', 'Bearer')
276
+ return reply.code(code).send({ error })
277
+ }
278
+
279
+ const existing = await storage.stat(storagePath)
280
+ if (!existing) {
281
+ return reply.code(404).send({ error: 'Not found' })
282
+ }
283
+
284
+ // Conditional delete — use shared utility
285
+ const ifMatchResult = checkIfMatch(request.headers['if-match'], existing.etag)
286
+ if (!ifMatchResult.ok) {
287
+ return reply.code(ifMatchResult.status).send({ error: ifMatchResult.error })
288
+ }
289
+
290
+ const success = await storage.remove(storagePath)
291
+ if (!success) {
292
+ return reply.code(500).send({ error: 'Delete failed' })
293
+ }
294
+
295
+ return reply
296
+ .code(200)
297
+ .header('ETag', existing.etag)
298
+ .send()
299
+ })
300
+
301
+ fastify.log.info(`remoteStorage enabled for user: ${username}`)
302
+ }
303
+
304
+ export default remoteStoragePlugin
package/src/server.js CHANGED
@@ -15,6 +15,7 @@ import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js'
15
15
  import { AccessMode } from './wac/parser.js';
16
16
  import { registerNostrRelay } from './nostr/relay.js';
17
17
  import { activityPubPlugin, getActorHandler } from './ap/index.js';
18
+ import { remoteStoragePlugin } from './remotestorage.js';
18
19
  import { dbPlugin } from './db/index.js';
19
20
 
20
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -234,6 +235,12 @@ export function createServer(options = {}) {
234
235
  });
235
236
  }
236
237
 
238
+ // Register remoteStorage plugin (always on — no flag needed)
239
+ fastify.register(remoteStoragePlugin, {
240
+ username: singleUserName || 'me',
241
+ ownerWebId: null // single-user: any authenticated user can access
242
+ });
243
+
237
244
  // Register MongoDB /db/ route if enabled
238
245
  if (mongoEnabled) {
239
246
  fastify.register(dbPlugin, { mongoUrl, mongoDatabase, singleUser });
@@ -353,7 +360,9 @@ export function createServer(options = {}) {
353
360
  fastify.addHook('preHandler', async (request, reply) => {
354
361
  // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, solidos-ui, well-known, notifications, nostr, git, and AP
355
362
  const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
356
- const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following'];
363
+ const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following',
364
+ '/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials',
365
+ '/oauth/authorize', '/oauth/token'];
357
366
  // Check if request wants ActivityPub content for profile
358
367
  const accept = request.headers.accept || '';
359
368
  const wantsAP = accept.includes('activity+json') || accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');
@@ -368,6 +377,7 @@ export function createServer(options = {}) {
368
377
  (gitEnabled && isGitRequest(request.url)) ||
369
378
  (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
370
379
  isProfileAP ||
380
+ request.url.startsWith('/storage/') ||
371
381
  (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
372
382
  mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
373
383
  return;