javascript-solid-server 0.0.2 → 0.0.5

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.
@@ -9,7 +9,11 @@
9
9
  "Bash(npm install:*)",
10
10
  "Bash(timeout 3 node:*)",
11
11
  "Bash(PORT=3030 timeout 3 node:*)",
12
- "Bash(git commit:*)"
12
+ "Bash(git commit:*)",
13
+ "Bash(pkill:*)",
14
+ "Bash(curl:*)",
15
+ "Bash(npm test:*)",
16
+ "Bash(git add:*)"
13
17
  ]
14
18
  }
15
19
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.2",
3
+ "version": "0.0.5",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "start": "node src/index.js",
9
9
  "dev": "node --watch src/index.js",
10
- "test": "node --test"
10
+ "test": "node --test --test-concurrency=1"
11
11
  },
12
12
  "dependencies": {
13
13
  "fastify": "^4.25.2",
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Authorization middleware
3
+ * Combines authentication (token verification) with WAC checking
4
+ */
5
+
6
+ import { getWebIdFromRequest } from './token.js';
7
+ import { checkAccess, getRequiredMode } from '../wac/checker.js';
8
+ import * as storage from '../storage/filesystem.js';
9
+
10
+ /**
11
+ * Check if request is authorized
12
+ * @param {object} request - Fastify request
13
+ * @param {object} reply - Fastify reply
14
+ * @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string}>}
15
+ */
16
+ export async function authorize(request, reply) {
17
+ const urlPath = request.url.split('?')[0];
18
+ const method = request.method;
19
+
20
+ // Skip auth for .acl files (they need special handling)
21
+ // and for OPTIONS (CORS preflight)
22
+ if (urlPath.endsWith('.acl') || method === 'OPTIONS') {
23
+ return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"' };
24
+ }
25
+
26
+ // Get WebID from token (null if not authenticated)
27
+ const webId = getWebIdFromRequest(request);
28
+
29
+ // Get resource info
30
+ const stats = await storage.stat(urlPath);
31
+ const resourceExists = stats !== null;
32
+ const isContainer = stats?.isDirectory || urlPath.endsWith('/');
33
+
34
+ // Build resource URL
35
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
36
+
37
+ // Get required access mode for this method
38
+ const requiredMode = getRequiredMode(method);
39
+
40
+ // For write operations on non-existent resources, check parent container
41
+ let checkPath = urlPath;
42
+ let checkUrl = resourceUrl;
43
+ let checkIsContainer = isContainer;
44
+
45
+ if (!resourceExists && (method === 'PUT' || method === 'POST' || method === 'PATCH')) {
46
+ // Check write permission on parent container
47
+ const parentPath = getParentPath(urlPath);
48
+ checkPath = parentPath;
49
+ checkUrl = `${request.protocol}://${request.hostname}${parentPath}`;
50
+ checkIsContainer = true;
51
+ }
52
+
53
+ // Check WAC permissions
54
+ const { allowed, wacAllow } = await checkAccess({
55
+ resourceUrl: checkUrl,
56
+ resourcePath: checkPath,
57
+ isContainer: checkIsContainer,
58
+ agentWebId: webId,
59
+ requiredMode
60
+ });
61
+
62
+ return { authorized: allowed, webId, wacAllow };
63
+ }
64
+
65
+ /**
66
+ * Get parent container path
67
+ */
68
+ function getParentPath(path) {
69
+ const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
70
+ const lastSlash = normalized.lastIndexOf('/');
71
+ if (lastSlash <= 0) return '/';
72
+ return normalized.substring(0, lastSlash + 1);
73
+ }
74
+
75
+ /**
76
+ * Handle unauthorized request
77
+ * @param {object} reply - Fastify reply
78
+ * @param {boolean} isAuthenticated - Whether user is authenticated
79
+ * @param {string} wacAllow - WAC-Allow header value
80
+ */
81
+ export function handleUnauthorized(reply, isAuthenticated, wacAllow) {
82
+ reply.header('WAC-Allow', wacAllow);
83
+
84
+ if (!isAuthenticated) {
85
+ // Not authenticated - return 401
86
+ return reply.code(401).send({
87
+ error: 'Unauthorized',
88
+ message: 'Authentication required'
89
+ });
90
+ } else {
91
+ // Authenticated but not authorized - return 403
92
+ return reply.code(403).send({
93
+ error: 'Forbidden',
94
+ message: 'Access denied'
95
+ });
96
+ }
97
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Simple token-based authentication
3
+ *
4
+ * For now, we use a simple JWT-like approach:
5
+ * - Token format: base64(JSON({webId, iat, exp}))
6
+ * - In production, this would be replaced with proper Solid-OIDC DPoP tokens
7
+ */
8
+
9
+ import crypto from 'crypto';
10
+
11
+ // Secret for signing tokens (in production, use env var)
12
+ const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
13
+
14
+ /**
15
+ * Create a simple token for a WebID
16
+ * @param {string} webId - The WebID to create token for
17
+ * @param {number} expiresIn - Expiration time in seconds (default 1 hour)
18
+ * @returns {string} Token string
19
+ */
20
+ export function createToken(webId, expiresIn = 3600) {
21
+ const payload = {
22
+ webId,
23
+ iat: Math.floor(Date.now() / 1000),
24
+ exp: Math.floor(Date.now() / 1000) + expiresIn
25
+ };
26
+
27
+ const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
28
+ const signature = crypto
29
+ .createHmac('sha256', SECRET)
30
+ .update(data)
31
+ .digest('base64url');
32
+
33
+ return `${data}.${signature}`;
34
+ }
35
+
36
+ /**
37
+ * Verify and decode a token
38
+ * @param {string} token - The token to verify
39
+ * @returns {{webId: string, iat: number, exp: number} | null} Decoded payload or null
40
+ */
41
+ export function verifyToken(token) {
42
+ if (!token || typeof token !== 'string') {
43
+ return null;
44
+ }
45
+
46
+ const parts = token.split('.');
47
+ if (parts.length !== 2) {
48
+ return null;
49
+ }
50
+
51
+ const [data, signature] = parts;
52
+
53
+ // Verify signature
54
+ const expectedSig = crypto
55
+ .createHmac('sha256', SECRET)
56
+ .update(data)
57
+ .digest('base64url');
58
+
59
+ if (signature !== expectedSig) {
60
+ return null;
61
+ }
62
+
63
+ // Decode payload
64
+ try {
65
+ const payload = JSON.parse(Buffer.from(data, 'base64url').toString());
66
+
67
+ // Check expiration
68
+ if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
69
+ return null;
70
+ }
71
+
72
+ return payload;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Extract token from Authorization header
80
+ * @param {string} authHeader - Authorization header value
81
+ * @returns {string | null} Token or null
82
+ */
83
+ export function extractToken(authHeader) {
84
+ if (!authHeader || typeof authHeader !== 'string') {
85
+ return null;
86
+ }
87
+
88
+ // Support "Bearer <token>" format
89
+ if (authHeader.startsWith('Bearer ')) {
90
+ return authHeader.slice(7);
91
+ }
92
+
93
+ // Also support raw token
94
+ return authHeader;
95
+ }
96
+
97
+ /**
98
+ * Extract WebID from request
99
+ * @param {object} request - Fastify request object
100
+ * @returns {string | null} WebID or null if not authenticated
101
+ */
102
+ export function getWebIdFromRequest(request) {
103
+ const authHeader = request.headers.authorization;
104
+ const token = extractToken(authHeader);
105
+
106
+ if (!token) {
107
+ return null;
108
+ }
109
+
110
+ const payload = verifyToken(token);
111
+ return payload?.webId || null;
112
+ }
@@ -1,6 +1,9 @@
1
1
  import * as storage from '../storage/filesystem.js';
2
2
  import { getAllHeaders } from '../ldp/headers.js';
3
3
  import { isContainer } from '../utils/url.js';
4
+ import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
5
+ import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, serializeAcl } from '../wac/parser.js';
6
+ import { createToken } from '../auth/token.js';
4
7
 
5
8
  /**
6
9
  * Handle POST request to container (create new resource)
@@ -69,6 +72,16 @@ export async function handlePost(request, reply) {
69
72
  /**
70
73
  * Create a pod (container) for a user
71
74
  * POST /.pods with { "name": "alice" }
75
+ *
76
+ * Creates the following structure:
77
+ * /{name}/
78
+ * /{name}/profile/card - WebID profile
79
+ * /{name}/inbox/ - Notifications
80
+ * /{name}/public/ - Public files
81
+ * /{name}/private/ - Private files
82
+ * /{name}/settings/prefs - Preferences
83
+ * /{name}/settings/publicTypeIndex
84
+ * /{name}/settings/privateTypeIndex
72
85
  */
73
86
  export async function handleCreatePod(request, reply) {
74
87
  const { name } = request.body || {};
@@ -89,22 +102,73 @@ export async function handleCreatePod(request, reply) {
89
102
  return reply.code(409).send({ error: 'Pod already exists' });
90
103
  }
91
104
 
92
- // Create pod container
93
- const success = await storage.createContainer(podPath);
94
- if (!success) {
105
+ // Build URIs
106
+ // WebID is at pod root: /alice/#me
107
+ const baseUri = `${request.protocol}://${request.hostname}`;
108
+ const podUri = `${baseUri}${podPath}`;
109
+ const webId = `${podUri}#me`;
110
+ const issuer = baseUri;
111
+
112
+ try {
113
+ // Create pod directory structure
114
+ await storage.createContainer(podPath);
115
+ await storage.createContainer(`${podPath}inbox/`);
116
+ await storage.createContainer(`${podPath}public/`);
117
+ await storage.createContainer(`${podPath}private/`);
118
+ await storage.createContainer(`${podPath}settings/`);
119
+
120
+ // Generate and write WebID profile as index.html at pod root
121
+ const profileHtml = generateProfile({ webId, name, podUri, issuer });
122
+ await storage.write(`${podPath}index.html`, profileHtml);
123
+
124
+ // Generate and write preferences
125
+ const prefs = generatePreferences({ webId, podUri });
126
+ await storage.write(`${podPath}settings/prefs`, serialize(prefs));
127
+
128
+ // Generate and write type indexes
129
+ const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex`);
130
+ await storage.write(`${podPath}settings/publicTypeIndex`, serialize(publicTypeIndex));
131
+
132
+ const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex`);
133
+ await storage.write(`${podPath}settings/privateTypeIndex`, serialize(privateTypeIndex));
134
+
135
+ // Create default ACL files
136
+ // Pod root: owner full control, public read
137
+ const rootAcl = generateOwnerAcl(podUri, webId, true);
138
+ await storage.write(`${podPath}.acl`, serializeAcl(rootAcl));
139
+
140
+ // Private folder: owner only (no public)
141
+ const privateAcl = generatePrivateAcl(`${podUri}private/`, webId);
142
+ await storage.write(`${podPath}private/.acl`, serializeAcl(privateAcl));
143
+
144
+ // Settings folder: owner only
145
+ const settingsAcl = generatePrivateAcl(`${podUri}settings/`, webId);
146
+ await storage.write(`${podPath}settings/.acl`, serializeAcl(settingsAcl));
147
+
148
+ // Inbox: owner full, public append
149
+ const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId);
150
+ await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl));
151
+
152
+ } catch (err) {
153
+ console.error('Pod creation error:', err);
154
+ // Cleanup on failure
155
+ await storage.remove(podPath);
95
156
  return reply.code(500).send({ error: 'Failed to create pod' });
96
157
  }
