javascript-solid-server 0.0.38 → 0.0.40

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.
@@ -119,7 +119,19 @@
119
119
  "Bash(DATA_ROOT=/tmp/jss-git-test JSS_PORT=4444 timeout 3 node:*)",
120
120
  "Bash(pm2 show:*)",
121
121
  "Bash(git config:*)",
122
- "Bash(npm version:*)"
122
+ "Bash(npm version:*)",
123
+ "Bash(git init:*)",
124
+ "Bash(gh repo create:*)",
125
+ "Bash(./bin/git-credential-nostr generate:*)",
126
+ "Bash(./bin/git-credential-nostr get:*)",
127
+ "Bash(git-credential-nostr:*)",
128
+ "Bash(git branch:*)",
129
+ "Bash(GIT_TRACE=1 GIT_CURL_VERBOSE=1 git push:*)",
130
+ "Bash(GIT_CURL_VERBOSE=1 git push:*)",
131
+ "Bash(git reset:*)",
132
+ "Bash(echo:*)",
133
+ "Bash(unset DATA_ROOT)",
134
+ "Bash(timeout 30 npm test:*)"
123
135
  ]
124
136
  }
125
137
  }
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.37)
7
+ ### Implemented (v0.0.39)
8
8
 
9
9
  - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
10
10
  - **N3 Patch** - Solid's native patch format for RDF updates
@@ -356,47 +356,25 @@ Git operations respect WAC permissions - clone requires Read access, push requir
356
356
 
357
357
  ### Git Push with Nostr Authentication
358
358
 
359
- Git push supports NIP-98 authentication via Basic Auth. Create a credential helper:
359
+ Git push supports NIP-98 authentication via Basic Auth. Install the credential helper:
360
360
 
361
- ```javascript
362
- // git-credential-nostr.js
363
- import { getPublicKey, finalizeEvent } from 'nostr-tools/pure';
364
- import { hexToBytes } from '@noble/hashes/utils';
365
- import fs from 'fs';
366
- import readline from 'readline';
367
-
368
- const sk = hexToBytes(fs.readFileSync('.nostr-key', 'utf8').trim());
369
- const rl = readline.createInterface({ input: process.stdin });
370
- const params = {};
371
- for await (const line of rl) {
372
- if (!line) break;
373
- const [key, ...vals] = line.split('=');
374
- params[key] = vals.join('=');
375
- }
376
-
377
- if (params.protocol && params.host) {
378
- let path = params.path || '/';
379
- path = path.replace(/\/info\/refs$/, '').replace(/\/git-.*$/, '');
380
- const baseUrl = `${params.protocol}://${params.host}${path}`;
381
-
382
- const event = finalizeEvent({
383
- kind: 27235,
384
- created_at: Math.floor(Date.now() / 1000),
385
- tags: [['u', baseUrl], ['method', '*']],
386
- content: ''
387
- }, sk);
388
-
389
- console.log('username=nostr');
390
- console.log('password=' + Buffer.from(JSON.stringify(event)).toString('base64'));
391
- }
361
+ ```bash
362
+ npm install -g git-credential-nostr
363
+ git config --global credential.helper nostr
392
364
  ```
393
365
 
394
- Configure git to use it:
366
+ Generate or configure your Nostr key:
395
367
 
396
368
  ```bash
397
- git config credential.helper 'node /path/to/git-credential-nostr.js'
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
398
374
  ```
399
375
 
376
+ See [git-credential-nostr](https://github.com/JavaScriptSolidServer/git-credential-nostr) for more details.
377
+
400
378
  Add the Nostr identity to your ACL:
401
379
 
402
380
  ```turtle
@@ -580,7 +558,7 @@ npm run benchmark
580
558
  npm test
581
559
  ```
582
560
 
583
- Currently passing: **182 tests** (including 27 conformance tests)
561
+ Currently passing: **187 tests** (including 27 conformance tests)
584
562
 
585
563
  ### Conformance Test Harness (CTH)
586
564
 
@@ -628,7 +606,8 @@ src/
628
606
  ├── auth/
629
607
  │ ├── middleware.js # Auth hook
630
608
  │ ├── token.js # Simple token auth
631
- └── solid-oidc.js # DPoP verification
609
+ ├── solid-oidc.js # DPoP verification
610
+ │ └── nostr.js # NIP-98 Nostr authentication
632
611
  ├── wac/
633
612
  │ ├── parser.js # ACL parsing
634
613
  │ └── checker.js # Permission checking
package/cth.env CHANGED
@@ -5,11 +5,11 @@ SOLID_IDENTITY_PROVIDER=http://localhost:3456
5
5
  RESOURCE_SERVER_ROOT=http://localhost:3456
6
6
  TEST_CONTAINER=alice/public/
7
7
 
8
- USERS_ALICE_WEBID=http://localhost:3456/alice/#me
8
+ USERS_ALICE_WEBID=http://localhost:3456/alice/profile/card#me
9
9
  USERS_ALICE_USERNAME=alice@test.local
10
10
  USERS_ALICE_PASSWORD=alicepassword123
11
11
 
12
- USERS_BOB_WEBID=http://localhost:3456/bob/#me
12
+ USERS_BOB_WEBID=http://localhost:3456/bob/profile/card#me
13
13
  USERS_BOB_USERNAME=bob@test.local
14
14
  USERS_BOB_PASSWORD=bobpassword123
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.38",
3
+ "version": "0.0.40",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -13,9 +13,11 @@ import { getEffectiveUrlPath } from '../utils/url.js';
13
13
  * Check if request is authorized
14
14
  * @param {object} request - Fastify request
15
15
  * @param {object} reply - Fastify reply
16
+ * @param {object} options - Optional settings
17
+ * @param {string} options.requiredMode - Override the required access mode (e.g., 'Write' for git push)
16
18
  * @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string, authError: string|null}>}
