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.
- package/.claude/settings.local.json +7 -1
- package/README.md +11 -20
- package/package.json +1 -1
- package/src/handlers/git.js +12 -0
- package/src/ldp/headers.js +5 -1
- package/src/server.js +3 -0
- package/src/wac/checker.js +17 -8
- package/src/wac/parser.js +45 -5
- package/test/wac.test.js +69 -0
|
@@ -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.
|
|
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
|
-
|
|
370
|
+
Create an ACL for your repo (includes public read for clone + owner write for push):
|
|
367
371
|
|
|
368
372
|
```bash
|
|
369
|
-
|
|
370
|
-
git-credential-nostr
|
|
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: **
|
|
552
|
+
Currently passing: **191 tests** (including 27 conformance tests)
|
|
562
553
|
|
|
563
554
|
### Conformance Test Harness (CTH)
|
|
564
555
|
|
package/package.json
CHANGED
package/src/handlers/git.js
CHANGED
|
@@ -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
|
});
|
package/src/ldp/headers.js
CHANGED
|
@@ -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
|
}
|
package/src/wac/checker.js
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
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
|
|
78
|
-
const parentAclPath =
|
|
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
|
-
|
|
84
|
-
const
|
|
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
|
-
|
|
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
|
|
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', () => {
|