97
158
 
98
- const location = `${request.protocol}://${request.hostname}${podPath}`;
99
159
  const origin = request.headers.origin;
100
-
101
160
  const headers = getAllHeaders({ isContainer: true, origin });
102
- headers['Location'] = location;
161
+ headers['Location'] = podUri;
103
162
 
104
163
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
105
164
 
165
+ // Generate token for the pod owner
166
+ const token = createToken(webId);
167
+
106
168
  return reply.code(201).send({
107
169
  name,
108
- url: location
170
+ webId,
171
+ podUri,
172
+ token
109
173
  });
110
174
  }
@@ -15,18 +15,41 @@ export async function handleGet(request, reply) {
15
15
  }
16
16
 
17
17
  const origin = request.headers.origin;
18
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
18
19
 
19
20
  // Handle container
20
21
  if (stats.isDirectory) {
22
+ // Check for index.html (serves as both profile and container representation)
23
+ const indexPath = urlPath.endsWith('/') ? `${urlPath}index.html` : `${urlPath}/index.html`;
24
+ const indexExists = await storage.exists(indexPath);
25
+
26
+ if (indexExists) {
27
+ // Serve index.html (contains JSON-LD structured data)
28
+ const content = await storage.read(indexPath);
29
+ const indexStats = await storage.stat(indexPath);
30
+
31
+ const headers = getAllHeaders({
32
+ isContainer: true,
33
+ etag: indexStats?.etag || stats.etag,
34
+ contentType: 'text/html',
35
+ origin,
36
+ resourceUrl
37
+ });
38
+
39
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
40
+ return reply.send(content);
41
+ }
42
+
43
+ // No index.html, return JSON-LD container listing
21
44
  const entries = await storage.listContainer(urlPath);
22
- const baseUrl = `${request.protocol}://${request.hostname}${urlPath}`;
23
- const jsonLd = generateContainerJsonLd(baseUrl, entries || []);
45
+ const jsonLd = generateContainerJsonLd(resourceUrl, entries || []);
24
46
 
25
47
  const headers = getAllHeaders({
26
48
  isContainer: true,
27
49
  etag: stats.etag,
28
50
  contentType: 'application/ld+json',
29
- origin
51
+ origin,
52
+ resourceUrl
30
53
  });
31
54
 
32
55
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
@@ -44,7 +67,8 @@ export async function handleGet(request, reply) {
44
67
  isContainer: false,
45
68
  etag: stats.etag,
46
69
  contentType,
47
- origin
70
+ origin,
71
+ resourceUrl
48
72
  });