17
19
  */
18
- export async function authorize(request, reply) {
20
+ export async function authorize(request, reply, options = {}) {
19
21
  const urlPath = request.url.split('?')[0];
20
22
  const method = request.method;
21
23
 
@@ -44,8 +46,8 @@ export async function authorize(request, reply) {
44
46
  // Build resource URL (uses actual request hostname which may be subdomain)
45
47
  const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
46
48
 
47
- // Get required access mode for this method
48
- const requiredMode = getRequiredMode(method);
49
+ // Get required access mode - use override if provided, otherwise derive from method
50
+ const requiredMode = options.requiredMode || getRequiredMode(method);
49
51
 
50
52
  // For write operations on non-existent resources, check parent container
51
53
  let checkPath = storagePath;
@@ -19,7 +19,7 @@ export function isGitRequest(urlPath) {
19
19
  * @returns {boolean}
20
20
  */
21
21
  export function isGitWriteOperation(urlPath) {
22
- return urlPath.includes('/git-receive-pack');
22
+ return urlPath.includes('/git-receive-pack') || urlPath.includes('service=git-receive-pack');
23
23
  }
24
24
 
25
25
  /**
@@ -320,10 +320,19 @@ export async function handleGet(request, reply) {
320
320
  }
321
321
 
322
322
  // Serve content as-is (no conneg or non-RDF resource)
323
+ // For extensionless files (like profile/card), detect HTML by content
324
+ let actualContentType = storedContentType;
325
+ if (storedContentType === 'application/octet-stream') {
326
+ const contentStr = content.toString().trimStart();
327
+ if (contentStr.startsWith('<!DOCTYPE') || contentStr.startsWith('<html')) {
328
+ actualContentType = 'text/html';
329
+ }
330
+ }
331
+
323
332
  const headers = getAllHeaders({
324
333
  isContainer: false,
325
334
  etag: stats.etag,
326
- contentType: storedContentType,
335
+ contentType: actualContentType,
327
336
  origin,
328
337
  resourceUrl,
329
338
  connegEnabled
package/src/server.js CHANGED
@@ -9,6 +9,7 @@ import { authorize, handleUnauthorized } from './auth/middleware.js';
9
9
  import { notificationsPlugin } from './notifications/index.js';
10
10
  import { idpPlugin } from './idp/index.js';
11
11
  import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
12
+ import { AccessMode } from './wac/parser.js';
12
13
 
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
15
 
@@ -135,7 +136,7 @@ export function createServer(options = {}) {
135
136
  // Security: Block access to dotfiles except allowed Solid-specific ones
136
137
  // This prevents exposure of .git/, .env, .htpasswd, etc.
137
138
  // Git protocol requests bypass this check when git is enabled
138
- const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta'];
139
+ const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta', '.pods', '.notifications'];
139
140
  fastify.addHook('onRequest', async (request, reply) => {
140
141
  // Allow git protocol requests through when git is enabled
141
142
  if (gitEnabled && isGitRequest(request.url)) {
@@ -162,15 +163,22 @@ export function createServer(options = {}) {
162
163
  return;
163
164
  }
164
165
 
165
- // Run WAC authorization - checkAccess already verifies the required mode
166
- const { authorized, webId, wacAllow, authError } = await authorize(request, reply);
166
+ // Determine required mode: Write for push, Read for clone/fetch
167
+ const needsWrite = isGitWriteOperation(request.url);
168
+ const requiredMode = needsWrite ? AccessMode.WRITE : AccessMode.READ;
169
+
170
+ // Run WAC authorization with the correct mode for git operations
171
+ const { authorized, webId, wacAllow, authError } = await authorize(request, reply, { requiredMode });
167
172
  request.webId = webId;
168
173
  request.wacAllow = wacAllow;
169
174
 
170
175
  if (!authorized) {
171
- const needsWrite = isGitWriteOperation(request.url);
172
176
  const message = needsWrite ? 'Write access required for push' : 'Read access required for clone';
173
177
  reply.header('WAC-Allow', wacAllow);
178
+ if (!webId) {
179
+ // No authentication - request Basic auth for git clients
180
+ reply.header('WWW-Authenticate', 'Basic realm="Solid"');
181
+ }
174
182
  return reply.code(webId ? 403 : 401).send({ error: message });
175
183
  }
176
184
 
@@ -1,10 +1,9 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import crypto from 'crypto';
4
- import { DATA_ROOT, urlToPath, isContainer } from '../utils/url.js';
4
+ import { getDataRoot, urlToPath, isContainer } from '../utils/url.js';
5
5
 
6
- // Ensure data directory exists
7
- fs.ensureDirSync(DATA_ROOT);
6
+ // Note: Data directory is ensured in server.js after DATA_ROOT is set
8
7
 
9
8
  /**
10
9
  * Check if resource exists
package/src/utils/url.js CHANGED
@@ -1,7 +1,19 @@
1
1
  import path from 'path';
2
2
 
3
3
  // Base directory for storing all pods
4
- export const DATA_ROOT = process.env.DATA_ROOT || './data';
4
+ // Use a getter function to read env var at runtime (not import time)
5
+ // This is necessary because ES modules are loaded before the CLI sets the env var
6
+ export function getDataRoot() {
7
+ return process.env.DATA_ROOT || './data';
8
+ }
9
+
10
+ // Legacy export - kept for compatibility, but callers should use getDataRoot()
11
+ export let DATA_ROOT = './data';
12
+
13
+ // Update DATA_ROOT when env var is set (called from storage init)
14
+ export function updateDataRoot() {
15
+ DATA_ROOT = getDataRoot();
16
+ }
5
17
 
6
18
  /**
7
19
  * Convert URL path to filesystem path
@@ -16,7 +28,7 @@ export function urlToPath(urlPath) {
16
28
  // Security: prevent path traversal
17
29
  normalized = normalized.replace(/\.\./g, '');
18
30
 
19
- return path.join(DATA_ROOT, normalized);
31
+ return path.join(getDataRoot(), normalized);
20
32
  }
21
33
 
22
34
  /**
@@ -35,7 +47,7 @@ export function urlToPathWithPod(urlPath, podName) {
35
47
  normalized = normalized.replace(/\.\./g, '');
36
48
 
37
49
  // Prepend pod name to path
38
- return path.join(DATA_ROOT, podName, normalized);
50
+ return path.join(getDataRoot(), podName, normalized);
39
51
  }
40
52
 
41
53
  /**
@@ -33,19 +33,20 @@ export async function checkAccess({
33
33
  return { allowed: true, wacAllow: 'user="read write append control", public="read write append"' };
34
34
  }
35
35
 
36
- const { authorizations, isDefault, targetUrl } = aclResult;
36
+ const { authorizations, isDefault, targetUrl: aclContainerUrl } = aclResult;
37
37
 
38
38
  // Check authorizations
39
+ // Note: For default ACLs, we check if the ACL's default rules apply to the actual resource URL
39
40
  const allowed = checkAuthorizations(
40
41
  authorizations,
41
- targetUrl,
42
+ resourceUrl, // Use actual resource URL, not the ACL container URL
42
43
  agentWebId,
43
44
  requiredMode,
44
45
  isDefault
45
46
  );
46
47
 
47
48
  // Calculate WAC-Allow header
48
- const wacAllow = calculateWacAllow(authorizations, targetUrl, agentWebId, isDefault);
49
+ const wacAllow = calculateWacAllow(authorizations, resourceUrl, agentWebId, isDefault);
49
50
 
50
51
  return { allowed, wacAllow };
51
52
  }
@@ -117,13 +118,17 @@ function getParentPath(path) {
117
118
  */
118
119
  function checkAuthorizations(authorizations, targetUrl, agentWebId, requiredMode, isDefault) {
119
120
  for (const auth of authorizations) {
120
- // Check if this authorization applies to the resource
121
- const appliesToResource = isDefault
122
- ? auth.default.some(d => urlMatches(d, targetUrl))
123
- : auth.accessTo.some(a => urlMatches(a, targetUrl));
124
-
125
- if (!appliesToResource && !isDefault) continue;
126
- if (isDefault && auth.default.length === 0) continue;
121
+ // For default ACLs, check if auth has default rules and matches target
122
+ // For direct ACLs, check if accessTo matches target
123
+ if (isDefault) {
124
+ // Skip if no default rules defined
125
+ if (auth.default.length === 0) continue;
126
+ // Skip if target URL doesn't match any default URL prefix
127
+ if (!auth.default.some(d => urlMatches(d, targetUrl, true))) continue;
128
+ } else {
129
+ // Skip if accessTo doesn't match target
130
+ if (!auth.accessTo.some(a => urlMatches(a, targetUrl))) continue;
131
+ }
127
132
 
128
133
  // Check if agent is authorized
129
134
  const agentAuthorized = isAgentAuthorized(auth, agentWebId);
@@ -172,10 +177,20 @@ function isAgentAuthorized(auth, agentWebId) {
172
177
 
173
178
  /**
174
179
  * Check if URLs match (handles trailing slashes)
180
+ * @param {string} pattern - The ACL URL pattern
181
+ * @param {string} url - The target URL to check
182
+ * @param {boolean} prefixMatch - If true, check if url starts with pattern (for acl:default)
175
183
  */
176
- function urlMatches(pattern, url) {
184
+ function urlMatches(pattern, url, prefixMatch = false) {
177
185
  const normalizedPattern = pattern.replace(/\/$/, '');
178
186
  const normalizedUrl = url.replace(/\/$/, '');
187
+
188
+ if (prefixMatch) {
189
+ // For default ACLs: target must be same as or under the pattern
190
+ return normalizedUrl === normalizedPattern ||
191
+ normalizedUrl.startsWith(normalizedPattern + '/');
192
+ }
193
+
179
194
  return normalizedPattern === normalizedUrl;
180
195
  }
181
196
 
@@ -187,12 +202,13 @@ function calculateWacAllow(authorizations, targetUrl, agentWebId, isDefault) {
187
202
  const publicModes = new Set();
188
203
 
189
204
  for (const auth of authorizations) {
190
- // Check if applies to resource
191
- const applies = isDefault
192
- ? auth.default.length > 0
193
- : auth.accessTo.some(a => urlMatches(a, targetUrl));
194
-
195
- if (!applies && !isDefault) continue;
205
+ // Check if applies to resource - use same logic as checkAuthorizations
206
+ if (isDefault) {
207
+ if (auth.default.length === 0) continue;
208
+ if (!auth.default.some(d => urlMatches(d, targetUrl, true))) continue;
209
+ } else {
210
+ if (!auth.accessTo.some(a => urlMatches(a, targetUrl))) continue;
211
+ }
196
212
 
197
213
  // Check what modes this grants
198
214
  const modes = auth.modes.map(m => {
package/test/pod.test.js CHANGED
@@ -36,7 +36,7 @@ describe('Pod Lifecycle', () => {
36
36
 
37
37
  const data = await res.json();
38
38
  assert.strictEqual(data.name, 'alice');
39
- assert.ok(data.webId.endsWith('/alice/#me'));
39
+ assert.ok(data.webId.endsWith('/alice/profile/card#me'));
40
40
  assert.ok(data.podUri.endsWith('/alice/'));
41
41
  });
42
42
 
@@ -95,24 +95,24 @@ describe('Pod Lifecycle', () => {
95
95
  const priv = await request('/carol/private/', { auth: 'carol' });
96
96
  assertStatus(priv, 200);
97
97
 
98
- // Check settings exists (needs auth)
99
- const settings = await request('/carol/settings/', { auth: 'carol' });
98
+ // Check Settings exists (needs auth)
99
+ const settings = await request('/carol/Settings/', { auth: 'carol' });
100
100
  assertStatus(settings, 200);
101
101
  });
102
102
 
103
103
  it('should create settings files', async () => {
104
104
  await createTestPod('dan');
105
105
 
106
- // Check prefs (needs auth - settings is private)
107
- const prefs = await request('/dan/settings/prefs', { auth: 'dan' });
106
+ // Check Preferences.ttl (needs auth - Settings is private)
107
+ const prefs = await request('/dan/Settings/Preferences.ttl', { auth: 'dan' });
108
108
  assertStatus(prefs, 200);
109
109
 
110
110
  // Check public type index (needs auth)
111
- const pubIndex = await request('/dan/settings/publicTypeIndex', { auth: 'dan' });
111
+ const pubIndex = await request('/dan/Settings/publicTypeIndex.ttl', { auth: 'dan' });
112
112
  assertStatus(pubIndex, 200);
113
113
 
114
114
  // Check private type index (needs auth)
115
- const privIndex = await request('/dan/settings/privateTypeIndex', { auth: 'dan' });
115
+ const privIndex = await request('/dan/Settings/privateTypeIndex.ttl', { auth: 'dan' });
116
116
  assertStatus(privIndex, 200);
117
117
  });
118
118
  });
@@ -30,119 +30,87 @@ describe('WebID Profile', () => {
30
30
  });
31
31
 
32
32
  describe('Profile Document', () => {
33
- it('should serve profile as HTML at pod root', async () => {
34
- const res = await request('/webidtest/');
33
+ // Profile is at /pod/profile/card following Solid convention
34
+ const profilePath = '/webidtest/profile/card';
35
+
36
+ it('should serve profile as HTML', async () => {
37
+ const res = await request(profilePath);
35
38
 
36
39
  assertStatus(res, 200);
37
40
  assertHeaderContains(res, 'Content-Type', 'text/html');
38
41
  });
39
42
 
40
43
  it('should contain JSON-LD structured data', async () => {
41
- const res = await request('/webidtest/');
44
+ const res = await request(profilePath);
42
45
  const html = await res.text();
43
46
 
44
47
  const jsonLd = extractJsonLdFromHtml(html);
45
48
  assert.ok(jsonLd['@context'], 'Should have @context');
46
- assert.ok(jsonLd['@graph'], 'Should have @graph');
49
+ // Profile uses flat structure, not @graph
50
+ assert.ok(jsonLd['@id'], 'Should have @id');
47
51
  });
48
52
 
49
53
  it('should have correct WebID URI', async () => {
50
- const res = await request('/webidtest/');
54
+ const res = await request(profilePath);
51
55
  const html = await res.text();
52
56
  const jsonLd = extractJsonLdFromHtml(html);
53
57
 
54
- // Find the Person in the graph
55
- const person = jsonLd['@graph'].find(node =>
56
- Array.isArray(node['@type'])
57
- ? node['@type'].includes('foaf:Person')
58
- : node['@type'] === 'foaf:Person'
59
- );
60
-
61
- assert.ok(person, 'Should have a foaf:Person');
62
- assert.ok(person['@id'].endsWith('/webidtest/#me'), 'WebID should end with /#me');
58
+ // Profile is a flat structure with the person as the main entity
59
+ assert.ok(jsonLd['@id'].endsWith('/webidtest/profile/card#me'), 'WebID should end with /profile/card#me');
63
60
  });
64
61
 
65
62
  it('should have foaf:name', async () => {
66
- const res = await request('/webidtest/');
63
+ const res = await request(profilePath);
67
64
  const html = await res.text();
68
65
  const jsonLd = extractJsonLdFromHtml(html);
69
66
 
70
- const person = jsonLd['@graph'].find(node =>
71
- Array.isArray(node['@type'])
72
- ? node['@type'].includes('foaf:Person')
73
- : node['@type'] === 'foaf:Person'
74
- );
75
-
76
- assert.strictEqual(person['foaf:name'], 'webidtest');
67
+ assert.strictEqual(jsonLd['foaf:name'], 'webidtest');
77
68
  });
78
69
 
79
70
  it('should have solid:oidcIssuer', async () => {
80
- const res = await request('/webidtest/');
71
+ const res = await request(profilePath);
81
72
  const html = await res.text();
82
73
  const jsonLd = extractJsonLdFromHtml(html);
83
74
 
84
- const person = jsonLd['@graph'].find(node =>
85
- Array.isArray(node['@type'])
86
- ? node['@type'].includes('foaf:Person')
87
- : node['@type'] === 'foaf:Person'
88
- );
89
-
90
- assert.ok(person['oidcIssuer'], 'Should have oidcIssuer');
75
+ assert.ok(jsonLd['oidcIssuer'], 'Should have oidcIssuer');
91
76
  });
92
77
 
93
78
  it('should have pim:storage pointing to pod', async () => {
94
- const res = await request('/webidtest/');
79
+ const res = await request(profilePath);
95
80
  const html = await res.text();
96
81
  const jsonLd = extractJsonLdFromHtml(html);
97
82
 
98
- const person = jsonLd['@graph'].find(node =>
99
- Array.isArray(node['@type'])
100
- ? node['@type'].includes('foaf:Person')
101
- : node['@type'] === 'foaf:Person'
102
- );
103
-
104
- assert.ok(person['storage'].endsWith('/webidtest/'), 'Storage should point to pod');
83
+ assert.ok(jsonLd['storage'].endsWith('/webidtest/'), 'Storage should point to pod');
105
84
  });
106
85
 
107
86
  it('should have ldp:inbox', async () => {
108
- const res = await request('/webidtest/');
87
+ const res = await request(profilePath);
109
88
  const html = await res.text();
110
89
  const jsonLd = extractJsonLdFromHtml(html);
111
90
 
112
- const person = jsonLd['@graph'].find(node =>
113
- Array.isArray(node['@type'])
114
- ? node['@type'].includes('foaf:Person')
115
- : node['@type'] === 'foaf:Person'
116
- );
117
-
118
- assert.ok(person['inbox'].endsWith('/webidtest/inbox/'), 'Should have inbox');
91
+ assert.ok(jsonLd['inbox'].endsWith('/webidtest/inbox/'), 'Should have inbox');
119
92
  });
120
93
 
121
- it('should have PersonalProfileDocument', async () => {
122
- const res = await request('/webidtest/');
94
+ it('should have mainEntityOfPage', async () => {
95
+ const res = await request(profilePath);
123
96
  const html = await res.text();
124
97
  const jsonLd = extractJsonLdFromHtml(html);
125
98
 
126
- const doc = jsonLd['@graph'].find(node =>
127
- node['@type'] === 'foaf:PersonalProfileDocument'
128
- );
129
-
130
- assert.ok(doc, 'Should have PersonalProfileDocument');
131
- assert.ok(doc['foaf:maker'], 'Should have foaf:maker');
132
- assert.ok(doc['foaf:primaryTopic'], 'Should have foaf:primaryTopic');
99
+ // Check for mainEntityOfPage which links to the profile document
100
+ assert.ok(jsonLd['mainEntityOfPage'], 'Should have mainEntityOfPage');
133
101
  });
134
102
  });
135
103
 
136
104
  describe('WebID Resolution', () => {
137
105
  it('should return LDP headers', async () => {
138
- const res = await request('/webidtest/');
106
+ const res = await request('/webidtest/profile/card');
139
107
 
140
108
  assertHeaderContains(res, 'Link', 'ldp#Resource');
141
109
  assertHeader(res, 'WAC-Allow');
142
110
  });
143
111
 
144
112
  it('should return CORS headers', async () => {
145
- const res = await request('/webidtest/', {
113
+ const res = await request('/webidtest/profile/card', {
146
114
  headers: { 'Origin': 'https://example.com' }
147
115
  });
148
116
 
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Test git push/pull with Nostr NIP-98 authentication
4
+ *
5
+ * Usage: node test-git-nostr-auth.js
6
+ *
7
+ * Prerequisites:
8
+ * - JSS server running on localhost:4000 with --git flag
9
+ * - git-credential-nostr installed globally
10
+ * - git config --global credential.helper nostr
11
+ *
12
+ * This script:
13
+ * 1. Creates a test git repo on the server
14
+ * 2. Sets up ACL with authorized Nostr DID
15
+ * 3. Tests clone (public read)
16
+ * 4. Tests push with authorized key (should succeed)
17
+ * 5. Tests push with unauthorized key (should fail with 403)
18
+ * 6. Cleans up
19
+ */
20
+
21
+ import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
22
+ import { bytesToHex } from '@noble/hashes/utils';
23
+ import { execSync, spawn } from 'child_process';
24
+ import fs from 'fs-extra';
25
+ import path from 'path';
26
+ import os from 'os';
27
+
28
+ const BASE_URL = process.env.TEST_URL || 'http://localhost:4000';
29
+ const DATA_ROOT = process.env.DATA_ROOT || '/home/melvin/jss/data';
30
+ const TEST_POD = 'test-git-nostr';
31
+ const TEST_REPO = 'test-repo';
32
+
33
+ // Generate keypairs for testing
34
+ const authorizedSk = generateSecretKey();
35
+ const authorizedPk = getPublicKey(authorizedSk);
36
+ const authorizedPrivHex = bytesToHex(authorizedSk);
37
+
38
+ const unauthorizedSk = generateSecretKey();
39
+ const unauthorizedPk = getPublicKey(unauthorizedSk);
40
+ const unauthorizedPrivHex = bytesToHex(unauthorizedSk);
41
+
42
+ let tempDir;
43
+ let passed = 0;
44
+ let failed = 0;
45
+
46
+ function log(msg) {
47
+ console.log(` ${msg}`);
48
+ }
49
+
50
+ function pass(test) {
51
+ console.log(`✓ ${test}`);
52
+ passed++;
53
+ }
54
+
55
+ function fail(test, error) {
56
+ console.log(`✗ ${test}`);
57
+ console.log(` Error: ${error}`);
58
+ failed++;
59
+ }
60
+
61
+ function exec(cmd, options = {}) {
62
+ try {
63
+ return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], ...options });
64
+ } catch (e) {
65
+ if (options.allowFail) {
66
+ return { error: true, stderr: e.stderr, stdout: e.stdout, status: e.status };
67
+ }
68
+ throw e;
69
+ }
70
+ }
71
+
72
+ async function setup() {
73
+ console.log('\n=== Setup ===\n');
74
+
75
+ // Create temp directory for client repos
76
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-nostr-test-'));
77
+ log(`Temp directory: ${tempDir}`);
78
+
79
+ // Create test pod directory
80
+ const podPath = path.join(DATA_ROOT, TEST_POD);
81
+ fs.ensureDirSync(podPath);
82
+ log(`Created pod: ${podPath}`);
83
+
84
+ // Create bare git repo
85
+ const repoPath = path.join(podPath, TEST_REPO);
86
+ fs.ensureDirSync(repoPath);
87
+ exec(`git init --bare`, { cwd: repoPath });
88
+ exec(`git config --local receive.denyCurrentBranch ignore`, { cwd: repoPath });
89
+ exec(`git config --local http.receivepack true`, { cwd: repoPath });
90
+ exec(`git symbolic-ref HEAD refs/heads/main`, { cwd: repoPath });
91
+ log(`Created bare repo: ${repoPath}`);
92
+
93
+ // Create ACL with authorized Nostr DID
94
+ const acl = {
95
+ "@context": {
96
+ "acl": "http://www.w3.org/ns/auth/acl#",
97
+ "foaf": "http://xmlns.com/foaf/0.1/"
98
+ },
99
+ "@graph": [
100
+ {
101
+ "@id": "#nostr",
102
+ "@type": "acl:Authorization",
103
+ "acl:agent": { "@id": `did:nostr:${authorizedPk}` },
104
+ "acl:accessTo": { "@id": `${BASE_URL}/${TEST_POD}/` },
105
+ "acl:mode": [
106
+ { "@id": "acl:Read" },
107
+ { "@id": "acl:Write" }
108
+ ],
109
+ "acl:default": { "@id": `${BASE_URL}/${TEST_POD}/` }
110
+ },
111
+ {
112
+ "@id": "#public",
113
+ "@type": "acl:Authorization",
114
+ "acl:agentClass": { "@id": "foaf:Agent" },
115
+ "acl:accessTo": { "@id": `${BASE_URL}/${TEST_POD}/` },
116
+ "acl:mode": [{ "@id": "acl:Read" }],
117
+ "acl:default": { "@id": `${BASE_URL}/${TEST_POD}/` }
118
+ }
119
+ ]
120
+ };
121
+
122
+ fs.writeJsonSync(path.join(podPath, '.acl'), acl, { spaces: 2 });
123
+ log(`Created ACL with authorized DID: did:nostr:${authorizedPk.slice(0, 16)}...`);
124
+
125
+ // Create initial commit in a temp client repo
126
+ const initRepo = path.join(tempDir, 'init');
127
+ fs.ensureDirSync(initRepo);
128
+ exec('git init', { cwd: initRepo });
129
+ exec('git config user.email "test@example.com"', { cwd: initRepo });
130
+ exec('git config user.name "Test User"', { cwd: initRepo });
131
+ fs.writeFileSync(path.join(initRepo, 'README.md'), '# Test Repo\n');
132
+ exec('git add .', { cwd: initRepo });
133
+ exec('git commit -m "Initial commit"', { cwd: initRepo });
134
+ exec('git branch -m main', { cwd: initRepo });
135
+ exec(`git remote add origin ${BASE_URL}/${TEST_POD}/${TEST_REPO}/`, { cwd: initRepo });
136
+
137
+ // Configure authorized key for initial push
138
+ exec(`git config nostr.privkey ${authorizedPrivHex}`, { cwd: initRepo });
139
+
140
+ log('Created initial commit');
141
+ }
142
+
143
+ async function testClone() {
144
+ console.log('\n=== Test: Clone (public read) ===\n');
145
+
146
+ const cloneDir = path.join(tempDir, 'clone-test');
147
+ try {
148
+ exec(`git clone ${BASE_URL}/${TEST_POD}/${TEST_REPO}/ ${cloneDir}`);
149
+ if (fs.existsSync(path.join(cloneDir, '.git'))) {
150
+ pass('Clone succeeded (public read works)');
151
+ } else {
152
+ fail('Clone', 'No .git directory created');
153
+ }
154
+ } catch (e) {
155
+ // Clone might fail if repo is empty, that's ok
156
+ if (e.message.includes('empty repository')) {
157
+ pass('Clone of empty repo handled correctly');
158
+ } else {
159
+ fail('Clone', e.message);
160
+ }
161
+ }
162
+ }
163
+
164
+ async function testPushAuthorized() {
165
+ console.log('\n=== Test: Push with authorized key ===\n');
166
+
167
+ const initRepo = path.join(tempDir, 'init');
168
+ try {
169
+ // Push should work with authorized key
170
+ exec('git push -u origin main', { cwd: initRepo });
171
+ pass('Push with authorized key succeeded');
172
+ } catch (e) {
173
+ fail('Push with authorized key', e.stderr || e.message);
174
+ }
175
+ }
176
+
177
+ async function testPushUnauthorized() {
178
+ console.log('\n=== Test: Push with unauthorized key ===\n');
179
+
180
+ const cloneDir = path.join(tempDir, 'unauthorized-test');
181
+
182
+ try {
183
+ // Clone first (should work - public read)
184
+ exec(`git clone ${BASE_URL}/${TEST_POD}/${TEST_REPO}/ ${cloneDir}`);
185
+ exec('git config user.email "test@example.com"', { cwd: cloneDir });
186
+ exec('git config user.name "Test User"', { cwd: cloneDir });
187
+
188
+ // Configure unauthorized key
189
+ exec(`git config nostr.privkey ${unauthorizedPrivHex}`, { cwd: cloneDir });
190
+
191
+ // Make a change
192
+ fs.appendFileSync(path.join(cloneDir, 'README.md'), '\nUnauthorized change\n');
193
+ exec('git add .', { cwd: cloneDir });
194
+ exec('git commit -m "Unauthorized change"', { cwd: cloneDir });
195
+
196
+ // Push should fail with 403
197
+ const result = exec('git push 2>&1', { cwd: cloneDir, allowFail: true });
198
+
199
+ if (result.error && (result.stderr?.includes('403') || result.stdout?.includes('403'))) {
200
+ pass('Push with unauthorized key correctly rejected (403)');
201
+ } else if (result.error) {
202
+ // Check if it failed for the right reason
203
+ if (result.stderr?.includes('Authentication failed') || result.stderr?.includes('403')) {
204
+ pass('Push with unauthorized key correctly rejected');
205
+ } else {
206
+ fail('Push with unauthorized key', `Unexpected error: ${result.stderr}`);
207
+ }
208
+ } else {
209
+ fail('Push with unauthorized key', 'Push should have failed but succeeded');
210
+ }
211
+ } catch (e) {
212
+ if (e.stderr?.includes('403') || e.message?.includes('403')) {
213
+ pass('Push with unauthorized key correctly rejected (403)');
214
+ } else {
215
+ fail('Push with unauthorized key', e.stderr || e.message);
216
+ }
217
+ }
218
+ }
219
+
220
+ async function testPullAfterPush() {
221
+ console.log('\n=== Test: Pull after push ===\n');
222
+
223
+ const pullDir = path.join(tempDir, 'pull-test');
224
+
225
+ try {
226
+ exec(`git clone ${BASE_URL}/${TEST_POD}/${TEST_REPO}/ ${pullDir}`);
227
+ const readme = fs.readFileSync(path.join(pullDir, 'README.md'), 'utf8');
228
+
229
+ if (readme.includes('Test Repo')) {
230
+ pass('Pull retrieved pushed content');
231
+ } else {
232
+ fail('Pull after push', 'Content mismatch');
233
+ }
234
+ } catch (e) {
235
+ fail('Pull after push', e.stderr || e.message);
236
+ }
237
+ }
238
+
239
+ async function cleanup() {
240
+ console.log('\n=== Cleanup ===\n');
241
+
242
+ // Remove temp directory
243
+ if (tempDir) {
244
+ fs.removeSync(tempDir);
245
+ log(`Removed temp directory: ${tempDir}`);
246
+ }
247
+
248
+ // Remove test pod
249
+ const podPath = path.join(DATA_ROOT, TEST_POD);
250
+ if (fs.existsSync(podPath)) {
251
+ fs.removeSync(podPath);
252
+ log(`Removed test pod: ${podPath}`);
253
+ }
254
+ }
255
+
256
+ async function main() {
257
+ console.log('╔════════════════════════════════════════════════════╗');
258
+ console.log('║ Git Push/Pull with Nostr Authentication Test ║');
259
+ console.log('╚════════════════════════════════════════════════════╝');
260
+
261
+ console.log(`\nServer: ${BASE_URL}`);
262
+ console.log(`Data root: ${DATA_ROOT}`);
263
+ console.log(`Authorized pubkey: ${authorizedPk.slice(0, 16)}...`);
264
+ console.log(`Unauthorized pubkey: ${unauthorizedPk.slice(0, 16)}...`);
265
+
266
+ try {
267
+ await setup();
268
+ await testPushAuthorized();
269
+ await testClone();
270
+ await testPullAfterPush();
271
+ await testPushUnauthorized();
272
+ } catch (e) {
273
+ console.error('\nFatal error:', e.message);
274
+ } finally {
275
+ await cleanup();
276
+ }
277
+
278
+ console.log('\n╔════════════════════════════════════════════════════╗');
279
+ console.log(`║ Results: ${passed} passed, ${failed} failed${' '.repeat(27 - String(passed).length - String(failed).length)}║`);
280
+ console.log('╚════════════════════════════════════════════════════╝\n');
281
+
282
+ process.exit(failed > 0 ? 1 : 0);
283
+ }
284
+
285
+ main().catch(console.error);
@@ -1,37 +0,0 @@
1
- {
2
- "jwks": {
3
- "keys": [
4
- {
5
- "kty": "RSA",
6
- "n": "zVVbuPqzcpJI4er_VpHQQd8UgRDI-PcLlwUGRG3DGqMvAWzB4UX2m5e5Uhc2vnzBC2U_1bZ5u3ovhZZhXqqj0ItBTOWjhjOZz4YpaPiwM68sqp_JUNNZPSLljhXnEhEv8q57Wcl0N8orJeuSmfjh7shsLKN-3nbWjCbQr4oAUneQ_2HNvazUTgRLKYCniS0SaI_ogTwuIJlNdFIc3FAHs4yNoSaXZDkdGWhGn49wYdB58nkjJ2ja8wc-rwNtcKEG7HZWNhoVCredav9jswWgAGodJofAxP_PCjguqn6-TzvGodXM3p0Rj1qaf27Ok39GbtOEwX4upwFKj-yJaZETQQ",
7
- "e": "AQAB",
8
- "d": "BAgqbUkP876bW4NMqP-jPB3kJk4k8h2QvtIIj9iraXcdlcyzyGeCKoc5ynq99pLWzBFchebnwEaLjxcXOZ-GaLKJUVgbhEe4XBa1crQaaqNkkEOjtXh28sBAC3CS6VwIyd5C-g3-gBdyTjPwXKFiV1jcZep-c-IXv6gF9kJyk-vv3aT6VabcqRBOk4PeNxL_VJt65x5JQBqyV06fv7OBrt15ez0dZL-fYMPhMhBZom0YGCkMVAC4VHAOmoVuHk3hQQCQv5x9-hq27qfgHAlkSKxTEDp2aJi4nIfzqEnFHrBqB9BwR0JEyV3ankVPzBcfEGffi5XY3kWK3EDE3oX9SQ",
9
- "p": "6ZGd_spW6xSiTNwlU-to7Qr06dr1hXzWLyFbJM9_APFxE761WJtAsN50oNg-rKRQk2JdlC0N04OFYWBED-pvJ0AIEVnKJfWsxEQaBkRueZefUcf-0L1M2Q7-LQdqz9qTR3hSZ42lJhPKMdLRI2WrxTz1v1p0w0yl3bwcbBRLtTk",
10
- "q": "4Q2QFyMKQUQhr4-JlQbI3VtBhfP-65oZg-13hG5Xc9kT0qo_-79YCK_4wMUX4WaSH6fG6lobTgZento3d6MXCAFpEl8WM4uRRNlzTLCC2FDZXms8i6hJHN89QFPLEPQd5bDXsw8pmA3ANIeHcwzR243VuWAYeQfeOJ-qymUjlkk",
11
- "dp": "AUldDm885VSaxEOeLQUp8cxSpwseuRqD74SGhQBjmbS6w7oUM6W_SHohOFWYmsjY7Mbo7w0Ee3rI_E1UcqX-8L9oi_frpiPhTL93STuNRDwyk3e_jpTMXJG5krPswbJZh1ZBVfKwyzHmtjmMD17bAF4imGg-JmlArKUBnxLJi_k",
12
- "dq": "A6Z7qtRnqy1WuolCewdUJLsBMhIGFX43YbttT9mWU4u21ZjrVsMAw4tPJplLzN0kC51mDZEOllJmIH97nNYpXnjfYmvmaUmfPpWkWB8Y0Ddnfy-QGNfO78fzL2LsjUbYYUxgA0iArTWz42Y7XTNdCAmh6NLVMslc4mA8nfHMBPk",
13
- "qi": "Wikwy4ljfyLLHDO1Fpm2hn-_G7rWbwOP0ANJzY62cIEorbyyr4zAaaPflGMrDJHadlLdww_x2L1g2lNePN7mY97kFJVL0ceuwUmxDr434LqyRZWTrTP5qP6vrkjGjMImBuJEftXdOyJElcqHgtpvltydZRjlvhugnxcOKRble3Y",
14
- "kid": "509868cc-c850-4708-9331-8e38273df4db",
15
- "use": "sig",
16
- "alg": "RS256",
17
- "iat": 1767263929
18
- },
19
- {
20
- "kty": "EC",
21
- "x": "KSGjhPP_QyoqHu8v891hEk_TsF5yuQUN0hFBY_qq26o",
22
- "y": "aTYYEwxOR0tM82FtJoCVJ7u8xtf3y0exoWVJzJAZQ24",
23
- "crv": "P-256",
24
- "d": "Mkm_Oh4_emAzg2MbxoMuHcJZo3E-O_-YlK8mlSHWU8M",
25
- "kid": "92009452-c551-4389-97fd-314ddd59b7d4",
26
- "use": "sig",
27
- "alg": "ES256",
28
- "iat": 1767263929
29
- }
30
- ]
31
- },
32
- "cookieKeys": [
33
- "IiO299-8bui4UVILu0azGvlCnNTckSGMPMPQSG2HqHw",
34
- "NbeOVkfw-xN9T4J0_1uzAmDp6tYOwFW-yTJLSd7nqDU"
35
- ],
36
- "createdAt": "2026-01-01T10:38:49.528Z"
37
- }