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.
@@ -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.10)
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
- ### Running
102
+ ### Quick Start
98
103
 
99
104
  ```bash
100
- # Start server (default port 3000)
101
- npm start
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
- # Development mode with watch
104
- npm dev
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: **136 tests**
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.10",
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 src/index.js",
17
- "dev": "node --watch src/index.js",
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
+ }
@@ -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
- const fastify = Fastify({
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
+ });