javascript-solid-server 0.0.40 → 0.0.42

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.
@@ -131,7 +131,13 @@
131
131
  "Bash(git reset:*)",
132
132
  "Bash(echo:*)",
133
133
  "Bash(unset DATA_ROOT)",
134
- "Bash(timeout 30 npm test:*)"
134
+ "Bash(timeout 30 npm test:*)",
135
+ "Bash(/home/melvin/remote/github.com/JavaScriptSolidServer/git-credential-nostr/bin/git-credential-nostr acl:*)",
136
+ "Bash(npm cache clean:*)",
137
+ "Bash(git checkout:*)",
138
+ "Bash(gh gist edit:*)",
139
+ "Bash(gh gist view:*)",
140
+ "Bash(./bin/git-credential-nostr acl:*)"
135
141
  ]
136
142
  }
137
143
  }
package/README.md CHANGED
@@ -4,7 +4,7 @@ A minimal, fast, JSON-LD native Solid server.
4
4
 
5
5
  ## Features
6
6
 
7
- ### Implemented (v0.0.39)
7
+ ### Implemented (v0.0.42)
8
8
 
9
9
  - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
10
10
  - **N3 Patch** - Solid's native patch format for RDF updates
@@ -18,7 +18,7 @@ A minimal, fast, JSON-LD native Solid server.
18
18
  - **Subdomain Mode** - XSS protection via origin isolation
19
19
  - **Mashlib Data Browser** - Optional SolidOS UI (CDN or local hosting)
20
20
  - **WebID Profiles** - HTML with JSON-LD data islands, rendered with mashlib-jss + solidos-lite
21
- - **Web Access Control (WAC)** - `.acl` file-based authorization
21
+ - **Web Access Control (WAC)** - `.acl` file-based authorization with relative URL support
22
22
  - **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, RS256/ES256, dynamic registration
23
23
  - **Solid-OIDC Resource Server** - Accept DPoP-bound access tokens from external IdPs
24
24
  - **NSS-style Registration** - Username/password auth compatible with Solid apps
@@ -354,38 +354,29 @@ git push
354
354
 
355
355
  Git operations respect WAC permissions - clone requires Read access, push requires Write access.
356
356
 
357
+ **Auto-checkout:** After a successful push to a non-bare repository, JSS automatically updates the working directory - no post-receive hooks needed.
358
+
357
359
  ### Git Push with Nostr Authentication
358
360
 
359
361
  Git push supports NIP-98 authentication via Basic Auth. Install the credential helper:
360
362
 
361
363
  ```bash
362
364
  npm install -g git-credential-nostr
365
+ git-credential-nostr generate
363
366
  git config --global credential.helper nostr
367
+ git config --global nostr.privkey <key-from-generate>
364
368
  ```
365
369
 
366
- Generate or configure your Nostr key:
370
+ Create an ACL for your repo (includes public read for clone + owner write for push):
367
371
 
368
372
  ```bash
369
- # Generate a new keypair
370
- git-credential-nostr generate
371
-
372
- # Or use an existing private key
373
- git config --global nostr.privkey YOUR_64_CHAR_HEX_PRIVKEY
373
+ cd myrepo
374
+ git-credential-nostr acl > .acl
375
+ git add .acl && git commit -m "Add ACL"
374
376
  ```
375
377
 
