javascript-solid-server 0.0.10 → 0.0.11
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 +6 -1
- package/README.md +66 -7
- package/bin/jss.js +208 -0
- package/package.json +7 -3
- package/src/config.js +185 -0
- package/src/handlers/resource.js +3 -1
- package/src/server.js +21 -10
- package/test/conformance.test.js +349 -0
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
"Bash(npm test:*)",
|
|
16
16
|
"Bash(git add:*)",
|
|
17
17
|
"WebFetch(domain:solid.github.io)",
|
|
18
|
-
"Bash(node:*)"
|
|
18
|
+
"Bash(node:*)",
|
|
19
|
+
"WebFetch(domain:solidservers.org)",
|
|
20
|
+
"WebFetch(domain:solid-contrib.github.io)",
|
|
21
|
+
"Bash(git clone:*)",
|
|
22
|
+
"Bash(chmod:*)",
|
|
23
|
+
"Bash(JSS_PORT=4000 JSS_CONNEG=true node bin/jss.js:*)"
|
|
19
24
|
]
|
|
20
25
|
}
|
|
21
26
|
}
|
package/README.md
CHANGED
|
@@ -54,12 +54,14 @@ npm run benchmark
|
|
|
54
54
|
|
|
55
55
|
## Features
|
|
56
56
|
|
|
57
|
-
### Implemented (v0.0.
|
|
57
|
+
### Implemented (v0.0.11)
|
|
58
58
|
|
|
59
59
|
- **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
|
|
60
60
|
- **N3 Patch** - Solid's native patch format for RDF updates
|
|
61
61
|
- **SPARQL Update** - Standard SPARQL UPDATE protocol for PATCH
|
|
62
62
|
- **Conditional Requests** - If-Match/If-None-Match headers (304, 412)
|
|
63
|
+
- **CLI & Config** - `jss` command with config file/env var support
|
|
64
|
+
- **SSL/TLS** - HTTPS support with certificate configuration
|
|
63
65
|
- **WebSocket Notifications** - Real-time updates via solid-0.1 protocol (SolidOS compatible)
|
|
64
66
|
- **Container Management** - Create, list, and manage containers
|
|
65
67
|
- **Multi-user Pods** - Create pods at `/<username>/`
|
|
@@ -92,18 +94,75 @@ npm run benchmark
|
|
|
92
94
|
|
|
93
95
|
```bash
|
|
94
96
|
npm install
|
|
97
|
+
|
|
98
|
+
# Or install globally
|
|
99
|
+
npm install -g javascript-solid-server
|
|
95
100
|
```
|
|
96
101
|
|
|
97
|
-
###
|
|
102
|
+
### Quick Start
|
|
98
103
|
|
|
99
104
|
```bash
|
|
100
|
-
#
|
|
101
|
-
|
|
105
|
+
# Initialize configuration (interactive)
|
|
106
|
+
jss init
|
|
107
|
+
|
|
108
|
+
# Start server
|
|
109
|
+
jss start
|
|
110
|
+
|
|
111
|
+
# Or with options
|
|
112
|
+
jss start --port 8443 --ssl-key ./key.pem --ssl-cert ./cert.pem
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### CLI Commands
|
|
102
116
|
|
|
103
|
-
|
|
104
|
-
|
|
117
|
+
```bash
|
|
118
|
+
jss start [options] # Start the server
|
|
119
|
+
jss init [options] # Initialize configuration
|
|
120
|
+
jss --help # Show help
|
|
105
121
|
```
|
|
106
122
|
|
|
123
|
+
### Start Options
|
|
124
|
+
|
|
125
|
+
| Option | Description | Default |
|
|
126
|
+
|--------|-------------|---------|
|
|
127
|
+
| `-p, --port <n>` | Port to listen on | 3000 |
|
|
128
|
+
| `-h, --host <addr>` | Host to bind to | 0.0.0.0 |
|
|
129
|
+
| `-r, --root <path>` | Data directory | ./data |
|
|
130
|
+
| `-c, --config <file>` | Config file path | - |
|
|
131
|
+
| `--ssl-key <path>` | SSL private key (PEM) | - |
|
|
132
|
+
| `--ssl-cert <path>` | SSL certificate (PEM) | - |
|
|
133
|
+
| `--conneg` | Enable Turtle support | false |
|
|
134
|
+
| `--notifications` | Enable WebSocket | false |
|
|
135
|
+
| `-q, --quiet` | Suppress logs | false |
|
|
136
|
+
|
|
137
|
+
### Environment Variables
|
|
138
|
+
|
|
139
|
+
All options can be set via environment variables with `JSS_` prefix:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
export JSS_PORT=8443
|
|
143
|
+
export JSS_SSL_KEY=/path/to/key.pem
|
|
144
|
+
export JSS_SSL_CERT=/path/to/cert.pem
|
|
145
|
+
export JSS_CONNEG=true
|
|
146
|
+
jss start
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Config File
|
|
150
|
+
|
|
151
|
+
Create `config.json`:
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{
|
|
155
|
+
"port": 8443,
|
|
156
|
+
"root": "./data",
|
|
157
|
+
"sslKey": "./ssl/key.pem",
|
|
158
|
+
"sslCert": "./ssl/cert.pem",
|
|
159
|
+
"conneg": true,
|
|
160
|
+
"notifications": true
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Then: `jss start --config config.json`
|
|
165
|
+
|
|
107
166
|
### Creating a Pod
|
|
108
167
|
|
|
109
168
|
```bash
|
|
@@ -264,7 +323,7 @@ Server: pub http://localhost:3000/alice/public/data.json (on change)
|
|
|
264
323
|
npm test
|
|
265
324
|
```
|
|
266
325
|
|
|
267
|
-
Currently passing: **
|
|
326
|
+
Currently passing: **163 tests** (including 27 conformance tests)
|
|
268
327
|
|
|
269
328
|
## Project Structure
|
|
270
329
|
|
package/bin/jss.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JavaScript Solid Server CLI
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* jss start [options] Start the server
|
|
8
|
+
* jss init Initialize configuration
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Command } from 'commander';
|
|
12
|
+
import { createServer } from '../src/server.js';
|
|
13
|
+
import { loadConfig, saveConfig, printConfig, defaults } from '../src/config.js';
|
|
14
|
+
import fs from 'fs-extra';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
import readline from 'readline';
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const pkg = JSON.parse(await fs.readFile(path.join(__dirname, '../package.json'), 'utf8'));
|
|
21
|
+
|
|
22
|
+
const program = new Command();
|
|
23
|
+
|
|
24
|
+
program
|
|
25
|
+
.name('jss')
|
|
26
|
+
.description('JavaScript Solid Server - A minimal, fast, JSON-LD native Solid server')
|
|
27
|
+
.version(pkg.version);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Start command
|
|
31
|
+
*/
|
|
32
|
+
program
|
|
33
|
+
.command('start')
|
|
34
|
+
.description('Start the Solid server')
|
|
35
|
+
.option('-p, --port <number>', 'Port to listen on', parseInt)
|
|
36
|
+
.option('-h, --host <address>', 'Host to bind to')
|
|
37
|
+
.option('-r, --root <path>', 'Data directory')
|
|
38
|
+
.option('-c, --config <file>', 'Config file path')
|
|
39
|
+
.option('--ssl-key <path>', 'Path to SSL private key (PEM)')
|
|
40
|
+
.option('--ssl-cert <path>', 'Path to SSL certificate (PEM)')
|
|
41
|
+
.option('--multiuser', 'Enable multi-user mode')
|
|
42
|
+
.option('--no-multiuser', 'Disable multi-user mode')
|
|
43
|
+
.option('--conneg', 'Enable content negotiation (Turtle support)')
|
|
44
|
+
.option('--no-conneg', 'Disable content negotiation')
|
|
45
|
+
.option('--notifications', 'Enable WebSocket notifications')
|
|
46
|
+
.option('--no-notifications', 'Disable WebSocket notifications')
|
|
47
|
+
.option('-q, --quiet', 'Suppress log output')
|
|
48
|
+
.option('--print-config', 'Print configuration and exit')
|
|
49
|
+
.action(async (options) => {
|
|
50
|
+
try {
|
|
51
|
+
const config = await loadConfig(options, options.config);
|
|
52
|
+
|
|
53
|
+
if (options.printConfig) {
|
|
54
|
+
printConfig(config);
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Create and start server
|
|
59
|
+
const server = createServer({
|
|
60
|
+
logger: config.logger,
|
|
61
|
+
conneg: config.conneg,
|
|
62
|
+
notifications: config.notifications,
|
|
63
|
+
ssl: config.ssl ? {
|
|
64
|
+
key: await fs.readFile(config.sslKey),
|
|
65
|
+
cert: await fs.readFile(config.sslCert),
|
|
66
|
+
} : null,
|
|
67
|
+
root: config.root,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await server.listen({ port: config.port, host: config.host });
|
|
71
|
+
|
|
72
|
+
const protocol = config.ssl ? 'https' : 'http';
|
|
73
|
+
const address = config.host === '0.0.0.0' ? 'localhost' : config.host;
|
|
74
|
+
|
|
75
|
+
if (!config.quiet) {
|
|
76
|
+
console.log(`\n JavaScript Solid Server v${pkg.version}`);
|
|
77
|
+
console.log(` ${protocol}://${address}:${config.port}/`);
|
|
78
|
+
console.log(`\n Data: ${path.resolve(config.root)}`);
|
|
79
|
+
if (config.ssl) console.log(' SSL: enabled');
|
|
80
|
+
if (config.conneg) console.log(' Conneg: enabled');
|
|
81
|
+
if (config.notifications) console.log(' WebSocket: enabled');
|
|
82
|
+
console.log('\n Press Ctrl+C to stop\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Handle shutdown
|
|
86
|
+
const shutdown = async () => {
|
|
87
|
+
if (!config.quiet) console.log('\n Shutting down...');
|
|
88
|
+
await server.close();
|
|
89
|
+
process.exit(0);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
process.on('SIGINT', shutdown);
|
|
93
|
+
process.on('SIGTERM', shutdown);
|
|
94
|
+
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`Error: ${err.message}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Init command - interactive configuration
|
|
103
|
+
*/
|
|
104
|
+
program
|
|
105
|
+
.command('init')
|
|
106
|
+
.description('Initialize server configuration')
|
|
107
|
+
.option('-c, --config <file>', 'Config file path', './config.json')
|
|
108
|
+
.option('-y, --yes', 'Accept defaults without prompting')
|
|
109
|
+
.action(async (options) => {
|
|
110
|
+
const configFile = path.resolve(options.config);
|
|
111
|
+
|
|
112
|
+
// Check if config already exists
|
|
113
|
+
if (await fs.pathExists(configFile)) {
|
|
114
|
+
console.log(`Config file already exists: ${configFile}`);
|
|
115
|
+
const overwrite = options.yes ? true : await confirm('Overwrite?');
|
|
116
|
+
if (!overwrite) {
|
|
117
|
+
console.log('Aborted.');
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let config;
|
|
123
|
+
|
|
124
|
+
if (options.yes) {
|
|
125
|
+
// Use defaults
|
|
126
|
+
config = { ...defaults };
|
|
127
|
+
} else {
|
|
128
|
+
// Interactive prompts
|
|
129
|
+
console.log('\n JavaScript Solid Server Setup\n');
|
|
130
|
+
|
|
131
|
+
config = {
|
|
132
|
+
port: await prompt('Port', defaults.port),
|
|
133
|
+
root: await prompt('Data directory', defaults.root),
|
|
134
|
+
conneg: await confirm('Enable content negotiation (Turtle support)?', defaults.conneg),
|
|
135
|
+
notifications: await confirm('Enable WebSocket notifications?', defaults.notifications),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Ask about SSL
|
|
139
|
+
const useSSL = await confirm('Configure SSL?', false);
|
|
140
|
+
if (useSSL) {
|
|
141
|
+
config.sslKey = await prompt('SSL key path', './ssl/key.pem');
|
|
142
|
+
config.sslCert = await prompt('SSL certificate path', './ssl/cert.pem');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log('');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Save config
|
|
149
|
+
await saveConfig(config, configFile);
|
|
150
|
+
console.log(`Configuration saved to: ${configFile}`);
|
|
151
|
+
|
|
152
|
+
// Create data directory
|
|
153
|
+
const dataDir = path.resolve(config.root);
|
|
154
|
+
await fs.ensureDir(dataDir);
|
|
155
|
+
console.log(`Data directory created: ${dataDir}`);
|
|
156
|
+
|
|
157
|
+
console.log('\nRun `jss start` to start the server.\n');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Helper: Prompt for input
|
|
162
|
+
*/
|
|
163
|
+
async function prompt(question, defaultValue) {
|
|
164
|
+
const rl = readline.createInterface({
|
|
165
|
+
input: process.stdin,
|
|
166
|
+
output: process.stdout
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return new Promise((resolve) => {
|
|
170
|
+
const defaultStr = defaultValue !== undefined ? ` (${defaultValue})` : '';
|
|
171
|
+
rl.question(` ${question}${defaultStr}: `, (answer) => {
|
|
172
|
+
rl.close();
|
|
173
|
+
const value = answer.trim() || defaultValue;
|
|
174
|
+
// Parse numbers
|
|
175
|
+
if (typeof defaultValue === 'number' && !isNaN(value)) {
|
|
176
|
+
resolve(parseInt(value, 10));
|
|
177
|
+
} else {
|
|
178
|
+
resolve(value);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Helper: Confirm yes/no
|
|
186
|
+
*/
|
|
187
|
+
async function confirm(question, defaultValue = false) {
|
|
188
|
+
const rl = readline.createInterface({
|
|
189
|
+
input: process.stdin,
|
|
190
|
+
output: process.stdout
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
const hint = defaultValue ? '[Y/n]' : '[y/N]';
|
|
195
|
+
rl.question(` ${question} ${hint}: `, (answer) => {
|
|
196
|
+
rl.close();
|
|
197
|
+
const normalized = answer.trim().toLowerCase();
|
|
198
|
+
if (normalized === '') {
|
|
199
|
+
resolve(defaultValue);
|
|
200
|
+
} else {
|
|
201
|
+
resolve(normalized === 'y' || normalized === 'yes');
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Parse and run
|
|
208
|
+
program.parse();
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"description": "A minimal, fast Solid server",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"jss": "./bin/jss.js"
|
|
9
|
+
},
|
|
7
10
|
"repository": {
|
|
8
11
|
"type": "git",
|
|
9
12
|
"url": "git+https://github.com/JavaScriptSolidServer/JavaScriptSolidServer.git"
|
|
@@ -13,13 +16,14 @@
|
|
|
13
16
|
},
|
|
14
17
|
"homepage": "https://github.com/JavaScriptSolidServer/JavaScriptSolidServer#readme",
|
|
15
18
|
"scripts": {
|
|
16
|
-
"start": "node
|
|
17
|
-
"dev": "node --watch
|
|
19
|
+
"start": "node bin/jss.js start",
|
|
20
|
+
"dev": "node --watch bin/jss.js start",
|
|
18
21
|
"test": "node --test --test-concurrency=1",
|
|
19
22
|
"benchmark": "node benchmark.js"
|
|
20
23
|
},
|
|
21
24
|
"dependencies": {
|
|
22
25
|
"@fastify/websocket": "^8.3.1",
|
|
26
|
+
"commander": "^14.0.2",
|
|
23
27
|
"fastify": "^4.25.2",
|
|
24
28
|
"fs-extra": "^11.2.0",
|
|
25
29
|
"jose": "^6.1.3",
|
package/src/config.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration Loading
|
|
3
|
+
*
|
|
4
|
+
* Loads config from (in order of precedence):
|
|
5
|
+
* 1. CLI arguments (highest)
|
|
6
|
+
* 2. Environment variables (JSS_*)
|
|
7
|
+
* 3. Config file (config.json)
|
|
8
|
+
* 4. Defaults (lowest)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs-extra';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default configuration values
|
|
16
|
+
*/
|
|
17
|
+
export const defaults = {
|
|
18
|
+
// Server
|
|
19
|
+
port: 3000,
|
|
20
|
+
host: '0.0.0.0',
|
|
21
|
+
root: './data',
|
|
22
|
+
|
|
23
|
+
// SSL
|
|
24
|
+
sslKey: null,
|
|
25
|
+
sslCert: null,
|
|
26
|
+
|
|
27
|
+
// Features
|
|
28
|
+
multiuser: true,
|
|
29
|
+
conneg: false,
|
|
30
|
+
notifications: false,
|
|
31
|
+
|
|
32
|
+
// Logging
|
|
33
|
+
logger: true,
|
|
34
|
+
quiet: false,
|
|
35
|
+
|
|
36
|
+
// Paths
|
|
37
|
+
configPath: './.jss',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Map of environment variable names to config keys
|
|
42
|
+
*/
|
|
43
|
+
const envMap = {
|
|
44
|
+
JSS_PORT: 'port',
|
|
45
|
+
JSS_HOST: 'host',
|
|
46
|
+
JSS_ROOT: 'root',
|
|
47
|
+
JSS_SSL_KEY: 'sslKey',
|
|
48
|
+
JSS_SSL_CERT: 'sslCert',
|
|
49
|
+
JSS_MULTIUSER: 'multiuser',
|
|
50
|
+
JSS_CONNEG: 'conneg',
|
|
51
|
+
JSS_NOTIFICATIONS: 'notifications',
|
|
52
|
+
JSS_QUIET: 'quiet',
|
|
53
|
+
JSS_CONFIG_PATH: 'configPath',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse a value from environment variable string
|
|
58
|
+
*/
|
|
59
|
+
function parseEnvValue(value, key) {
|
|
60
|
+
if (value === undefined) return undefined;
|
|
61
|
+
|
|
62
|
+
// Boolean values
|
|
63
|
+
if (value.toLowerCase() === 'true') return true;
|
|
64
|
+
if (value.toLowerCase() === 'false') return false;
|
|
65
|
+
|
|
66
|
+
// Numeric values for known numeric keys
|
|
67
|
+
if (key === 'port' && !isNaN(value)) {
|
|
68
|
+
return parseInt(value, 10);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load configuration from environment variables
|
|
76
|
+
*/
|
|
77
|
+
function loadEnvConfig() {
|
|
78
|
+
const config = {};
|
|
79
|
+
|
|
80
|
+
for (const [envVar, configKey] of Object.entries(envMap)) {
|
|
81
|
+
const value = process.env[envVar];
|
|
82
|
+
if (value !== undefined) {
|
|
83
|
+
config[configKey] = parseEnvValue(value, configKey);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return config;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Load configuration from a JSON file
|
|
92
|
+
*/
|
|
93
|
+
async function loadFileConfig(configFile) {
|
|
94
|
+
if (!configFile) return {};
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const fullPath = path.resolve(configFile);
|
|
98
|
+
if (await fs.pathExists(fullPath)) {
|
|
99
|
+
const content = await fs.readFile(fullPath, 'utf8');
|
|
100
|
+
return JSON.parse(content);
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.error(`Warning: Failed to load config file: ${e.message}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Merge configuration sources
|
|
111
|
+
* @param {object} cliOptions - Options from command line
|
|
112
|
+
* @param {string} configFile - Path to config file (optional)
|
|
113
|
+
* @returns {Promise<object>} Merged configuration
|
|
114
|
+
*/
|
|
115
|
+
export async function loadConfig(cliOptions = {}, configFile = null) {
|
|
116
|
+
// Load from file first
|
|
117
|
+
const fileConfig = await loadFileConfig(configFile || cliOptions.config);
|
|
118
|
+
|
|
119
|
+
// Load from environment
|
|
120
|
+
const envConfig = loadEnvConfig();
|
|
121
|
+
|
|
122
|
+
// Merge in order: defaults < file < env < cli
|
|
123
|
+
const config = {
|
|
124
|
+
...defaults,
|
|
125
|
+
...fileConfig,
|
|
126
|
+
...envConfig,
|
|
127
|
+
...filterUndefined(cliOptions),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Derive additional settings
|
|
131
|
+
if (config.quiet) {
|
|
132
|
+
config.logger = false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Validate SSL config
|
|
136
|
+
if ((config.sslKey && !config.sslCert) || (!config.sslKey && config.sslCert)) {
|
|
137
|
+
throw new Error('Both --ssl-key and --ssl-cert must be provided together');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
config.ssl = !!(config.sslKey && config.sslCert);
|
|
141
|
+
|
|
142
|
+
return config;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Filter out undefined values from an object
|
|
147
|
+
*/
|
|
148
|
+
function filterUndefined(obj) {
|
|
149
|
+
const result = {};
|
|
150
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
151
|
+
if (value !== undefined) {
|
|
152
|
+
result[key] = value;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Save configuration to a file
|
|
160
|
+
*/
|
|
161
|
+
export async function saveConfig(config, configFile) {
|
|
162
|
+
const toSave = { ...config };
|
|
163
|
+
// Remove derived/runtime values
|
|
164
|
+
delete toSave.ssl;
|
|
165
|
+
delete toSave.logger;
|
|
166
|
+
|
|
167
|
+
await fs.ensureDir(path.dirname(configFile));
|
|
168
|
+
await fs.writeFile(configFile, JSON.stringify(toSave, null, 2));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Print configuration (for debugging)
|
|
173
|
+
*/
|
|
174
|
+
export function printConfig(config) {
|
|
175
|
+
console.log('\nConfiguration:');
|
|
176
|
+
console.log('─'.repeat(40));
|
|
177
|
+
console.log(` Port: ${config.port}`);
|
|
178
|
+
console.log(` Host: ${config.host}`);
|
|
179
|
+
console.log(` Root: ${path.resolve(config.root)}`);
|
|
180
|
+
console.log(` SSL: ${config.ssl ? 'enabled' : 'disabled'}`);
|
|
181
|
+
console.log(` Multi-user: ${config.multiuser}`);
|
|
182
|
+
console.log(` Conneg: ${config.conneg}`);
|
|
183
|
+
console.log(` Notifications: ${config.notifications}`);
|
|
184
|
+
console.log('─'.repeat(40));
|
|
185
|
+
}
|
package/src/handlers/resource.js
CHANGED
|
@@ -315,10 +315,12 @@ export async function handleOptions(request, reply) {
|
|
|
315
315
|
|
|
316
316
|
const origin = request.headers.origin;
|
|
317
317
|
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
318
|
+
const connegEnabled = request.connegEnabled || false;
|
|
318
319
|
const headers = getAllHeaders({
|
|
319
320
|
isContainer: stats?.isDirectory || isContainer(urlPath),
|
|
320
321
|
origin,
|
|
321
|
-
resourceUrl
|
|
322
|
+
resourceUrl,
|
|
323
|
+
connegEnabled
|
|
322
324
|
});
|
|
323
325
|
|
|
324
326
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
package/src/server.js
CHANGED
|
@@ -11,6 +11,8 @@ import { notificationsPlugin } from './notifications/index.js';
|
|
|
11
11
|
* @param {boolean} options.logger - Enable logging (default true)
|
|
12
12
|
* @param {boolean} options.conneg - Enable content negotiation for RDF (default false)
|
|
13
13
|
* @param {boolean} options.notifications - Enable WebSocket notifications (default false)
|
|
14
|
+
* @param {object} options.ssl - SSL configuration { key, cert } (default null)
|
|
15
|
+
* @param {string} options.root - Data directory path (default from env or ./data)
|
|
14
16
|
*/
|
|
15
17
|
export function createServer(options = {}) {
|
|
16
18
|
// Content negotiation is OFF by default - we're a JSON-LD native server
|
|
@@ -18,12 +20,28 @@ export function createServer(options = {}) {
|
|
|
18
20
|
// WebSocket notifications are OFF by default
|
|
19
21
|
const notificationsEnabled = options.notifications ?? false;
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
// Set data root via environment variable if provided
|
|
24
|
+
if (options.root) {
|
|
25
|
+
process.env.DATA_ROOT = options.root;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Fastify options
|
|
29
|
+
const fastifyOptions = {
|
|
22
30
|
logger: options.logger ?? true,
|
|
23
31
|
trustProxy: true,
|
|
24
32
|
// Handle raw body for non-JSON content
|
|
25
33
|
bodyLimit: 10 * 1024 * 1024 // 10MB
|
|
26
|
-
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Add HTTPS support if SSL config provided
|
|
37
|
+
if (options.ssl && options.ssl.key && options.ssl.cert) {
|
|
38
|
+
fastifyOptions.https = {
|
|
39
|
+
key: options.ssl.key,
|
|
40
|
+
cert: options.ssl.cert,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fastify = Fastify(fastifyOptions);
|
|
27
45
|
|
|
28
46
|
// Add raw body parser for all content types
|
|
29
47
|
fastify.addContentTypeParser('*', { parseAs: 'buffer' }, (req, body, done) => {
|
|
@@ -54,14 +72,7 @@ export function createServer(options = {}) {
|
|
|
54
72
|
const wsProtocol = request.protocol === 'https' ? 'wss' : 'ws';
|
|
55
73
|
reply.header('Updates-Via', `${wsProtocol}://${request.hostname}/.notifications`);
|
|
56
74
|
}
|
|
57
|
-
|
|
58
|
-
// Handle preflight OPTIONS
|
|
59
|
-
if (request.method === 'OPTIONS') {
|
|
60
|
-
// Add Allow header for LDP compliance
|
|
61
|
-
reply.header('Allow', 'GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS');
|
|
62
|
-
reply.code(204).send();
|
|
63
|
-
return reply;
|
|
64
|
-
}
|
|
75
|
+
// Note: OPTIONS requests are handled by handleOptions to include Accept-* headers
|
|
65
76
|
});
|
|
66
77
|
|
|
67
78
|
// Authorization hook - check WAC permissions
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solid Conformance Tests (Simplified)
|
|
3
|
+
*
|
|
4
|
+
* Tests based on solid/solid-crud-tests but using Bearer token auth.
|
|
5
|
+
* Covers the same MUST requirements from the Solid Protocol spec.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, before, after } from 'node:test';
|
|
9
|
+
import assert from 'node:assert';
|
|
10
|
+
import {
|
|
11
|
+
startTestServer,
|
|
12
|
+
stopTestServer,
|
|
13
|
+
request,
|
|
14
|
+
createTestPod,
|
|
15
|
+
assertStatus
|
|
16
|
+
} from './helpers.js';
|
|
17
|
+
|
|
18
|
+
describe('Solid Protocol Conformance', () => {
|
|
19
|
+
let token;
|
|
20
|
+
|
|
21
|
+
before(async () => {
|
|
22
|
+
// Enable conneg for full Turtle support (required for Solid conformance)
|
|
23
|
+
await startTestServer({ conneg: true });
|
|
24
|
+
const pod = await createTestPod('conformance');
|
|
25
|
+
token = pod.token;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
after(async () => {
|
|
29
|
+
await stopTestServer();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('MUST: Create non-container using POST', () => {
|
|
33
|
+
it('creates the resource and returns 201', async () => {
|
|
34
|
+
const res = await request('/conformance/public/', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'text/turtle',
|
|
38
|
+
'Slug': 'post-created.ttl'
|
|
39
|
+
},
|
|
40
|
+
body: '<#hello> <#linked> <#world> .',
|
|
41
|
+
auth: 'conformance'
|
|
42
|
+
});
|
|
43
|
+
assertStatus(res, 201);
|
|
44
|
+
assert.ok(res.headers.get('Location'), 'Should return Location header');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('adds the resource to container listing', async () => {
|
|
48
|
+
const res = await request('/conformance/public/');
|
|
49
|
+
const body = await res.text();
|
|
50
|
+
assert.ok(body.includes('post-created.ttl'), 'Container should list the resource');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('MUST: Create non-container using PUT', () => {
|
|
55
|
+
it('creates the resource', async () => {
|
|
56
|
+
const res = await request('/conformance/public/put-created.ttl', {
|
|
57
|
+
method: 'PUT',
|
|
58
|
+
headers: { 'Content-Type': 'text/turtle' },
|
|
59
|
+
body: '<#hello> <#linked> <#world> .',
|
|
60
|
+
auth: 'conformance'
|
|
61
|
+
});
|
|
62
|
+
assert.ok([200, 201, 204].includes(res.status), `Expected 2xx, got ${res.status}`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('adds the resource to container listing', async () => {
|
|
66
|
+
const res = await request('/conformance/public/');
|
|
67
|
+
const body = await res.text();
|
|
68
|
+
assert.ok(body.includes('put-created.ttl'), 'Container should list the resource');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('MUST: Create container using PUT', () => {
|
|
73
|
+
it('creates container with trailing slash', async () => {
|
|
74
|
+
// First create a resource inside the new container (creates container implicitly)
|
|
75
|
+
const res = await request('/conformance/public/new-container/test.ttl', {
|
|
76
|
+
method: 'PUT',
|
|
77
|
+
headers: { 'Content-Type': 'text/turtle' },
|
|
78
|
+
body: '<#test> <#is> <#here> .',
|
|
79
|
+
auth: 'conformance'
|
|
80
|
+
});
|
|
81
|
+
assert.ok([200, 201, 204].includes(res.status));
|
|
82
|
+
|
|
83
|
+
// Container should exist
|
|
84
|
+
const containerRes = await request('/conformance/public/new-container/');
|
|
85
|
+
assertStatus(containerRes, 200);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('MUST: Update using PUT', () => {
|
|
90
|
+
it('overwrites existing resource', async () => {
|
|
91
|
+
// Create
|
|
92
|
+
await request('/conformance/public/update-test.ttl', {
|
|
93
|
+
method: 'PUT',
|
|
94
|
+
headers: { 'Content-Type': 'text/turtle' },
|
|
95
|
+
body: '<#v> <#is> "1" .',
|
|
96
|
+
auth: 'conformance'
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Update
|
|
100
|
+
const res = await request('/conformance/public/update-test.ttl', {
|
|
101
|
+
method: 'PUT',
|
|
102
|
+
headers: { 'Content-Type': 'text/turtle' },
|
|
103
|
+
body: '<#v> <#is> "2" .',
|
|
104
|
+
auth: 'conformance'
|
|
105
|
+
});
|
|
106
|
+
assertStatus(res, 204);
|
|
107
|
+
|
|
108
|
+
// Verify
|
|
109
|
+
const getRes = await request('/conformance/public/update-test.ttl');
|
|
110
|
+
const body = await getRes.text();
|
|
111
|
+
assert.ok(body.includes('"2"'), 'Resource should be updated');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('MUST: Update using PATCH (N3)', () => {
|
|
116
|
+
it('adds triple to existing resource', async () => {
|
|
117
|
+
// Create resource with context for cleaner patch matching
|
|
118
|
+
await request('/conformance/public/patch-test.json', {
|
|
119
|
+
method: 'PUT',
|
|
120
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
'@context': { 'ex': 'http://example.org/' },
|
|
123
|
+
'@id': '#me',
|
|
124
|
+
'ex:name': 'Test'
|
|
125
|
+
}),
|
|
126
|
+
auth: 'conformance'
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const patch = `
|
|
130
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
131
|
+
@prefix ex: <http://example.org/>.
|
|
132
|
+
_:patch a solid:InsertDeletePatch;
|
|
133
|
+
solid:inserts { <#me> ex:added "yes" }.
|
|
134
|
+
`;
|
|
135
|
+
const res = await request('/conformance/public/patch-test.json', {
|
|
136
|
+
method: 'PATCH',
|
|
137
|
+
headers: { 'Content-Type': 'text/n3' },
|
|
138
|
+
body: patch,
|
|
139
|
+
auth: 'conformance'
|
|
140
|
+
});
|
|
141
|
+
assertStatus(res, 204);
|
|
142
|
+
|
|
143
|
+
const getRes = await request('/conformance/public/patch-test.json', {
|
|
144
|
+
headers: { 'Accept': 'application/ld+json' }
|
|
145
|
+
});
|
|
146
|
+
const data = await getRes.json();
|
|
147
|
+
// Check for either prefixed or full URI form
|
|
148
|
+
const added = data['ex:added'] || data['http://example.org/added'];
|
|
149
|
+
assert.strictEqual(added, 'yes');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('MUST: Update using PATCH (SPARQL Update)', () => {
|
|
154
|
+
it('modifies resource with INSERT DATA', async () => {
|
|
155
|
+
await request('/conformance/public/sparql-test.json', {
|
|
156
|
+
method: 'PUT',
|
|
157
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
158
|
+
body: JSON.stringify({ '@id': '#item' }),
|
|
159
|
+
auth: 'conformance'
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const sparql = `
|
|
163
|
+
PREFIX ex: <http://example.org/>
|
|
164
|
+
INSERT DATA { <#item> ex:status "active" }
|
|
165
|
+
`;
|
|
166
|
+
const res = await request('/conformance/public/sparql-test.json', {
|
|
167
|
+
method: 'PATCH',
|
|
168
|
+
headers: { 'Content-Type': 'application/sparql-update' },
|
|
169
|
+
body: sparql,
|
|
170
|
+
auth: 'conformance'
|
|
171
|
+
});
|
|
172
|
+
assertStatus(res, 204);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('MUST: Delete resource', () => {
|
|
177
|
+
it('deletes and returns 204', async () => {
|
|
178
|
+
await request('/conformance/public/to-delete.ttl', {
|
|
179
|
+
method: 'PUT',
|
|
180
|
+
headers: { 'Content-Type': 'text/turtle' },
|
|
181
|
+
body: '<#x> <#y> <#z> .',
|
|
182
|
+
auth: 'conformance'
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const res = await request('/conformance/public/to-delete.ttl', {
|
|
186
|
+
method: 'DELETE',
|
|
187
|
+
auth: 'conformance'
|
|
188
|
+
});
|
|
189
|
+
assertStatus(res, 204);
|
|
190
|
+
|
|
191
|
+
const getRes = await request('/conformance/public/to-delete.ttl');
|
|
192
|
+
assertStatus(getRes, 404);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('removes from container listing', async () => {
|
|
196
|
+
const res = await request('/conformance/public/');
|
|
197
|
+
const body = await res.text();
|
|
198
|
+
assert.ok(!body.includes('to-delete.ttl'), 'Should not be in listing');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('MUST: LDP Headers', () => {
|
|
203
|
+
it('includes Link rel=type for containers', async () => {
|
|
204
|
+
const res = await request('/conformance/public/');
|
|
205
|
+
const link = res.headers.get('Link');
|
|
206
|
+
assert.ok(link.includes('ldp#BasicContainer'), 'Should have BasicContainer type');
|
|
207
|
+
assert.ok(link.includes('ldp#Container'), 'Should have Container type');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('includes Link rel=type for resources', async () => {
|
|
211
|
+
const res = await request('/conformance/public/put-created.ttl');
|
|
212
|
+
const link = res.headers.get('Link');
|
|
213
|
+
assert.ok(link.includes('ldp#Resource'), 'Should have Resource type');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('includes Link rel=acl', async () => {
|
|
217
|
+
const res = await request('/conformance/public/put-created.ttl');
|
|
218
|
+
const link = res.headers.get('Link');
|
|
219
|
+
assert.ok(link.includes('rel="acl"'), 'Should have acl link');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('includes ETag header', async () => {
|
|
223
|
+
const res = await request('/conformance/public/put-created.ttl');
|
|
224
|
+
assert.ok(res.headers.get('ETag'), 'Should have ETag');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('includes Allow header on OPTIONS', async () => {
|
|
228
|
+
const res = await request('/conformance/public/', { method: 'OPTIONS' });
|
|
229
|
+
const allow = res.headers.get('Allow');
|
|
230
|
+
assert.ok(allow.includes('GET'), 'Should allow GET');
|
|
231
|
+
assert.ok(allow.includes('POST'), 'Should allow POST');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('includes Accept-Post for containers', async () => {
|
|
235
|
+
const res = await request('/conformance/public/', { method: 'OPTIONS' });
|
|
236
|
+
assert.ok(res.headers.get('Accept-Post'), 'Should have Accept-Post');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('includes Accept-Put for resources', async () => {
|
|
240
|
+
const res = await request('/conformance/public/put-created.ttl', { method: 'OPTIONS' });
|
|
241
|
+
assert.ok(res.headers.get('Accept-Put'), 'Should have Accept-Put');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('includes Accept-Patch for resources', async () => {
|
|
245
|
+
const res = await request('/conformance/public/put-created.ttl', { method: 'OPTIONS' });
|
|
246
|
+
const acceptPatch = res.headers.get('Accept-Patch');
|
|
247
|
+
assert.ok(acceptPatch, 'Should have Accept-Patch');
|
|
248
|
+
assert.ok(acceptPatch.includes('text/n3'), 'Should accept N3');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('MUST: WAC Headers', () => {
|
|
253
|
+
it('includes WAC-Allow header', async () => {
|
|
254
|
+
const res = await request('/conformance/public/');
|
|
255
|
+
assert.ok(res.headers.get('WAC-Allow'), 'Should have WAC-Allow');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('MUST: Conditional Requests', () => {
|
|
260
|
+
it('returns 304 for If-None-Match on GET', async () => {
|
|
261
|
+
const res1 = await request('/conformance/public/put-created.ttl');
|
|
262
|
+
const etag = res1.headers.get('ETag');
|
|
263
|
+
|
|
264
|
+
const res2 = await request('/conformance/public/put-created.ttl', {
|
|
265
|
+
headers: { 'If-None-Match': etag }
|
|
266
|
+
});
|
|
267
|
+
assertStatus(res2, 304);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('returns 412 for If-Match mismatch on PUT', async () => {
|
|
271
|
+
const res = await request('/conformance/public/put-created.ttl', {
|
|
272
|
+
method: 'PUT',
|
|
273
|
+
headers: {
|
|
274
|
+
'Content-Type': 'text/turtle',
|
|
275
|
+
'If-Match': '"wrong-etag"'
|
|
276
|
+
},
|
|
277
|
+
body: '<#new> <#data> <#here> .',
|
|
278
|
+
auth: 'conformance'
|
|
279
|
+
});
|
|
280
|
+
assertStatus(res, 412);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('returns 412 for If-None-Match: * on existing resource', async () => {
|
|
284
|
+
const res = await request('/conformance/public/put-created.ttl', {
|
|
285
|
+
method: 'PUT',
|
|
286
|
+
headers: {
|
|
287
|
+
'Content-Type': 'text/turtle',
|
|
288
|
+
'If-None-Match': '*'
|
|
289
|
+
},
|
|
290
|
+
body: '<#new> <#data> <#here> .',
|
|
291
|
+
auth: 'conformance'
|
|
292
|
+
});
|
|
293
|
+
assertStatus(res, 412);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('MUST: CORS Headers', () => {
|
|
298
|
+
it('includes Access-Control-Allow-Origin', async () => {
|
|
299
|
+
const res = await request('/conformance/public/', {
|
|
300
|
+
headers: { 'Origin': 'https://example.com' }
|
|
301
|
+
});
|
|
302
|
+
const acao = res.headers.get('Access-Control-Allow-Origin');
|
|
303
|
+
assert.ok(acao, 'Should have ACAO header');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('includes Access-Control-Expose-Headers', async () => {
|
|
307
|
+
const res = await request('/conformance/public/', {
|
|
308
|
+
headers: { 'Origin': 'https://example.com' }
|
|
309
|
+
});
|
|
310
|
+
const expose = res.headers.get('Access-Control-Expose-Headers');
|
|
311
|
+
assert.ok(expose, 'Should expose headers');
|
|
312
|
+
assert.ok(expose.includes('Location'), 'Should expose Location');
|
|
313
|
+
assert.ok(expose.includes('Link'), 'Should expose Link');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('handles preflight OPTIONS', async () => {
|
|
317
|
+
const res = await request('/conformance/public/', {
|
|
318
|
+
method: 'OPTIONS',
|
|
319
|
+
headers: {
|
|
320
|
+
'Origin': 'https://example.com',
|
|
321
|
+
'Access-Control-Request-Method': 'PUT'
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
assertStatus(res, 204);
|
|
325
|
+
assert.ok(res.headers.get('Access-Control-Allow-Methods'), 'Should have Allow-Methods');
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('MUST: Content Negotiation', () => {
|
|
330
|
+
it('returns JSON-LD by default for RDF resources', async () => {
|
|
331
|
+
const res = await request('/conformance/public/patch-test.json');
|
|
332
|
+
const ct = res.headers.get('Content-Type');
|
|
333
|
+
assert.ok(ct.includes('application/ld+json') || ct.includes('application/json'));
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe('SHOULD: WebSocket Notifications', () => {
|
|
338
|
+
it('includes Updates-Via header when enabled', async () => {
|
|
339
|
+
// Note: requires server started with notifications: true
|
|
340
|
+
// This test documents the expected behavior
|
|
341
|
+
const res = await request('/conformance/', { method: 'OPTIONS' });
|
|
342
|
+
// Updates-Via may or may not be present depending on server config
|
|
343
|
+
const updatesVia = res.headers.get('Updates-Via');
|
|
344
|
+
if (updatesVia) {
|
|
345
|
+
assert.ok(updatesVia.includes('ws'), 'Should be WebSocket URL');
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
});
|