javascript-solid-server 0.0.108 → 0.0.110

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/README.md CHANGED
@@ -164,6 +164,8 @@ jss --help # Show help
164
164
  | `--mongo-database <name>` | MongoDB database name | solid |
165
165
  | `--webrtc` | Enable WebRTC signaling server | false |
166
166
  | `--webrtc-path <path>` | WebRTC signaling WebSocket path | /.webrtc |
167
+ | `--tunnel` | Enable tunnel proxy (decentralized ngrok) | false |
168
+ | `--tunnel-path <path>` | Tunnel WebSocket path | /.tunnel |
167
169
  | `-q, --quiet` | Suppress logs | false |
168
170
 
169
171
  ### Environment Variables
@@ -862,6 +864,36 @@ Messages are JSON over WebSocket:
862
864
 
863
865
  On connect, peers receive a list of online users and get notified when others join or leave.
864
866
 
867
+ ## Tunnel Proxy (Decentralized ngrok)
868
+
869
+ Expose a local dev server to the internet through your JSS pod. A tunnel client connects via WebSocket, registers a name, and receives proxied HTTP requests.
870
+
871
+ ```bash
872
+ jss start --tunnel
873
+ ```
874
+
875
+ ### How It Works
876
+
877
+ 1. Tunnel client connects to `wss://your.pod/.tunnel` (WebID auth required)
878
+ 2. Client registers a name: `{ "type": "register", "name": "myapp" }`
879
+ 3. Public URL becomes available at `https://your.pod/tunnel/myapp/`
880
+ 4. HTTP requests to that URL are serialized and sent to the tunnel client over WebSocket
881
+ 5. Tunnel client forwards to localhost, returns the response
882
+
883
+ ### Tunnel Client Protocol
884
+
885
+ ```js
886
+ // 1. Register a tunnel
887
+ → { "type": "register", "name": "myapp" }
888
+ ← { "type": "registered", "name": "myapp", "url": "/tunnel/myapp/" }
889
+
890
+ // 2. Receive proxied HTTP requests
891
+ ← { "type": "request", "id": "uuid", "method": "GET", "path": "/api/hello", "headers": {...} }
892
+
893
+ // 3. Return the response
894
+ → { "type": "response", "id": "uuid", "status": 200, "headers": {...}, "body": "..." }
895
+ ```
896
+
865
897
  ## HTTP 402 Paid Access
866
898
 
867
899
  Monetize API endpoints with per-request satoshi payments. Resources under `/pay/*` require NIP-98 authentication and a positive balance.
package/bin/jss.js CHANGED
@@ -68,6 +68,9 @@ program
68
68
  .option('--webrtc', 'Enable WebRTC signaling server')
69
69
  .option('--no-webrtc', 'Disable WebRTC signaling server')
70
70
  .option('--webrtc-path <path>', 'WebRTC signaling WebSocket path (default: /.webrtc)')
71
+ .option('--tunnel', 'Enable tunnel proxy (decentralized ngrok)')
72
+ .option('--no-tunnel', 'Disable tunnel proxy')
73
+ .option('--tunnel-path <path>', 'Tunnel WebSocket path (default: /.tunnel)')
71
74
  .option('--activitypub', 'Enable ActivityPub federation')
72
75
  .option('--no-activitypub', 'Disable ActivityPub federation')
73
76
  .option('--ap-username <name>', 'ActivityPub username (default: me)')
@@ -147,6 +150,8 @@ program
147
150
  nostrMaxEvents: config.nostrMaxEvents,
148
151
  webrtc: config.webrtc,
149
152
  webrtcPath: config.webrtcPath,
153
+ tunnel: config.tunnel,
154
+ tunnelPath: config.tunnelPath,
150
155
  activitypub: config.activitypub,
151
156
  apUsername: config.apUsername,
152
157
  apDisplayName: config.apDisplayName,
@@ -192,6 +197,7 @@ program
192
197
  if (config.git) console.log(' Git: enabled (clone/push support)');
193
198
  if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