376
378
  See [git-credential-nostr](https://github.com/JavaScriptSolidServer/git-credential-nostr) for more details.
377
379
 
378
- Add the Nostr identity to your ACL:
379
-
380
- ```turtle
381
- <#nostr-writer>
382
- a acl:Authorization;
383
- acl:agent <did:nostr:YOUR_64_CHAR_HEX_PUBKEY>;
384
- acl:accessTo <./>;
385
- acl:default <./>;
386
- acl:mode acl:Read, acl:Write.
387
- ```
388
-
389
380
  ## Authentication
390
381
 
391
382
  ### Simple Tokens (Development)
@@ -558,7 +549,7 @@ npm run benchmark
558
549
  npm test
559
550
  ```
560
551
 
561
- Currently passing: **187 tests** (including 27 conformance tests)
552
+ Currently passing: **191 tests** (including 27 conformance tests)
562
553
 
563
554
  ### Conformance Test Harness (CTH)
564
555
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.40",
3
+ "version": "0.0.42",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -201,6 +201,18 @@ export async function handleGit(request, reply) {
201
201
  if (code !== 0 && !headersSent) {
202
202
  reply.code(500).send({ error: 'Git operation failed' });
203
203
  }
204
+
205
+ // Auto-checkout working directory after successful push to non-bare repo
206
+ if (code === 0 && isGitWriteOperation(urlPath) && gitInfo.isRegular) {
207
+ const checkout = spawn('git', ['checkout', '-f'], {
208
+ cwd: repoAbs,
209
+ env: { ...process.env, GIT_DIR: gitInfo.gitDir }
210
+ });
211
+ checkout.on('error', (err) => {
212
+ console.error('Auto-checkout failed:', err.message);
213
+ });
214
+ }
215
+
204
216
  resolve();
205
217
  });
206
218
  });
@@ -55,12 +55,16 @@ export function getResponseHeaders({ isContainer = false, etag = null, contentTy
55
55
 
56
56
  const headers = {
57
57
  'Link': getLinkHeader(isContainer, aclUrl),
58
- 'WAC-Allow': wacAllow || 'user="read write append control", public="read write append"',
59
58
  'Accept-Patch': 'text/n3, application/sparql-update',
60
59
  'Allow': 'GET, HEAD, PUT, DELETE, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
61
60
  'Vary': connegEnabled ? 'Accept, Authorization, Origin' : 'Authorization, Origin'
62
61
  };
63
62
 
63
+ // Only set WAC-Allow if explicitly provided (otherwise the auth hook sets it)
64
+ if (wacAllow) {
65
+ headers['WAC-Allow'] = wacAllow;
66
+ }
67
+
64
68
  // Add Accept-* headers (conneg-aware)
65
69
  const acceptHeaders = getAcceptHeaders(connegEnabled, isContainer);
66
70
  Object.assign(headers, acceptHeaders);
package/src/server.js CHANGED
@@ -208,6 +208,9 @@ export function createServer(options = {}) {
208
208
  request.webId = webId;
209
209
  request.wacAllow = wacAllow;
210
210
 
211
+ // Set WAC-Allow header for all responses (handlers may override)
212
+ reply.header('WAC-Allow', wacAllow);
213
+
211
214
  if (!authorized) {
212
215
  return handleUnauthorized(reply, webId !== null, wacAllow, authError);
213
216
  }
@@ -71,22 +71,30 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
71
71
  }
72
72
 
73
73
  // Walk up the hierarchy looking for default ACLs
74
- let currentPath = resourcePath;
75
- while (currentPath && currentPath !== '/') {
74
+ // Track both storage path (for file lookup) and URL path (for URL construction)
75
+ let currentStoragePath = resourcePath;
76
+ let currentUrlPath = new URL(resourceUrl).pathname;
77
+
78
+ while (currentStoragePath && currentStoragePath !== '/') {
76
79
  // Get parent container
77
- const parentPath = getParentPath(currentPath);
78
- const parentAclPath = parentPath + '.acl';
80
+ const parentStoragePath = getParentPath(currentStoragePath);
81
+ const parentAclPath = parentStoragePath + '.acl';
79
82
 
80
83
  if (await storage.exists(parentAclPath)) {
81
84
  const content = await storage.read(parentAclPath);
82
85
  if (content) {
83
- const parentUrl = resourceUrl.substring(0, resourceUrl.lastIndexOf(currentPath)) + parentPath;
84
- const authorizations = await parseAcl(content.toString(), parentAclPath);
86
+ // Get parent URL path and construct full URL
87
+ const parentUrlPath = getParentPath(currentUrlPath);
88
+ const origin = resourceUrl.substring(0, resourceUrl.indexOf('/', 8));
89
+ const parentUrl = origin + parentUrlPath;
90
+ const parentAclUrl = getAclUrl(parentUrl, true); // Container ACL URL
91
+ const authorizations = await parseAcl(content.toString(), parentAclUrl);
85
92
  return { authorizations, isDefault: true, targetUrl: parentUrl };
86
93
  }
87
94
  }
88
95
 
89
- currentPath = parentPath;
96
+ currentStoragePath = parentStoragePath;
97
+ currentUrlPath = getParentPath(currentUrlPath);
90
98
  }
91
99
 
92
100
  // Check root ACL
@@ -94,7 +102,8 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
94
102
  const content = await storage.read('/.acl');
95
103
  if (content) {
96
104
  const rootUrl = resourceUrl.substring(0, resourceUrl.indexOf('/', 8) + 1);
97
- const authorizations = await parseAcl(content.toString(), '/.acl');
105
+ const rootAclUrl = getAclUrl(rootUrl, true); // Root container ACL URL
106
+ const authorizations = await parseAcl(content.toString(), rootAclUrl);
98
107
  return { authorizations, isDefault: true, targetUrl: rootUrl };
99
108
  }
100
109
  }
package/src/wac/parser.js CHANGED
@@ -83,10 +83,48 @@ function isAuthorization(node) {
83
83
  );
84
84
  }
85
85
 
86
+ /**
87
+ * Get base URL from ACL URL (the container the ACL applies to)
88
+ * e.g., https://example.com/foo/.acl -> https://example.com/foo/
89
+ * https://example.com/foo/bar.acl -> https://example.com/foo/
90
+ */
91
+ function getBaseUrl(aclUrl) {
92
+ if (!aclUrl) return null;
93
+ // Remove .acl suffix and get the directory
94
+ const withoutAcl = aclUrl.replace(/\.acl$/, '');
95
+ // If it was a container ACL (ended with /.acl), withoutAcl ends with /
96
+ // If it was a resource ACL (foo.acl), we need the parent directory
97
+ if (withoutAcl.endsWith('/')) {
98
+ return withoutAcl;
99
+ }
100
+ // Get parent directory
101
+ const lastSlash = withoutAcl.lastIndexOf('/');
102
+ return lastSlash > 0 ? withoutAcl.substring(0, lastSlash + 1) : withoutAcl;
103
+ }
104
+
105
+ /**
106
+ * Resolve a URI against a base URL
107
+ */
108
+ function resolveUri(uri, baseUrl) {
109
+ if (!uri || !baseUrl) return uri;
110
+ // Already absolute
111
+ if (uri.startsWith('http://') || uri.startsWith('https://')) return uri;
112
+ // Fragment-only (like #owner) - not a resource URL
113
+ if (uri.startsWith('#')) return uri;
114
+ // Resolve relative URL
115
+ try {
116
+ return new URL(uri, baseUrl).href;
117
+ } catch {
118
+ return uri;
119
+ }
120
+ }
121
+
86
122
  /**
87
123
  * Parse a single Authorization node
88
124
  */
89
125
  function parseAuthorization(node, aclUrl) {
126
+ const baseUrl = getBaseUrl(aclUrl);
127
+
90
128
  const auth = {
91
129
  id: node['@id'],
92
130
  accessTo: [], // Resources this applies to
@@ -97,13 +135,15 @@ function parseAuthorization(node, aclUrl) {
97
135
  modes: [] // Access modes
98
136
  };
99
137
 
100
- // Parse accessTo
101
- auth.accessTo = parseUriArray(node['acl:accessTo'] || node['accessTo']);
138
+ // Parse accessTo - resolve relative URLs
139
+ auth.accessTo = parseUriArray(node['acl:accessTo'] || node['accessTo'])
140
+ .map(uri => resolveUri(uri, baseUrl));
102
141
 
103
- // Parse default (for containers)
104
- auth.default = parseUriArray(node['acl:default'] || node['default']);
142
+ // Parse default (for containers) - resolve relative URLs
143
+ auth.default = parseUriArray(node['acl:default'] || node['default'])
144
+ .map(uri => resolveUri(uri, baseUrl));
105
145
 
106
- // Parse agents
146
+ // Parse agents (WebIDs can be relative too)
107
147
  auth.agents = parseUriArray(node['acl:agent'] || node['agent']);
108
148
 
109
149
  // Parse agentClass
package/test/wac.test.js CHANGED
@@ -98,6 +98,75 @@ describe('WAC Parser', () => {
98
98
  assert.ok(auths[0].modes.includes(AccessMode.READ));
99
99
  assert.ok(auths[0].modes.includes(AccessMode.WRITE));
100
100
  });
101
+
102
+ it('should resolve relative accessTo URLs', async () => {
103
+ const acl = {
104
+ '@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
105
+ '@id': '#owner',
106
+ '@type': 'acl:Authorization',
107
+ 'acl:agent': { '@id': 'https://alice.example/#me' },
108
+ 'acl:accessTo': { '@id': './' },
109
+ 'acl:mode': [{ '@id': 'acl:Read' }]
110
+ };
111
+
112
+ const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/folder/.acl');
113
+
114
+ assert.strictEqual(auths.length, 1);
115
+ assert.ok(auths[0].accessTo.includes('https://alice.example/folder/'),
116
+ `Expected accessTo to include 'https://alice.example/folder/', got: ${auths[0].accessTo}`);
117
+ });
118
+
119
+ it('should resolve relative default URLs', async () => {
120
+ const acl = {
121
+ '@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
122
+ '@id': '#owner',
123
+ '@type': 'acl:Authorization',
124
+ 'acl:agent': { '@id': 'https://alice.example/#me' },
125
+ 'acl:accessTo': { '@id': './' },
126
+ 'acl:default': { '@id': './' },
127
+ 'acl:mode': [{ '@id': 'acl:Read' }]
128
+ };
129
+
130
+ const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/folder/.acl');
131
+
132
+ assert.strictEqual(auths.length, 1);
133
+ assert.ok(auths[0].default.includes('https://alice.example/folder/'),
134
+ `Expected default to include 'https://alice.example/folder/', got: ${auths[0].default}`);
135
+ });
136
+
137
+ it('should resolve parent-relative URLs like ../other/', async () => {
138
+ const acl = {
139
+ '@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
140
+ '@id': '#owner',
141
+ '@type': 'acl:Authorization',
142
+ 'acl:agent': { '@id': 'https://alice.example/#me' },
143
+ 'acl:accessTo': { '@id': '../other/' },
144
+ 'acl:mode': [{ '@id': 'acl:Read' }]
145
+ };
146
+
147
+ const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/folder/.acl');
148
+
149
+ assert.strictEqual(auths.length, 1);
150
+ assert.ok(auths[0].accessTo.includes('https://alice.example/other/'),
151
+ `Expected accessTo to include 'https://alice.example/other/', got: ${auths[0].accessTo}`);
152
+ });
153
+
154
+ it('should keep absolute URLs unchanged', async () => {
155
+ const acl = {
156
+ '@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
157
+ '@id': '#owner',
158
+ '@type': 'acl:Authorization',
159
+ 'acl:agent': { '@id': 'https://alice.example/#me' },
160
+ 'acl:accessTo': { '@id': 'https://other.example/resource' },
161
+ 'acl:mode': [{ '@id': 'acl:Read' }]
162
+ };
163
+
164
+ const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/folder/.acl');
165
+
166
+ assert.strictEqual(auths.length, 1);
167
+ assert.ok(auths[0].accessTo.includes('https://other.example/resource'),
168
+ `Expected accessTo to include 'https://other.example/resource', got: ${auths[0].accessTo}`);
169
+ });
101
170
  });
102
171
 
103
172
  describe('generateOwnerAcl', () => {