javascript-solid-server 0.0.58 → 0.0.59

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.
@@ -209,7 +209,11 @@
209
209
  "WebFetch(domain:css-tricks.com)",
210
210
  "Bash(node bin/jss.js:*)",
211
211
  "WebFetch(domain:nostr.social)",
212
- "Bash(xargs curl -s)"
212
+ "Bash(xargs curl -s)",
213
+ "Bash(ssh phone:*)",
214
+ "Bash(dig:*)",
215
+ "WebFetch(domain:fonstr.com)",
216
+ "Bash(node -e \"import\\(''nostr-tools''\\).then\\(m => console.log\\(Object.keys\\(m\\).join\\(''\\\\n''\\)\\)\\)\":*)"
213
217
  ]
214
218
  }
215
219
  }
package/README.md CHANGED
@@ -6,7 +6,7 @@ A minimal, fast, JSON-LD native Solid server.
6
6
 
7
7
  ## Features
8
8
 
9
- ### Implemented (v0.0.57)
9
+ ### Implemented (v0.0.59)
10
10
 
11
11
  - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
12
12
  - **N3 Patch** - Solid's native patch format for RDF updates
@@ -29,6 +29,7 @@ A minimal, fast, JSON-LD native Solid server.
29
29
  - **Content Negotiation** - Turtle <-> JSON-LD conversion, including HTML data islands
30
30
  - **CORS Support** - Full cross-origin resource sharing
31
31
  - **Git HTTP Backend** - Clone and push to containers via `git` protocol
32
+ - **Nostr Relay** - Integrated NIP-01 relay on the same port (`wss://your.pod/relay`)
32
33
  - **Invite-Only Registration** - CLI-managed invite codes for controlled signups
33
34
  - **Storage Quotas** - Per-user storage limits with CLI management
34
35
  - **Security** - Blocks access to dotfiles (`.git/`, `.env`, etc.) except Solid-specific ones
@@ -103,6 +104,9 @@ jss --help # Show help
103
104
  | `--mashlib-cdn` | Enable Mashlib (CDN mode) | false |
