javascript-solid-server 0.0.37 → 0.0.39

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.
@@ -116,7 +116,22 @@
116
116
  "Bash(fi)",
117
117
  "Bash(timeout 45 node:*)",
118
118
  "Bash(gh issue list:*)",
119
- "Bash(DATA_ROOT=/tmp/jss-git-test JSS_PORT=4444 timeout 3 node:*)"
119
+ "Bash(DATA_ROOT=/tmp/jss-git-test JSS_PORT=4444 timeout 3 node:*)",
120
+ "Bash(pm2 show:*)",
121
+ "Bash(git config:*)",
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:*)"
120
135
  ]
121
136
  }
122
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.31)
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
@@ -19,13 +19,15 @@ A minimal, fast, JSON-LD native Solid server.
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
21
  - **Web Access Control (WAC)** - `.acl` file-based authorization
22
- - **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, dynamic registration
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
25
25
  - **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures
26
26
  - **Simple Auth Tokens** - Built-in token authentication for development
27
27
  - **Content Negotiation** - Turtle <-> JSON-LD conversion, including HTML data islands
28
28
  - **CORS Support** - Full cross-origin resource sharing
29
+ - **Git HTTP Backend** - Clone and push to containers via `git` protocol
30
+ - **Security** - Blocks access to dotfiles (`.git/`, `.env`, etc.) except Solid-specific ones
29
31
 
30
32
  ### HTTP Methods
31
33
 
@@ -94,6 +96,7 @@ jss --help # Show help
94
96
  | `--mashlib` | Enable Mashlib (local mode) | false |
95
97
  | `--mashlib-cdn` | Enable Mashlib (CDN mode) | false |
96
98
  | `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
99
+ | `--git` | Enable Git HTTP backend | false |
97
100
  | `-q, --quiet` | Suppress logs | false |
98
101
 
99
102
  ### Environment Variables
@@ -318,6 +321,71 @@ Server: ack http://localhost:3000/alice/public/data.json
318
321
  Server: pub http://localhost:3000/alice/public/data.json (on change)
319
322
  ```
320
323
 
