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.
- package/.claude/settings.local.json +16 -1
- package/README.md +75 -5
- package/cth.env +2 -2
- package/package.json +1 -1
- package/src/auth/middleware.js +5 -3
- package/src/auth/nostr.js +51 -6
- package/src/handlers/resource.js +10 -1
- package/src/server.js +12 -4
- package/src/storage/filesystem.js +2 -3
- package/src/utils/url.js +15 -3
- package/src/wac/checker.js +33 -17
- package/test/pod.test.js +7 -7
- package/test/webid.test.js +25 -57
- package/test-git-nostr-auth.js +285 -0
|
@@ -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.
|
|
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: **
|
|
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
|
-
│
|
|
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
|
-
│
|
|
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
|
|
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
|
|
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
package/src/auth/middleware.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/handlers/resource.js
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
166
|
-
const
|
|
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 {
|
|
4
|
+
import { getDataRoot, urlToPath, isContainer } from '../utils/url.js';
|
|
5
5
|
|
|
6
|
-
//
|
|
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
|
-
|
|
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(
|
|
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(
|
|
50
|
+
return path.join(getDataRoot(), podName, normalized);
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
/**
|
package/src/wac/checker.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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
|
|
99
|
-
const settings = await request('/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
|
|
107
|
-
const prefs = await request('/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/
|
|
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/
|
|
115
|
+
const privIndex = await request('/dan/Settings/privateTypeIndex.ttl', { auth: 'dan' });
|
|
116
116
|
assertStatus(privIndex, 200);
|
|
117
117
|
});
|
|
118
118
|
});
|
package/test/webid.test.js
CHANGED
|
@@ -30,119 +30,87 @@ describe('WebID Profile', () => {
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
describe('Profile Document', () => {
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
54
|
+
const res = await request(profilePath);
|
|
51
55
|
const html = await res.text();
|
|
52
56
|
const jsonLd = extractJsonLdFromHtml(html);
|
|
53
57
|
|
|
54
|
-
//
|
|
55
|
-
|
|
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(
|
|
63
|
+
const res = await request(profilePath);
|
|
67
64
|
const html = await res.text();
|
|
68
65
|
const jsonLd = extractJsonLdFromHtml(html);
|
|
69
66
|
|
|
70
|
-
|
|
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(
|
|
71
|
+
const res = await request(profilePath);
|
|
81
72
|
const html = await res.text();
|
|
82
73
|
const jsonLd = extractJsonLdFromHtml(html);
|
|
83
74
|
|
|
84
|
-
|
|
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(
|
|
79
|
+
const res = await request(profilePath);
|
|
95
80
|
const html = await res.text();
|
|
96
81
|
const jsonLd = extractJsonLdFromHtml(html);
|
|
97
82
|
|
|
98
|
-
|
|
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(
|
|
87
|
+
const res = await request(profilePath);
|
|
109
88
|
const html = await res.text();
|
|
110
89
|
const jsonLd = extractJsonLdFromHtml(html);
|
|
111
90
|
|
|
112
|
-
|
|
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
|
|
122
|
-
const res = await request(
|
|
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
|
-
|
|
127
|
-
|
|
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);
|