194
199
  if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`);
200
+ if (config.tunnel) console.log(` Tunnel: enabled (${config.tunnelPath || '/.tunnel'})`);
195
201
  if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
196
202
  if (config.singleUser) console.log(` Single-user: ${config.singleUserName || 'me'} (registration disabled)`);
197
203
  else if (config.inviteOnly) console.log(' Registration: invite-only');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.108",
3
+ "version": "0.0.110",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/config.js CHANGED
@@ -58,6 +58,10 @@ export const defaults = {
58
58
  webrtc: false,
59
59
  webrtcPath: '/.webrtc',
60
60
 
61
+ // Tunnel (decentralized ngrok)
62
+ tunnel: false,
63
+ tunnelPath: '/.tunnel',
64
+
61
65
  // ActivityPub federation
62
66
  activitypub: false,
63
67
  apUsername: 'me',
@@ -140,6 +144,8 @@ const envMap = {
140
144
  JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
141
145
  JSS_WEBRTC: 'webrtc',
142
146
  JSS_WEBRTC_PATH: 'webrtcPath',
147
+ JSS_TUNNEL: 'tunnel',
148
+ JSS_TUNNEL_PATH: 'tunnelPath',
143
149
  JSS_ACTIVITYPUB: 'activitypub',
144
150
  JSS_AP_USERNAME: 'apUsername',
145
151
  JSS_AP_DISPLAY_NAME: 'apDisplayName',
@@ -57,6 +57,7 @@ export function parseN3Patch(patchText, baseUri) {
57
57
 
58
58
  /**
59
59
  * Parse triples from N3 block content
60
+ * Handles Turtle semicolon shorthand (same subject, different predicate-object)
60
61
  */
61
62
  function parseTriples(content, prefixes, baseUri) {
62
63
  const triples = [];
@@ -68,11 +69,37 @@ function parseTriples(content, prefixes, baseUri) {
68
69
  // Split by '.' but be careful with strings containing '.'
69
70
  const statements = splitStatements(content);
70
71
 
72
+ let lastSubject = null;
71
73
  for (const stmt of statements) {
72
- const triple = parseStatement(stmt.trim(), prefixes, baseUri);
73
- if (triple) {
74
- triples.push(triple);
74
+ const trimmed = stmt.trim();
75
+ if (!trimmed) continue;
76
+
77
+ const tokens = tokenize(trimmed);
78
+ if (tokens.length < 2) continue;
79
+
80
+ let subject, predicate, object;
81
+
82
+ if (tokens.length >= 3) {
83
+ // Full triple: subject predicate object
84
+ subject = resolveValue(tokens[0], prefixes, baseUri);
85
+ predicate = resolveValue(tokens[1], prefixes, baseUri);
86
+ object = resolveValue(tokens.slice(2).join(' '), prefixes, baseUri);
87
+ lastSubject = subject;
88
+ } else if (tokens.length === 2 && lastSubject) {
89
+ // Semicolon continuation: predicate object (reuse last subject)
90
+ subject = lastSubject;
91
+ predicate = resolveValue(tokens[0], prefixes, baseUri);
92
+ object = resolveValue(tokens[1], prefixes, baseUri);
93
+ } else {
94
+ continue;
95
+ }
96
+
97
+ // Handle 'a' as rdf:type
98
+ if (predicate === 'a') {
99
+ predicate = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
75
100
  }
101
+
102
+ triples.push({ subject, predicate, object });
76
103
  }
77
104
 
78
105
  return triples;
@@ -86,11 +113,18 @@ function splitStatements(content) {
86
113
  let current = '';
87
114
  let inString = false;
88
115
  let stringChar = null;
116
+ let inIri = false;
89
117
 
90
118
  for (let i = 0; i < content.length; i++) {
91
119
  const char = content[i];
92
120
 
93
- if (!inString && (char === '"' || char === "'")) {
121
+ if (!inString && !inIri && char === '<') {
122
+ inIri = true;
123
+ current += char;
124
+ } else if (inIri && char === '>') {
125
+ inIri = false;
126
+ current += char;
127
+ } else if (!inIri && !inString && (char === '"' || char === "'")) {
94
128
  inString = true;
95
129
  stringChar = char;
96
130
  current += char;
@@ -98,12 +132,12 @@ function splitStatements(content) {
98
132
  inString = false;
99
133
  stringChar = null;
100
134
  current += char;
101
- } else if (!inString && char === '.') {
135
+ } else if (!inString && !inIri && char === '.') {
102
136
  if (current.trim()) {
103
137
  statements.push(current);
104
138
  }
105
139
  current = '';
106
- } else if (!inString && char === ';') {
140
+ } else if (!inString && !inIri && char === ';') {
107
141
  // Turtle shorthand - same subject, different predicate
108
142
  if (current.trim()) {
109
143
  statements.push(current);
@@ -121,23 +155,6 @@ function splitStatements(content) {
121
155
  return statements;
122
156
  }
123
157
 
124
- /**
125
- * Parse a single N3 statement into a triple
126
- */
127
- function parseStatement(stmt, prefixes, baseUri) {
128
- if (!stmt) return null;
129
-
130
- // Tokenize - split by whitespace but respect quotes
131
- const tokens = tokenize(stmt);
132
- if (tokens.length < 3) return null;
133
-
134
- const subject = resolveValue(tokens[0], prefixes, baseUri);
135
- const predicate = resolveValue(tokens[1], prefixes, baseUri);
136
- const object = resolveValue(tokens.slice(2).join(' '), prefixes, baseUri);
137
-
138
- return { subject, predicate, object };
139
- }
140
-
141
158
  /**
142
159
  * Tokenize a statement respecting quoted strings
143
160
  */
package/src/server.js CHANGED
@@ -19,6 +19,7 @@ import { activityPubPlugin, getActorHandler } from './ap/index.js';
19
19
  import { remoteStoragePlugin } from './remotestorage.js';
20
20
  import { dbPlugin } from './db/index.js';
21
21
  import { webrtcPlugin } from './webrtc/index.js';
22
+ import { tunnelPlugin } from './tunnel/index.js';
22
23
 
23
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
25
 
@@ -78,6 +79,9 @@ export function createServer(options = {}) {
78
79
  // WebRTC signaling is OFF by default
79
80
  const webrtcEnabled = options.webrtc ?? false;
80
81
  const webrtcPath = options.webrtcPath ?? '/.webrtc';
82
+ // Tunnel proxy is OFF by default
83
+ const tunnelEnabled = options.tunnel ?? false;
84
+ const tunnelPath = options.tunnelPath ?? '/.tunnel';
81
85
  // ActivityPub federation is OFF by default
82
86
  const activitypubEnabled = options.activitypub ?? false;
83
87
  const apUsername = options.apUsername ?? 'me';
@@ -249,6 +253,11 @@ export function createServer(options = {}) {
249
253
  fastify.register(webrtcPlugin, { path: webrtcPath });
250
254
  }
251
255
 
256
+ // Register tunnel proxy if enabled
257
+ if (tunnelEnabled) {
258
+ fastify.register(tunnelPlugin, { path: tunnelPath });
259
+ }
260
+
252
261
  // Register ActivityPub plugin if enabled
253
262
  if (activitypubEnabled) {
254
263
  fastify.register(activityPubPlugin, {
@@ -340,8 +349,11 @@ export function createServer(options = {}) {
340
349
  return;
341
350
  }
342
351
 
343
- // Allow WebRTC signaling endpoint through when enabled
352
+ // Allow WebRTC and tunnel endpoints through when enabled
344
353
  const urlNoQuery = request.url.split('?')[0];
354
+ if (tunnelEnabled && (urlNoQuery === tunnelPath || urlNoQuery.startsWith('/tunnel/'))) {
355
+ return;
356
+ }
345
357
  if (webrtcEnabled && urlNoQuery === webrtcPath) {
346
358
  return;
347
359
  }
@@ -421,6 +433,7 @@ export function createServer(options = {}) {
421
433
  (payEnabled && isPayRequest(request.url)) ||
422
434
  (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
423
435
  (webrtcEnabled && (request.url === webrtcPath || request.url.startsWith(webrtcPath + '?'))) ||
436
+ (tunnelEnabled && (request.url === tunnelPath || request.url.startsWith(tunnelPath + '?') || request.url.startsWith('/tunnel/'))) ||
424
437
  mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
425
438
  return;
426
439
  }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Tunnel Plugin — Decentralized ngrok
3
+ *
4
+ * Tunnels HTTP traffic to a local dev server through JSS via WebSocket.
5
+ * A tunnel client connects over WebSocket, registers a name, and receives
6
+ * proxied HTTP requests which it forwards to localhost.
7
+ *
8
+ * Usage: jss start --tunnel
9
+ * Tunnel client connects to: wss://your.pod/.tunnel
10
+ * Public URL: https://your.pod/tunnel/{name}/path
11
+ *
12
+ * Tunnel client protocol (JSON over WebSocket):
13
+ * → { type: "register", name: "myapp" }
14
+ * ← { type: "registered", name: "myapp", url: "/tunnel/myapp/" }
15
+ * ← { type: "request", id: "<uuid>", method: "GET", path: "/api/hello", headers: {...}, body: "..." }
16
+ * → { type: "response", id: "<uuid>", status: 200, headers: {...}, body: "..." }
17
+ * ← { type: "error", message: "..." }
18
+ */
19
+
20
+ import websocket from '@fastify/websocket';
21
+ import { getWebIdFromRequestAsync } from '../auth/token.js';
22
+ import { randomUUID } from 'crypto';
23
+
24
+ const REQUEST_TIMEOUT = 30000; // 30s timeout for tunnel responses
25
+ const MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB
26
+
27
+ /**
28
+ * @param {object} fastify - Fastify instance
29
+ * @param {object} options - Options
30
+ * @param {string} options.path - WebSocket path for tunnel clients (default: '/.tunnel')
31
+ */
32
+ export async function tunnelPlugin(fastify, options = {}) {
33
+ const wsPath = options.path || '/.tunnel';
34
+
35
+ // Instance-scoped: tunnel name → { socket, webId }
36
+ const tunnels = new Map();
37
+ // Pending HTTP requests waiting for tunnel response: id → { resolve, timer }
38
+ const pending = new Map();
39
+
40
+ if (!fastify.websocketServer) {
41
+ await fastify.register(websocket);
42
+ }
43
+
44
+ fastify.addHook('onClose', async () => {
45
+ for (const [, tunnel] of tunnels) {
46
+ tunnel.socket.close();
47
+ }
48
+ tunnels.clear();
49
+ for (const [, p] of pending) {
50
+ clearTimeout(p.timer);
51
+ p.resolve({ status: 502, headers: {}, body: 'Tunnel shutting down' });
52
+ }
53
+ pending.clear();
54
+ });
55
+
56
+ // WebSocket endpoint for tunnel clients
57
+ fastify.get(wsPath, { websocket: true }, async (connection, request) => {
58
+ const socket = connection.socket;
59
+
60
+ // Authenticate
61
+ const { webId } = await getWebIdFromRequestAsync(request);
62
+ if (!webId) {
63
+ socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
64
+ socket.close();
65
+ return;
66
+ }
67
+
68
+ let tunnelName = null;
69
+
70
+ socket.on('message', (data) => {
71
+ const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
72
+ if (raw.byteLength > MAX_MESSAGE_SIZE) {
73
+ socket.send(JSON.stringify({ type: 'error', message: 'Message too large' }));
74
+ return;
75
+ }
76
+
77
+ let msg;
78
+ try {
79
+ msg = JSON.parse(raw.toString());
80
+ } catch {
81
+ socket.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
82
+ return;
83
+ }
84
+
85
+ if (msg.type === 'register') {
86
+ // Register a tunnel name
87
+ const name = (msg.name || '').replace(/[^a-zA-Z0-9_-]/g, '');
88
+ if (!name) {
89
+ socket.send(JSON.stringify({ type: 'error', message: 'Invalid tunnel name' }));
90
+ return;
91
+ }
92
+
93
+ const existing = tunnels.get(name);
94
+ if (existing && existing.webId !== webId) {
95
+ socket.send(JSON.stringify({ type: 'error', message: 'Tunnel name taken by another user' }));
96
+ return;
97
+ }
98
+
99
+ // Close old tunnel with same name from same user
100
+ if (existing) {
101
+ existing.socket.close();
102
+ }
103
+
104
+ tunnelName = name;
105
+ tunnels.set(name, { socket, webId });
106
+ socket.send(JSON.stringify({ type: 'registered', name, url: `/tunnel/${name}/` }));
107
+
108
+ } else if (msg.type === 'response') {
109
+ // Tunnel client returning an HTTP response
110
+ if (!msg.id) return;
111
+ const p = pending.get(msg.id);
112
+ if (p) {
113
+ clearTimeout(p.timer);
114
+ pending.delete(msg.id);
115
+ p.resolve({
116
+ status: msg.status || 502,
117
+ headers: msg.headers || {},
118
+ body: msg.body || '',
119
+ bodyEncoding: msg.bodyEncoding
120
+ });
121
+ }
122
+ }
123
+ });
124
+
125
+ socket.on('close', () => {
126
+ if (tunnelName && tunnels.get(tunnelName)?.socket === socket) {
127
+ tunnels.delete(tunnelName);
128
+ // Resolve pending requests for this tunnel only with 502
129
+ for (const [id, p] of pending) {
130
+ if (p.tunnelName === tunnelName) {
131
+ clearTimeout(p.timer);
132
+ pending.delete(id);
133
+ p.resolve({ status: 502, headers: {}, body: 'Tunnel disconnected' });
134
+ }
135
+ }
136
+ }
137
+ });
138
+
139
+ socket.on('error', () => {});
140
+ });
141
+
142
+ // HTTP proxy: /tunnel/{name}/*
143
+ fastify.all('/tunnel/:name/*', async (request, reply) => {
144
+ const { name } = request.params;
145
+ const tunnel = tunnels.get(name);
146
+
147
+ if (!tunnel || tunnel.socket.readyState !== 1) {
148
+ return reply.code(502).send({ error: 'Bad Gateway', message: 'Tunnel not connected' });
149
+ }
150
+
151
+ // Build the downstream path (strip /tunnel/{name} prefix)
152
+ const fullPath = request.url.replace(`/tunnel/${name}`, '') || '/';
153
+ const id = randomUUID();
154
+
155
+ // Serialize the HTTP request
156
+ const tunnelReq = Object.create(null);
157
+ tunnelReq.type = 'request';
158
+ tunnelReq.id = id;
159
+ tunnelReq.method = request.method;
160
+ tunnelReq.path = fullPath;
161
+ tunnelReq.headers = Object.create(null);
162
+ // Forward relevant headers (skip hop-by-hop)
163
+ const skipHeaders = new Set(['host', 'connection', 'upgrade', 'transfer-encoding', 'cookie', 'authorization', 'proxy-authorization']);
164
+ for (const [k, v] of Object.entries(request.headers)) {
165
+ if (!skipHeaders.has(k.toLowerCase())) {
166
+ tunnelReq.headers[k] = v;
167
+ }
168
+ }
169
+ // Forward body if present
170
+ if (request.body) {
171
+ tunnelReq.body = Buffer.isBuffer(request.body)
172
+ ? request.body.toString('base64')
173
+ : typeof request.body === 'string' ? request.body : JSON.stringify(request.body);
174
+ tunnelReq.bodyEncoding = Buffer.isBuffer(request.body) ? 'base64' : 'utf8';
175
+ }
176
+
177
+ // Send to tunnel client and wait for response
178
+ const responsePromise = new Promise((resolve) => {
179
+ const timer = setTimeout(() => {
180
+ pending.delete(id);
181
+ resolve({ status: 504, headers: {}, body: 'Gateway Timeout' });
182
+ }, REQUEST_TIMEOUT);
183
+ pending.set(id, { resolve, timer, tunnelName: name });
184
+ });
185
+
186
+ try {
187
+ tunnel.socket.send(JSON.stringify(tunnelReq));
188
+ } catch {
189
+ const p = pending.get(id);
190
+ if (p) { clearTimeout(p.timer); pending.delete(id); }
191
+ return reply.code(502).send({ error: 'Bad Gateway', message: 'Failed to reach tunnel client' });
192
+ }
193
+
194
+ const res = await responsePromise;
195
+
196
+ // Set response headers
197
+ const hopHeaders = new Set(['connection', 'transfer-encoding', 'keep-alive', 'set-cookie']);
198
+ for (const [k, v] of Object.entries(res.headers)) {
199
+ if (!hopHeaders.has(k.toLowerCase())) {
200
+ reply.header(k, v);
201
+ }
202
+ }
203
+
204
+ // Decode body if base64
205
+ const body = res.bodyEncoding === 'base64' && res.body
206
+ ? Buffer.from(res.body, 'base64')
207
+ : res.body || '';
208
+
209
+ return reply.code(res.status).send(body);
210
+ });
211
+
212
+ // Also handle /tunnel/{name} without trailing path
213
+ fastify.all('/tunnel/:name', async (request, reply) => {
214
+ // Redirect to add trailing slash, or proxy as root
215
+ const { name } = request.params;
216
+ const tunnel = tunnels.get(name);
217
+
218
+ if (!tunnel || tunnel.socket.readyState !== 1) {
219
+ return reply.code(502).send({ error: 'Bad Gateway', message: 'Tunnel not connected' });
220
+ }
221
+
222
+ return reply.redirect(308, `/tunnel/${name}/`);
223
+ });
224
+ }
225
+
226
+ export default tunnelPlugin;
@@ -185,6 +185,67 @@ describe('PATCH Operations', () => {
185
185
  assert.ok(data['@graph'], 'Should have @graph');
186
186
  assert.strictEqual(data['@graph'].length, 2, 'Should have 2 nodes');
187
187
  });
188
+
189
+ it('should handle semicolon shorthand and rdf:type "a" keyword', async () => {
190
+ // Create initial resource with @graph
191
+ const initial = {
192
+ '@context': { 'solid': 'http://www.w3.org/ns/solid/terms#' },
193
+ '@graph': []
194
+ };
195
+
196
+ await request('/patchtest/public/patch-semicolon.json', {
197
+ method: 'PUT',
198
+ headers: { 'Content-Type': 'application/ld+json' },
199
+ body: JSON.stringify(initial),
200
+ auth: 'patchtest'
201
+ });
202
+
203
+ // Use semicolons and 'a' keyword (Turtle shorthand)
204
+ const patch = `
205
+ @prefix solid: <http://www.w3.org/ns/solid/terms#>.
206
+ @prefix wf: <http://www.w3.org/2005/01/wf/flow#>.
207
+ _:patch a solid:InsertDeletePatch;
208
+ solid:inserts {
209
+ <#reg1> a solid:TypeRegistration;
210
+ solid:forClass wf:Tracker;
211
+ solid:instance <https://example.com/todo/data.jsonld#this>.
212
+ }.
213
+ `;
214
+
215
+ const res = await request('/patchtest/public/patch-semicolon.json', {
216
+ method: 'PATCH',
217
+ headers: { 'Content-Type': 'text/n3' },
218
+ body: patch,
219
+ auth: 'patchtest'
220
+ });
221
+
222
+ assertStatus(res, 204);
223
+
224
+ // Verify all three triples were inserted
225
+ const verify = await request('/patchtest/public/patch-semicolon.json');
226
+ const data = await verify.json();
227
+ const node = data['@graph'].find(n => n['@id'] && n['@id'].includes('#reg1'));
228
+ assert.ok(node, 'Should have the reg1 node');
229
+
230
+ // Check rdf:type value (from 'a' keyword)
231
+ const rdfType = node['rdf:type'] || node['http://www.w3.org/1999/02/22-rdf-syntax-ns#type'];
232
+ assert.ok(rdfType, 'Should have rdf:type (from "a" keyword)');
233
+ const typeId = rdfType['@id'] || rdfType;
234
+ assert.ok(String(typeId).includes('TypeRegistration'), `rdf:type should be TypeRegistration, got ${typeId}`);
235
+
236
+ // Check solid:forClass value
237
+ const forClass = node['solid:forClass'];
238
+ assert.ok(forClass, 'Should have solid:forClass');
239
+ const forClassId = forClass['@id'] || forClass;
240
+ assert.ok(String(forClassId).includes('Tracker'), `solid:forClass should be Tracker, got ${forClassId}`);
241
+
242
+ // Check solid:instance value (contains a dot in the IRI - tests IRI splitting)
243
+ const instance = node['solid:instance'];
244
+ assert.ok(instance, 'Should have solid:instance');
245
+ const instanceId = instance['@id'] || instance;
246
+ assert.strictEqual(instanceId, 'https://example.com/todo/data.jsonld#this',
247
+ 'solid:instance should have full IRI preserved');
248
+ });
188
249
  });
189
250
 
190
251
  describe('PATCH Error Handling', () => {
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Tunnel Proxy Tests
3
+ */
4
+
5
+ import { describe, it, before, after } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { WebSocket } from 'ws';
8
+ import {
9
+ startTestServer,
10
+ stopTestServer,
11
+ createTestPod,
12
+ getBaseUrl,
13
+ getPodToken
14
+ } from './helpers.js';
15
+
16
+ describe('Tunnel Proxy', () => {
17
+ let wsUrl, baseUrl;
18
+
19
+ before(async () => {
20
+ await startTestServer({ tunnel: true });
21
+ await createTestPod('tunneler');
22
+ baseUrl = getBaseUrl();
23
+ wsUrl = baseUrl.replace('http', 'ws') + '/.tunnel';
24
+ });
25
+
26
+ after(async () => {
27
+ await stopTestServer();
28
+ });
29
+
30
+ function connectTunnel() {
31
+ const token = getPodToken('tunneler');
32
+ return new WebSocket(wsUrl, {
33
+ headers: { 'Authorization': `Bearer ${token}` }
34
+ });
35
+ }
36
+
37
+ function waitMsg(ws, type, timeout = 5000) {
38
+ return new Promise((resolve, reject) => {
39
+ function handler(data) {
40
+ const msg = JSON.parse(data.toString());
41
+ if (msg.type === type) {
42
+ clearTimeout(timer);
43
+ ws.removeListener('message', handler);
44
+ ws.removeListener('close', onClose);
45
+ resolve(msg);
46
+ }
47
+ }
48
+ function onClose() {
49
+ clearTimeout(timer);
50
+ ws.removeListener('message', handler);
51
+ reject(new Error(`WebSocket closed while waiting for "${type}"`));
52
+ }
53
+ const timer = setTimeout(() => {
54
+ ws.removeListener('message', handler);
55
+ ws.removeListener('close', onClose);
56
+ reject(new Error(`Timeout waiting for "${type}"`));
57
+ }, timeout);
58
+ ws.on('message', handler);
59
+ ws.on('close', onClose);
60
+ });
61
+ }
62
+
63
+ describe('Registration', () => {
64
+ it('should reject unauthenticated connections', async () => {
65
+ const ws = new WebSocket(wsUrl);
66
+ const msg = await waitMsg(ws, 'error');
67
+ assert.ok(msg.message.includes('Authentication'));
68
+ ws.close();
69
+ });
70
+
71
+ it('should register a tunnel name', async () => {
72
+ const ws = connectTunnel();
73
+ await new Promise(r => ws.on('open', r));
74
+
75
+ ws.send(JSON.stringify({ type: 'register', name: 'myapp' }));
76
+ const msg = await waitMsg(ws, 'registered');
77
+ assert.strictEqual(msg.name, 'myapp');
78
+ assert.strictEqual(msg.url, '/tunnel/myapp/');
79
+
80
+ ws.close();
81
+ await new Promise(r => setTimeout(r, 50));
82
+ });
83
+
84
+ it('should reject invalid tunnel names', async () => {
85
+ const ws = connectTunnel();
86
+ await new Promise(r => ws.on('open', r));
87
+
88
+ ws.send(JSON.stringify({ type: 'register', name: '...' }));
89
+ const msg = await waitMsg(ws, 'error');
90
+ assert.ok(msg.message.includes('Invalid'));
91
+
92
+ ws.close();
93
+ await new Promise(r => setTimeout(r, 50));
94
+ });
95
+ });
96
+
97
+ describe('HTTP Proxying', () => {
98
+ it('should proxy GET requests through the tunnel', async () => {
99
+ const ws = connectTunnel();
100
+ await new Promise(r => ws.on('open', r));
101
+
102
+ // Register tunnel
103
+ ws.send(JSON.stringify({ type: 'register', name: 'testapp' }));
104
+ await waitMsg(ws, 'registered');
105
+
106
+ // Listen for tunnel requests and respond
107
+ ws.on('message', (data) => {
108
+ const msg = JSON.parse(data.toString());
109
+ if (msg.type === 'request') {
110
+ ws.send(JSON.stringify({
111
+ type: 'response',
112
+ id: msg.id,
113
+ status: 200,
114
+ headers: { 'content-type': 'application/json' },
115
+ body: JSON.stringify({ hello: 'world', path: msg.path })
116
+ }));
117
+ }
118
+ });
119
+
120
+ // Make HTTP request through the tunnel
121
+ const res = await fetch(`${baseUrl}/tunnel/testapp/api/hello`);
122
+ assert.strictEqual(res.status, 200);
123
+ const body = await res.json();
124
+ assert.strictEqual(body.hello, 'world');
125
+ assert.strictEqual(body.path, '/api/hello');
126
+
127
+ ws.close();
128
+ await new Promise(r => setTimeout(r, 50));
129
+ });
130
+
131
+ it('should proxy POST requests with body', async () => {
132
+ const ws = connectTunnel();
133
+ await new Promise(r => ws.on('open', r));
134
+
135
+ ws.send(JSON.stringify({ type: 'register', name: 'postapp' }));
136
+ await waitMsg(ws, 'registered');
137
+
138
+ ws.on('message', (data) => {
139
+ const msg = JSON.parse(data.toString());
140
+ if (msg.type === 'request') {
141
+ assert.strictEqual(msg.method, 'POST');
142
+ ws.send(JSON.stringify({
143
+ type: 'response',
144
+ id: msg.id,
145
+ status: 201,
146
+ headers: { 'content-type': 'text/plain' },
147
+ body: 'Created'
148
+ }));
149
+ }
150
+ });
151
+
152
+ const res = await fetch(`${baseUrl}/tunnel/postapp/items`, {
153
+ method: 'POST',
154
+ headers: { 'Content-Type': 'application/json' },
155
+ body: JSON.stringify({ name: 'test' })
156
+ });
157
+ assert.strictEqual(res.status, 201);
158
+ assert.strictEqual(await res.text(), 'Created');
159
+
160
+ ws.close();
161
+ await new Promise(r => setTimeout(r, 50));
162
+ });
163
+
164
+ it('should return 502 for unregistered tunnel', async () => {
165
+ const res = await fetch(`${baseUrl}/tunnel/nonexistent/path`);
166
+ assert.strictEqual(res.status, 502);
167
+ });
168
+
169
+ it('should forward custom headers', async () => {
170
+ const ws = connectTunnel();
171
+ await new Promise(r => ws.on('open', r));
172
+
173
+ ws.send(JSON.stringify({ type: 'register', name: 'headerapp' }));
174
+ await waitMsg(ws, 'registered');
175
+
176
+ let receivedHeaders;
177
+ ws.on('message', (data) => {
178
+ const msg = JSON.parse(data.toString());
179
+ if (msg.type === 'request') {
180
+ receivedHeaders = msg.headers;
181
+ ws.send(JSON.stringify({
182
+ type: 'response',
183
+ id: msg.id,
184
+ status: 200,
185
+ headers: { 'x-custom-response': 'from-tunnel' },
186
+ body: 'ok'
187
+ }));
188
+ }
189
+ });
190
+
191
+ const res = await fetch(`${baseUrl}/tunnel/headerapp/`, {
192
+ headers: { 'X-Custom-Request': 'to-tunnel' }
193
+ });
194
+ assert.strictEqual(res.status, 200);
195
+ assert.strictEqual(res.headers.get('x-custom-response'), 'from-tunnel');
196
+ assert.strictEqual(receivedHeaders['x-custom-request'], 'to-tunnel');
197
+
198
+ ws.close();
199
+ await new Promise(r => setTimeout(r, 50));
200
+ });
201
+ });
202
+ });
@@ -1,90 +0,0 @@
1
- import { createServer } from './src/server.js';
2
- import { WebSocket } from 'ws';
3
-
4
- const PORT = 9877;
5
- const BASE = `http://127.0.0.1:${PORT}`;
6
-
7
- // Start server
8
- const server = createServer({ logger: false, webrtc: true, forceCloseConnections: true });
9
- await server.listen({ port: PORT, host: '127.0.0.1' });
10
- console.log(`Server running on port ${PORT}`);
11
-
12
- // Create two pods and get tokens
13
- const aliceRes = await (await fetch(`${BASE}/.pods`, {
14
- method: 'POST', headers: { 'Content-Type': 'application/json' },
15
- body: JSON.stringify({ name: 'alice' })
16
- })).json();
17
-
18
- const bobRes = await (await fetch(`${BASE}/.pods`, {
19
- method: 'POST', headers: { 'Content-Type': 'application/json' },
20
- body: JSON.stringify({ name: 'bob' })
21
- })).json();
22
-
23
- console.log(`Alice WebID: ${aliceRes.webId}`);
24
- console.log(`Bob WebID: ${bobRes.webId}`);
25
-
26
- function connectWs(token) {
27
- return new WebSocket(`ws://127.0.0.1:${PORT}/.webrtc`, {
28
- headers: { 'Authorization': `Bearer ${token}` }
29
- });
30
- }
31
-
32
- function waitMsg(ws, type) {
33
- return new Promise((resolve, reject) => {
34
- const timer = setTimeout(() => { ws.removeListener('message', h); reject(new Error(`Timeout: ${type}`)); }, 5000);
35
- function h(data) {
36
- const msg = JSON.parse(data.toString());
37
- if (msg.type === type) { clearTimeout(timer); ws.removeListener('message', h); resolve(msg); }
38
- }
39
- ws.on('message', h);
40
- });
41
- }
42
-
43
- // 1. Alice connects
44
- const alice = connectWs(aliceRes.token);
45
- const alicePeers = await waitMsg(alice, 'peers');
46
- console.log(`\n1. Alice connected — you: ${alicePeers.you}, peers: [${alicePeers.peers}]`);
47
-
48
- // 2. Bob connects — alice should get peer-joined
49
- const joinPromise = waitMsg(alice, 'peer-joined');
50
- const bob = connectWs(bobRes.token);
51
- const bobPeers = await waitMsg(bob, 'peers');
52
- console.log(`2. Bob connected — you: ${bobPeers.you}, peers: [${bobPeers.peers}]`);
53
-
54
- const joined = await joinPromise;
55
- console.log(` Alice got peer-joined: ${joined.webId}`);
56
-
57
- // 3. Alice sends offer to Bob
58
- const offerPromise = waitMsg(bob, 'offer');
59
- alice.send(JSON.stringify({ type: 'offer', to: bobPeers.you, sdp: 'v=0\r\nfake-sdp-offer' }));
60
- const offer = await offerPromise;
61
- console.log(`3. Bob received offer from ${offer.from} — sdp: "${offer.sdp}"`);
62
-
63
- // 4. Bob sends answer to Alice
64
- const answerPromise = waitMsg(alice, 'answer');
65
- bob.send(JSON.stringify({ type: 'answer', to: alicePeers.you, sdp: 'v=0\r\nfake-sdp-answer' }));
66
- const answer = await answerPromise;
67
- console.log(`4. Alice received answer from ${answer.from} — sdp: "${answer.sdp}"`);
68
-
69
- // 5. Alice sends ICE candidate to Bob
70
- const candPromise = waitMsg(bob, 'candidate');
71
- alice.send(JSON.stringify({ type: 'candidate', to: bobPeers.you, candidate: { candidate: 'candidate:1 1 UDP 12345 192.168.1.1 9999 typ host' } }));
72
- const cand = await candPromise;
73
- console.log(`5. Bob received ICE candidate from ${cand.from}`);
74
-
75
- // 6. Alice sends hangup
76
- const hangPromise = waitMsg(bob, 'hangup');
77
- alice.send(JSON.stringify({ type: 'hangup', to: bobPeers.you }));
78
- const hang = await hangPromise;
79
- console.log(`6. Bob received hangup from ${hang.from}`);
80
-
81
- // 7. Bob disconnects — alice gets peer-left
82
- const leftPromise = waitMsg(alice, 'peer-left');
83
- bob.close();
84
- const left = await leftPromise;
85
- console.log(`7. Alice got peer-left: ${left.webId}`);
86
-
87
- alice.close();
88
- await server.close();
89
- console.log(`\n✅ All signaling steps passed!`);
90
- process.exit(0);