324
+ ## Git Support
325
+
326
+ Enable Git HTTP backend to clone and push to pod containers:
327
+
328
+ ```bash
329
+ jss start --git
330
+ ```
331
+
332
+ ### Initialize a Repository
333
+
334
+ ```bash
335
+ # Create a git repo in a pod container
336
+ cd data/alice/myrepo
337
+ git init
338
+ echo "# My Project" > README.md
339
+ git add . && git commit -m "Initial commit"
340
+ ```
341
+
342
+ ### Clone and Push
343
+
344
+ ```bash
345
+ # Clone (public read access)
346
+ git clone http://localhost:3000/alice/myrepo
347
+
348
+ # Push (requires write access via WAC)
349
+ cd myrepo
350
+ echo "New content" >> README.md
351
+ git add . && git commit -m "Update"
352
+ git push
353
+ ```
354
+
355
+ Git operations respect WAC permissions - clone requires Read access, push requires Write access.
356
+
357
+ ### Git Push with Nostr Authentication
358
+
359
+ Git push supports NIP-98 authentication via Basic Auth. Install the credential helper:
360
+
361
+ ```bash
362
+ npm install -g git-credential-nostr
363
+ git config --global credential.helper nostr
364
+ ```
365
+
366
+ Generate or configure your Nostr key:
367
+
368
+ ```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
374
+ ```
375
+
376
+ See [git-credential-nostr](https://github.com/JavaScriptSolidServer/git-credential-nostr) for more details.
377
+
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
+
321
389
  ## Authentication
322
390
 
323
391
  ### Simple Tokens (Development)
@@ -490,7 +558,7 @@ npm run benchmark
490
558
  npm test
491
559
  ```
492
560
 
493
- Currently passing: **182 tests** (including 27 conformance tests)
561
+ Currently passing: **187 tests** (including 27 conformance tests)
494
562
 
495
563
  ### Conformance Test Harness (CTH)
496
564
 
@@ -531,13 +599,15 @@ src/
531
599
  ├── server.js # Fastify setup
532
600
  ├── handlers/
533
601
  │ ├── resource.js # GET, PUT, DELETE, HEAD, PATCH
534
- └── container.js # POST, pod creation
602
+ ├── container.js # POST, pod creation
603
+ │ └── git.js # Git HTTP backend
535
604
  ├── storage/
536
605
  │ └── filesystem.js # File operations
537
606
  ├── auth/
538
607
  │ ├── middleware.js # Auth hook
539
608
  │ ├── token.js # Simple token auth
540
- └── solid-oidc.js # DPoP verification
609
+ ├── solid-oidc.js # DPoP verification
610
+ │ └── nostr.js # NIP-98 Nostr authentication
541
611
  ├── wac/
542
612
  │ ├── parser.js # ACL parsing
543
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.37",
3
+ "version": "0.0.39",
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;
package/src/auth/nostr.js CHANGED
@@ -22,24 +22,58 @@ const TIMESTAMP_TOLERANCE = 60;
22
22
 
23
23
  /**
24
24
  * Check if request has Nostr authentication
25
+ * Supports both "Nostr <token>" and "Basic <base64(nostr:token)>" formats
26
+ * The Basic format allows git clients to authenticate via NIP-98
25
27
  * @param {object} request - Fastify request object
26
28
  * @returns {boolean}
27
29
  */
28
30
  export function hasNostrAuth(request) {
29
31
  const authHeader = request.headers.authorization;
30
- return authHeader && authHeader.startsWith('Nostr ');
32
+ if (!authHeader) return false;
33
+
34
+ // Direct Nostr header
35
+ if (authHeader.startsWith('Nostr ')) return true;
36
+
37
+ // Basic auth with username=nostr (for git clients)
38
+ if (authHeader.startsWith('Basic ')) {
39
+ try {
40
+ const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
41
+ return decoded.startsWith('nostr:');
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ return false;
31
48
  }
32
49
 
33
50
  /**
34
51
  * Extract token from Nostr authorization header
52
+ * Supports both "Nostr <token>" and "Basic <base64(nostr:token)>" formats
35
53
  * @param {string} authHeader - Authorization header value
36
54
  * @returns {string|null}
37
55
  */
38
56
  export function extractNostrToken(authHeader) {
39
- if (!authHeader || !authHeader.startsWith('Nostr ')) {
40
- return null;
57
+ if (!authHeader) return null;
58
+
59
+ // Direct Nostr header
60
+ if (authHeader.startsWith('Nostr ')) {
61
+ return authHeader.slice(6).trim();
62
+ }
63
+
64
+ // Basic auth with username=nostr, password=token
65
+ if (authHeader.startsWith('Basic ')) {
66
+ try {
67
+ const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
68
+ if (decoded.startsWith('nostr:')) {
69
+ return decoded.slice(6); // Remove "nostr:" prefix to get token
70
+ }
71
+ } catch {
72
+ return null;
73
+ }
41
74
  }
42
- return authHeader.slice(6).trim();
75
+
76
+ return null;
43
77
  }
44
78
 
45
79
  /**
@@ -125,16 +159,27 @@ export async function verifyNostrAuth(request) {
125
159
  const normalizedRequestUrl = fullUrl.replace(/\/$/, '');
126
160
  const normalizedRequestUrlNoQuery = fullUrl.split('?')[0].replace(/\/$/, '');
127
161
 
128
- if (normalizedEventUrl !== normalizedRequestUrl && normalizedEventUrl !== normalizedRequestUrlNoQuery) {
162
+ // Check for exact match first
163
+ let urlMatches = normalizedEventUrl === normalizedRequestUrl ||
164
+ normalizedEventUrl === normalizedRequestUrlNoQuery;
165
+
166
+ // For git clients: allow prefix matching (event URL is base of request URL)
167
+ // This enables git credential helpers that sign for the repo base URL
168
+ if (!urlMatches && normalizedRequestUrlNoQuery.startsWith(normalizedEventUrl + '/')) {
169
+ urlMatches = true;
170
+ }
171
+
172
+ if (!urlMatches) {
129
173
  return { webId: null, error: `URL mismatch: event URL "${eventUrl}" does not match request URL "${fullUrl}"` };
130
174
  }
131
175
 
132
176
  // Validate method tag matches request method
177
+ // For git clients: allow '*' as wildcard method
133
178
  const eventMethod = getTagValue(event, 'method');
134
179
  if (!eventMethod) {
135
180
  return { webId: null, error: 'Missing method tag in event' };
136
181
  }
137
- if (eventMethod.toUpperCase() !== request.method.toUpperCase()) {
182
+ if (eventMethod !== '*' && eventMethod.toUpperCase() !== request.method.toUpperCase()) {
138
183
  return { webId: null, error: `Method mismatch: expected ${request.method}, got ${eventMethod}` };
139
184
  }
140
185
 
@@ -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);