49
73
 
50
74
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
@@ -63,13 +87,15 @@ export async function handleHead(request, reply) {
63
87
  }
64
88
 
65
89
  const origin = request.headers.origin;
90
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
66
91
  const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(urlPath);
67
92
 
68
93
  const headers = getAllHeaders({
69
94
  isContainer: stats.isDirectory,
70
95
  etag: stats.etag,
71
96
  contentType,
72
- origin
97
+ origin,
98
+ resourceUrl
73
99
  });
74
100
 
75
101
  if (!stats.isDirectory) {
@@ -114,8 +140,9 @@ export async function handlePut(request, reply) {
114
140
  }
115
141
 
116
142
  const origin = request.headers.origin;
117
- const headers = getAllHeaders({ isContainer: false, origin });
118
- headers['Location'] = `${request.protocol}://${request.hostname}${urlPath}`;
143
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
144
+ const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
145
+ headers['Location'] = resourceUrl;
119
146
 
120
147
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
121
148
  return reply.code(existed ? 204 : 201).send();
@@ -138,7 +165,8 @@ export async function handleDelete(request, reply) {
138
165
  }
139
166
 
140
167
  const origin = request.headers.origin;
141
- const headers = getAllHeaders({ isContainer: false, origin });
168
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
169
+ const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
142
170
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
143
171
 
144
172
  return reply.code(204).send();
@@ -152,9 +180,11 @@ export async function handleOptions(request, reply) {
152
180
  const stats = await storage.stat(urlPath);
153
181
 
154
182
  const origin = request.headers.origin;
183
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
155
184
  const headers = getAllHeaders({
156
185
  isContainer: stats?.isDirectory || isContainer(urlPath),
157
- origin
186
+ origin,
187
+ resourceUrl
158
188
  });
159
189
 
160
190
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
@@ -7,9 +7,10 @@ const LDP = 'http://www.w3.org/ns/ldp#';
7
7
  /**
8
8
  * Get Link headers for a resource
9
9
  * @param {boolean} isContainer
10
+ * @param {string} aclUrl - URL to the ACL resource
10
11
  * @returns {string}
11
12
  */
12
- export function getLinkHeader(isContainer) {
13
+ export function getLinkHeader(isContainer, aclUrl = null) {
13
14
  const links = [`<${LDP}Resource>; rel="type"`];
14
15
 
15
16
  if (isContainer) {
@@ -17,18 +18,42 @@ export function getLinkHeader(isContainer) {
17
18
  links.push(`<${LDP}BasicContainer>; rel="type"`);
18
19
  }
19
20
 
21
+ // Add acl link for auxiliary resource discovery
22
+ if (aclUrl) {
23
+ links.push(`<${aclUrl}>; rel="acl"`);
24
+ }
25
+
20
26
  return links.join(', ');
21
27
  }
22
28
 
29
+ /**
30
+ * Get the ACL URL for a resource
31
+ * @param {string} resourceUrl - Full URL of the resource
32
+ * @param {boolean} isContainer - Whether the resource is a container
33
+ * @returns {string} ACL URL
34
+ */
35
+ export function getAclUrl(resourceUrl, isContainer) {
36
+ if (isContainer) {
37
+ // Container ACL: /path/.acl
38
+ const base = resourceUrl.endsWith('/') ? resourceUrl : resourceUrl + '/';
39
+ return base + '.acl';
40
+ }
41
+ // Resource ACL: /path/file.acl
42
+ return resourceUrl + '.acl';
43
+ }
44
+
23
45
  /**
24
46
  * Get standard LDP response headers
25
47
  * @param {object} options
26
48
  * @returns {object}
27
49
  */
28
- export function getResponseHeaders({ isContainer = false, etag = null, contentType = null }) {
50
+ export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null }) {
51
+ // Calculate ACL URL if resource URL provided
52
+ const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
53
+
29
54
  const headers = {
30
- 'Link': getLinkHeader(isContainer),
31
- 'WAC-Allow': 'user="read write append control", public="read write append"',
55
+ 'Link': getLinkHeader(isContainer, aclUrl),
56
+ 'WAC-Allow': wacAllow || 'user="read write append control", public="read write append"',
32
57
  'Accept-Patch': 'application/sparql-update',
33
58
  'Allow': 'GET, HEAD, PUT, DELETE, OPTIONS' + (isContainer ? ', POST' : ''),
34
59
  'Vary': 'Accept, Authorization, Origin'
@@ -70,9 +95,9 @@ export function getCorsHeaders(origin) {
70
95
  * @param {object} options
71
96
  * @returns {object}
72
97
  */
73
- export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null }) {
98
+ export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null }) {
74
99
  return {
75
- ...getResponseHeaders({ isContainer, etag, contentType }),
100
+ ...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow }),
76
101
  ...getCorsHeaders(origin)
77
102
  };
78
103
  }
package/src/server.js CHANGED
@@ -2,6 +2,7 @@ import Fastify from 'fastify';
2
2
  import { handleGet, handleHead, handlePut, handleDelete, handleOptions } from './handlers/resource.js';
3
3
  import { handlePost, handleCreatePod } from './handlers/container.js';
4
4
  import { getCorsHeaders } from './ldp/headers.js';
5
+ import { authorize, handleUnauthorized } from './auth/middleware.js';
5
6
 
6
7
  /**
7
8
  * Create and configure Fastify server
@@ -25,13 +26,34 @@ export function createServer(options = {}) {
25
26
  const corsHeaders = getCorsHeaders(request.headers.origin);
26
27
  Object.entries(corsHeaders).forEach(([k, v]) => reply.header(k, v));
27
28
 
28
- // Handle preflight
29
+ // Handle preflight OPTIONS
29
30
  if (request.method === 'OPTIONS') {
31
+ // Add Allow header for LDP compliance
32
+ reply.header('Allow', 'GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS');
30
33
  reply.code(204).send();
31
34
  return reply;
32
35
  }
33
36
  });
34
37
 
38
+ // Authorization hook - check WAC permissions
39
+ // Skip for pod creation endpoint (needs special handling)
40
+ fastify.addHook('preHandler', async (request, reply) => {
41
+ // Skip auth for pod creation and OPTIONS
42
+ if (request.url === '/.pods' || request.method === 'OPTIONS') {
43
+ return;
44
+ }
45
+
46
+ const { authorized, webId, wacAllow } = await authorize(request, reply);
47
+
48
+ // Store webId and wacAllow on request for handlers to use
49
+ request.webId = webId;
50
+ request.wacAllow = wacAllow;
51
+
52
+ if (!authorized) {
53
+ return handleUnauthorized(reply, webId !== null, wacAllow);
54
+ }
55
+ });
56
+
35
57
  // Pod creation endpoint
36
58
  fastify.post('/.pods', handleCreatePod);
37
59