104
105
  | `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
105
106
  | `--git` | Enable Git HTTP backend | false |
107
+ | `--nostr` | Enable Nostr relay | false |
108
+ | `--nostr-path <path>` | Nostr relay WebSocket path | /relay |
109
+ | `--nostr-max-events <n>` | Max events in relay memory | 1000 |
106
110
  | `--invite-only` | Require invite code for registration | false |
107
111
  | `--default-quota <size>` | Default storage quota per pod (e.g., 50MB) | 50MB |
108
112
  | `-q, --quiet` | Suppress logs | false |
@@ -119,6 +123,7 @@ export JSS_CONNEG=true
119
123
  export JSS_SUBDOMAINS=true
120
124
  export JSS_BASE_DOMAIN=example.com
121
125
  export JSS_MASHLIB=true
126
+ export JSS_NOSTR=true
122
127
  export JSS_INVITE_ONLY=true
123
128
  export JSS_DEFAULT_QUOTA=100MB
124
129
  jss start
package/bin/jss.js CHANGED
@@ -59,6 +59,10 @@ program
59
59
  .option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
60
60
  .option('--git', 'Enable Git HTTP backend (clone/push support)')
61
61
  .option('--no-git', 'Disable Git HTTP backend')
62
+ .option('--nostr', 'Enable Nostr relay')
63
+ .option('--no-nostr', 'Disable Nostr relay')
64
+ .option('--nostr-path <path>', 'Nostr relay WebSocket path (default: /relay)')
65
+ .option('--nostr-max-events <n>', 'Max events in relay memory (default: 1000)', parseInt)
62
66
  .option('--invite-only', 'Require invite code for registration')
63
67
  .option('--no-invite-only', 'Allow open registration')
64
68
  .option('-q, --quiet', 'Suppress log output')
@@ -103,6 +107,9 @@ program
103
107
  mashlibCdn: config.mashlibCdn,
104
108
  mashlibVersion: config.mashlibVersion,
105
109
  git: config.git,
110
+ nostr: config.nostr,
111
+ nostrPath: config.nostrPath,
112
+ nostrMaxEvents: config.nostrMaxEvents,
106
113
  inviteOnly: config.inviteOnly,
107
114
  });
108
115
 
@@ -123,6 +130,7 @@ program
123
130
  console.log(` Mashlib: local (data browser enabled)`);
124
131
  }
125
132
  if (config.git) console.log(' Git: enabled (clone/push support)');
133
+ if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
126
134
  if (config.inviteOnly) console.log(' Registration: invite-only');
127
135
  console.log('\n Press Ctrl+C to stop\n');
128
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.58",
3
+ "version": "0.0.59",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/auth/nostr.js CHANGED
@@ -187,11 +187,9 @@ export async function verifyNostrAuth(request) {
187
187
 
188
188
  // Validate method tag matches request method
189
189
  // For git clients: allow '*' as wildcard method
190
+ // If method tag is missing, infer from HTTP request (lenient mode)
190
191
  const eventMethod = getTagValue(event, 'method');
191
- if (!eventMethod) {
192
- return { webId: null, error: 'Missing method tag in event' };
193
- }
194
- if (eventMethod !== '*' && eventMethod.toUpperCase() !== request.method.toUpperCase()) {
192
+ if (eventMethod && eventMethod !== '*' && eventMethod.toUpperCase() !== request.method.toUpperCase()) {
195
193
  return { webId: null, error: `Method mismatch: expected ${request.method}, got ${eventMethod}` };
196
194
  }
197
195
 
@@ -218,6 +216,19 @@ export async function verifyNostrAuth(request) {
218
216
  return { webId: null, error: 'Invalid or missing pubkey' };
219
217
  }
220
218
 
219
+ // Compute event id if missing (lenient mode for nosdav compatibility)
220
+ if (!event.id) {
221
+ const serialized = JSON.stringify([
222
+ 0,
223
+ event.pubkey,
224
+ event.created_at,
225
+ event.kind,
226
+ event.tags,
227
+ event.content
228
+ ]);
229
+ event.id = crypto.createHash('sha256').update(serialized).digest('hex');
230
+ }
231
+
221
232
  // Verify Schnorr signature
222
233
  const isValid = verifyEvent(event);
223
234
  if (!isValid) {
package/src/config.js CHANGED
@@ -45,6 +45,11 @@ export const defaults = {
45
45
  // Git HTTP backend
46
46
  git: false,
47
47
 
48
+ // Nostr relay
49
+ nostr: false,
50
+ nostrPath: '/relay',
51
+ nostrMaxEvents: 1000,
52
+
48
53
  // Invite-only registration
49
54
  inviteOnly: false,
50
55
 
@@ -81,6 +86,9 @@ const envMap = {
81
86
  JSS_MASHLIB_CDN: 'mashlibCdn',
82
87
  JSS_MASHLIB_VERSION: 'mashlibVersion',
83
88
  JSS_GIT: 'git',
89
+ JSS_NOSTR: 'nostr',
90
+ JSS_NOSTR_PATH: 'nostrPath',
91
+ JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
84
92
  JSS_INVITE_ONLY: 'inviteOnly',
85
93
  JSS_DEFAULT_QUOTA: 'defaultQuota',
86
94
  };
@@ -109,7 +117,7 @@ function parseEnvValue(value, key) {
109
117
  if (value.toLowerCase() === 'false') return false;
110
118
 
111
119
  // Numeric values for known numeric keys
112
- if (key === 'port' && !isNaN(value)) {
120
+ if ((key === 'port' || key === 'nostrMaxEvents') && !isNaN(value)) {
113
121
  return parseInt(value, 10);
114
122
  }
115
123
 
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Nostr Relay Module
3
+ *
4
+ * Lightweight Nostr relay (NIP-01) integrated into JSS.
5
+ * Based on Fonstr (https://github.com/nostrapps/fonstr)
6
+ *
7
+ * Usage: jss start --nostr
8
+ * Endpoint: wss://your.pod/relay
9
+ */
10
+
11
+ import { validateEvent, verifyEvent } from 'nostr-tools';
12
+ import websocket from '@fastify/websocket';
13
+
14
+ // Default max events to prevent memory exhaustion
15
+ const DEFAULT_MAX_EVENTS = 1000;
16
+ // Rate limiting: max events per socket per minute
17
+ const DEFAULT_RATE_LIMIT = 60;
18
+ const RATE_WINDOW_MS = 60000;
19
+
20
+ /**
21
+ * Check if event passes filter (NIP-01)
22
+ */
23
+ function eventPassesFilter(event, filter) {
24
+ if (filter.ids && !filter.ids.includes(event.id)) {
25
+ return false;
26
+ }
27
+
28
+ if (filter.authors && !filter.authors.includes(event.pubkey)) {
29
+ return false;
30
+ }
31
+
32
+ if (filter.kinds && !filter.kinds.includes(event.kind)) {
33
+ return false;
34
+ }
35
+
36
+ if (filter.since && event.created_at < filter.since) {
37
+ return false;
38
+ }
39
+
40
+ if (filter.until && event.created_at > filter.until) {
41
+ return false;
42
+ }
43
+
44
+ // Tag filters (#e, #p, etc.)
45
+ for (const [key, values] of Object.entries(filter)) {
46
+ if (key.startsWith('#') && key.length === 2) {
47
+ const tagName = key[1];
48
+ const eventTagValues = event.tags
49
+ .filter(tag => tag[0] === tagName)
50
+ .map(tag => tag[1]);
51
+
52
+ if (!values.some(v => eventTagValues.includes(v))) {
53
+ return false;
54
+ }
55
+ }
56
+ }
57
+
58
+ return true;
59
+ }
60
+
61
+ /**
62
+ * Event kind helpers (NIP-01, NIP-16)
63
+ */
64
+ function isReplaceableKind(kind) {
65
+ return (kind >= 10000 && kind < 20000) || kind === 0 || kind === 3;
66
+ }
67
+
68
+ function isEphemeralKind(kind) {
69
+ return kind >= 20000 && kind < 30000;
70
+ }
71
+
72
+ function isParameterizedReplaceable(kind) {
73
+ return kind >= 30000 && kind < 40000;
74
+ }
75
+
76
+ function getDTagValue(tags) {
77
+ for (const tag of tags) {
78
+ if (tag[0] === 'd') {
79
+ return tag[1];
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Register Nostr relay routes on Fastify instance
87
+ *
88
+ * @param {object} fastify - Fastify instance
89
+ * @param {object} options - Options
90
+ * @param {string} options.path - WebSocket path (default: '/relay')
91
+ * @param {number} options.maxEvents - Max events in memory (default: 1000)
92
+ */
93
+ export async function registerNostrRelay(fastify, options = {}) {
94
+ const path = options.path || '/relay';
95
+ const maxEvents = options.maxEvents || DEFAULT_MAX_EVENTS;
96
+
97
+ // In-memory storage
98
+ const events = [];
99
+ const subscribers = new Map();
100
+ const rateLimits = new Map(); // socket -> { count, resetTime }
101
+
102
+ /**
103
+ * Check rate limit for socket
104
+ */
105
+ function checkRateLimit(socket) {
106
+ const now = Date.now();
107
+ let limit = rateLimits.get(socket);
108
+
109
+ if (!limit || now > limit.resetTime) {
110
+ limit = { count: 0, resetTime: now + RATE_WINDOW_MS };
111
+ rateLimits.set(socket, limit);
112
+ }
113
+
114
+ limit.count++;
115
+ return limit.count <= DEFAULT_RATE_LIMIT;
116
+ }
117
+
118
+ /**
119
+ * Process incoming message
120
+ */
121
+ async function processMessage(type, value, rest, socket) {
122
+ switch (type) {
123
+ case 'EVENT': {
124
+ // Rate limit check
125
+ if (!checkRateLimit(socket)) {
126
+ socket.send(JSON.stringify(['OK', value?.id || '', false, 'rate-limited: too many events']));
127
+ return;
128
+ }
129
+
130
+ const event = value;
131
+ const isValid = validateEvent(event) && verifyEvent(event);
132
+
133
+ if (!isValid) {
134
+ socket.send(JSON.stringify(['OK', event?.id || '', false, 'invalid: bad signature or format']));
135
+ return;
136
+ }
137
+
138
+ // Handle different event kinds
139
+ if (isEphemeralKind(event.kind)) {
140
+ // Ephemeral: don't store, just broadcast
141
+ } else if (isReplaceableKind(event.kind) || isParameterizedReplaceable(event.kind)) {
142
+ // Replaceable: find and update existing
143
+ let indexToReplace = -1;
144
+ for (let i = 0; i < events.length; i++) {
145
+ if (events[i].pubkey === event.pubkey && events[i].kind === event.kind) {
146
+ if (isParameterizedReplaceable(event.kind)) {
147
+ const dTagValue = getDTagValue(event.tags);
148
+ const existingDTagValue = getDTagValue(events[i].tags);
149
+ if (dTagValue === existingDTagValue) {
150
+ indexToReplace = i;
151
+ break;
152
+ }
153
+ } else {
154
+ indexToReplace = i;
155
+ break;
156
+ }
157
+ }
158
+ }
159
+
160
+ if (indexToReplace !== -1) {
161
+ events[indexToReplace] = event;
162
+ } else {
163
+ if (events.length >= maxEvents) {
164
+ events.shift();
165
+ }
166
+ events.push(event);
167
+ }
168
+ } else {
169
+ // Regular event
170
+ if (events.length >= maxEvents) {
171
+ events.shift();
172
+ }
173
+ events.push(event);
174
+ }
175
+
176
+ // Broadcast to matching subscribers
177
+ subscribers.forEach((filters, subscriber) => {
178
+ filters.forEach(filter => {
179
+ if (eventPassesFilter(event, filter)) {
180
+ try {
181
+ subscriber.send(JSON.stringify(['EVENT', filter.subscription_id, event]));
182
+ } catch (e) {
183
+ // Socket closed, will be cleaned up
184
+ }
185
+ }
186
+ });
187
+ });
188
+
189
+ socket.send(JSON.stringify(['OK', event.id, true, '']));
190
+ break;
191
+ }
192
+
193
+ case 'REQ': {
194
+ const subscriptionId = value;
195
+ const filters = rest.map(filter => ({ ...filter, subscription_id: subscriptionId }));
196
+ subscribers.set(socket, filters);
197
+
198
+ // Send matching historical events
199
+ filters.forEach(filter => {
200
+ const matchingEvents = events.filter(event => eventPassesFilter(event, filter));
201
+ const limited = filter.limit ? matchingEvents.slice(-filter.limit) : matchingEvents;
202
+ limited.forEach(event => {
203
+ socket.send(JSON.stringify(['EVENT', filter.subscription_id, event]));
204
+ });
205
+ });
206
+
207
+ socket.send(JSON.stringify(['EOSE', subscriptionId]));
208
+ break;
209
+ }
210
+
211
+ case 'CLOSE': {
212
+ const subId = value;
213
+ if (subscribers.has(socket)) {
214
+ const updatedFilters = subscribers.get(socket).filter(
215
+ filter => filter.subscription_id !== subId
216
+ );
217
+ if (updatedFilters.length === 0) {
218
+ subscribers.delete(socket);
219
+ } else {
220
+ subscribers.set(socket, updatedFilters);
221
+ }
222
+ }
223
+ break;
224
+ }
225
+
226
+ default:
227
+ socket.send(JSON.stringify(['NOTICE', `Unknown message type: ${type}`]));
228
+ }
229
+ }
230
+
231
+ // Register websocket plugin if not already registered
232
+ if (!fastify.websocketServer) {
233
+ await fastify.register(websocket);
234
+ }
235
+
236
+ // Register WebSocket route for Nostr relay
237
+ fastify.get(path, { websocket: true }, (connection, request) => {
238
+ const socket = connection.socket;
239
+
240
+ socket.on('message', async (data) => {
241
+ try {
242
+ const message = JSON.parse(data.toString());
243
+ const [type, value, ...rest] = message;
244
+ await processMessage(type, value, rest, socket);
245
+ } catch (e) {
246
+ socket.send(JSON.stringify(['NOTICE', `Error: ${e.message}`]));
247
+ }
248
+ });
249
+
250
+ socket.on('close', () => {
251
+ subscribers.delete(socket);
252
+ rateLimits.delete(socket);
253
+ });
254
+
255
+ socket.on('error', () => {
256
+ subscribers.delete(socket);
257
+ rateLimits.delete(socket);
258
+ });
259
+ });
260
+
261
+ // NIP-11: Relay Information Document at /relay/info
262
+ fastify.get(path + '/info', (request, reply) => {
263
+ const relayInfo = {
264
+ name: 'JSS Nostr Relay',
265
+ description: 'Nostr relay integrated with JavaScript Solid Server',
266
+ pubkey: '',
267
+ contact: '',
268
+ supported_nips: [1, 11, 16],
269
+ software: 'https://github.com/JavaScriptSolidServer/JavaScriptSolidServer',
270
+ version: '0.0.1'
271
+ };
272
+
273
+ return reply
274
+ .header('Access-Control-Allow-Origin', '*')
275
+ .header('Content-Type', 'application/json')
276
+ .send(relayInfo);
277
+ });
278
+
279
+ return {
280
+ getEventCount: () => events.length,
281
+ getSubscriberCount: () => subscribers.size
282
+ };
283
+ }
package/src/server.js CHANGED
@@ -11,6 +11,7 @@ import { notificationsPlugin } from './notifications/index.js';
11
11
  import { idpPlugin } from './idp/index.js';
12
12
  import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
13
13
  import { AccessMode } from './wac/parser.js';
14
+ import { registerNostrRelay } from './nostr/relay.js';
14
15
 
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
17
 
@@ -27,6 +28,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
27
28
  * @param {boolean} options.subdomains - Enable subdomain-based pods for XSS protection (default false)
28
29
  * @param {string} options.baseDomain - Base domain for subdomain pods (e.g., "example.com")
29
30
  * @param {boolean} options.git - Enable Git HTTP backend for clone/push (default false)
31
+ * @param {boolean} options.nostr - Enable Nostr relay (default false)
32
+ * @param {string} options.nostrPath - Nostr relay WebSocket path (default '/relay')
33
+ * @param {number} options.nostrMaxEvents - Max events in relay memory (default 1000)
30
34
  */
31
35
  export function createServer(options = {}) {
32
36
  // Content negotiation is OFF by default - we're a JSON-LD native server
@@ -46,6 +50,10 @@ export function createServer(options = {}) {
46
50
  const mashlibVersion = options.mashlibVersion ?? '2.0.0';
47
51
  // Git HTTP backend is OFF by default - enables clone/push via git protocol
48
52
  const gitEnabled = options.git ?? false;
53
+ // Nostr relay is OFF by default
54
+ const nostrEnabled = options.nostr ?? false;
55
+ const nostrPath = options.nostrPath ?? '/relay';
56
+ const nostrMaxEvents = options.nostrMaxEvents ?? 1000;
49
57
  // Invite-only registration is OFF by default - open registration
50
58
  const inviteOnly = options.inviteOnly ?? false;
51
59
  // Default storage quota per pod (50MB default, 0 = unlimited)
@@ -134,6 +142,16 @@ export function createServer(options = {}) {
134
142
  fastify.register(idpPlugin, { issuer: idpIssuer, inviteOnly });
135
143
  }
136
144
 
145
+ // Register Nostr relay if enabled
146
+ if (nostrEnabled) {
147
+ fastify.register(async (instance) => {
148
+ await registerNostrRelay(instance, {
149
+ path: nostrPath,
150
+ maxEvents: nostrMaxEvents
151
+ });
152
+ });
153
+ }
154
+
137
155
  // Register rate limiting plugin
138
156
  // Protects against brute force attacks and resource exhaustion
139
157
  fastify.register(rateLimit, {
@@ -219,13 +237,14 @@ export function createServer(options = {}) {
219
237
  // Authorization hook - check WAC permissions
220
238
  // Skip for pod creation endpoint (needs special handling)
221
239
  fastify.addHook('preHandler', async (request, reply) => {
222
- // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, and git
240
+ // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, and git
223
241
  const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
224
242
  if (request.url === '/.pods' ||
225
243
  request.url === '/.notifications' ||
226
244
  request.method === 'OPTIONS' ||
227
245
  request.url.startsWith('/idp/') ||
228
246
  request.url.startsWith('/.well-known/') ||
247
+ (nostrEnabled && request.url.startsWith(nostrPath)) ||
229
248
  (gitEnabled && isGitRequest(request.url)) ||
230
249
  mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
231
250
  return;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Tests for did:nostr to WebID resolution
3
+ */
4
+
5
+ import { describe, it, before, after, mock } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
8
+ import {
9
+ startTestServer,
10
+ stopTestServer,
11
+ request,
12
+ createTestPod,
13
+ getBaseUrl,
14
+ assertStatus
15
+ } from './helpers.js';
16
+
17
+ // Import the module under test
18
+ import { resolveDidNostrToWebId, clearCache } from '../src/auth/did-nostr.js';
19
+
20
+ describe('DID:nostr Resolution', () => {
21
+ describe('Unit Tests', () => {
22
+ before(() => {
23
+ clearCache();
24
+ });
25
+
26
+ it('should return null for invalid pubkey', async () => {
27
+ const result = await resolveDidNostrToWebId('invalid');
28
+ assert.strictEqual(result, null);
29
+ });
30
+
31
+ it('should return null for empty pubkey', async () => {
32
+ const result = await resolveDidNostrToWebId('');
33
+ assert.strictEqual(result, null);
34
+ });
35
+
36
+ it('should return null for null pubkey', async () => {
37
+ const result = await resolveDidNostrToWebId(null);
38
+ assert.strictEqual(result, null);
39
+ });
40
+
41
+ it('should return null for pubkey with wrong length', async () => {
42
+ const result = await resolveDidNostrToWebId('abcd1234');
43
+ assert.strictEqual(result, null);
44
+ });
45
+
46
+ it('should handle non-existent DID gracefully', async () => {
47
+ // Use a random pubkey that won't exist
48
+ const sk = generateSecretKey();
49
+ const pubkey = getPublicKey(sk);
50
+
51
+ // This will hit nostr.social and get 404
52
+ const result = await resolveDidNostrToWebId(pubkey);
53
+ assert.strictEqual(result, null);
54
+ });
55
+ });
56
+
57
+ describe('checkSameAsLink Function', () => {
58
+ // We need to test the internal checkSameAsLink function
59
+ // Since it's not exported, we test it indirectly through WebID verification
60
+
61
+ it('should recognize owl:sameAs string value', async () => {
62
+ // This test verifies the format we expect in WebID profiles
63
+ const profile = {
64
+ '@id': '#me',
65
+ 'owl:sameAs': 'did:nostr:abcd1234'
66
+ };
67
+
68
+ // The profile should have the correct structure
69
+ assert.strictEqual(profile['owl:sameAs'], 'did:nostr:abcd1234');
70
+ });
71
+
72
+ it('should recognize sameAs as @id object', async () => {
73
+ const profile = {
74
+ '@id': '#me',
75
+ 'owl:sameAs': { '@id': 'did:nostr:abcd1234' }
76
+ };
77
+
78
+ assert.strictEqual(profile['owl:sameAs']['@id'], 'did:nostr:abcd1234');
79
+ });
80
+ });
81
+
82
+ describe('Nostr Auth with DID Resolution', () => {
83
+ before(async () => {
84
+ await startTestServer();
85
+ });
86
+
87
+ after(async () => {
88
+ await stopTestServer();
89
+ clearCache();
90
+ });
91
+
92
+ it('should create a pod for DID testing', async () => {
93
+ const result = await createTestPod('nostrtest');
94
+ assert.ok(result.webId, 'Should have webId');
95
+ assert.ok(result.token, 'Should have token');
96
+ });
97
+
98
+ it('should accept valid NIP-98 auth header', async () => {
99
+ // Generate a Nostr keypair
100
+ const sk = generateSecretKey();
101
+ const pubkey = getPublicKey(sk);
102
+
103
+ // Create the pod for this pubkey
104
+ const podName = pubkey.substring(0, 16);
105
+ await createTestPod(podName);
106
+
107
+ // Create a NIP-98 event
108
+ const baseUrl = getBaseUrl();
109
+ const event = finalizeEvent({
110
+ kind: 27235,
111
+ created_at: Math.floor(Date.now() / 1000),
112
+ tags: [
113
+ ['u', `${baseUrl}/${podName}/public/`],
114
+ ['method', 'GET']
115
+ ],
116
+ content: ''
117
+ }, sk);
118
+
119
+ // Encode as base64
120
+ const token = Buffer.from(JSON.stringify(event)).toString('base64');
121
+
122
+ // Make request with Nostr auth
123
+ const res = await fetch(`${baseUrl}/${podName}/public/`, {
124
+ headers: {
125
+ 'Authorization': `Nostr ${token}`
126
+ }
127
+ });
128
+
129
+ // Should succeed (200) - the Nostr auth should work
130
+ // Even without DID resolution, did:nostr:<pubkey> is accepted
131
+ assertStatus(res, 200);
132
+ });
133
+
134
+ it('should return did:nostr when no WebID linked', async () => {
135
+ const sk = generateSecretKey();
136
+ const pubkey = getPublicKey(sk);
137
+
138
+ // Try to resolve - should return null since no alsoKnownAs
139
+ const result = await resolveDidNostrToWebId(pubkey);
140
+ assert.strictEqual(result, null, 'Should return null when no WebID linked');
141
+ });
142
+ });
143
+
144
+ describe('Real DID Document Fetch', () => {
145
+ before(() => {
146
+ clearCache();
147
+ });
148
+
149
+ it('should fetch DID document from nostr.social', async () => {
150
+ // Use a known pubkey that exists on nostr.social
151
+ // fiatjaf's pubkey
152
+ const pubkey = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
153
+
154
+ // This should not throw, just return null if no WebID linked
155
+ const result = await resolveDidNostrToWebId(pubkey);
156
+
157
+ // fiatjaf likely doesn't have a WebID linked, so expect null
158
+ // But the fetch itself should work without error
159
+ assert.strictEqual(result, null, 'Should return null when no bidirectional link');
160
+ });
161
+
162
+ it('should cache DID resolution results', async () => {
163
+ const pubkey = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
164
+
165
+ // First call
166
+ const start1 = Date.now();
167
+ await resolveDidNostrToWebId(pubkey);
168
+ const time1 = Date.now() - start1;
169
+
170
+ // Second call should be cached (much faster)
171
+ const start2 = Date.now();
172
+ await resolveDidNostrToWebId(pubkey);
173
+ const time2 = Date.now() - start2;
174
+
175
+ // Cached call should be < 5ms typically
176
+ assert.ok(time2 < time1 || time2 < 10, `Cached call should be fast. First: ${time1}ms, Second: ${time2}ms`);
177
+ });
178
+ });
179
+ });