javascript-solid-server 0.0.100 → 0.0.101

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.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Web Ledger — spec-compliant balance tracking
3
+ *
4
+ * Implements the Web Ledgers specification (https://webledgers.org/)
5
+ * for mapping URIs to numerical balances. Default unit: satoshi.
6
+ *
7
+ * JSON-LD context: https://w3id.org/webledgers
8
+ *
9
+ * @module webledger
10
+ */
11
+
12
+ import * as storage from './storage/filesystem.js';
13
+
14
+ const DEFAULT_PATH = '/.well-known/webledgers/webledgers.json';
15
+ const CONTEXT = 'https://w3id.org/webledgers';
16
+
17
+ /**
18
+ * Create an empty spec-compliant ledger
19
+ * @param {object} options
20
+ * @param {string} options.name - Ledger name
21
+ * @param {string} options.description - Ledger description
22
+ * @param {string} options.id - Ledger URI identifier
23
+ * @param {string} options.defaultCurrency - Default currency (default 'satoshi')
24
+ * @returns {object} Empty WebLedger object
25
+ */
26
+ export function createLedger(options = {}) {
27
+ const now = Math.floor(Date.now() / 1000);
28
+ return {
29
+ '@context': CONTEXT,
30
+ type: 'WebLedger',
31
+ ...(options.id && { id: options.id }),
32
+ name: options.name ?? 'Pod Credits',
33
+ description: options.description ?? 'Paid API balance ledger',
34
+ defaultCurrency: options.defaultCurrency ?? 'satoshi',
35
+ created: now,
36
+ updated: now,
37
+ entries: []
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Read a webledger from storage
43
+ * @param {string} ledgerPath - URL path to ledger file
44
+ * @returns {Promise<object>} WebLedger object
45
+ */
46
+ export async function readLedger(ledgerPath = DEFAULT_PATH) {
47
+ const buf = await storage.read(ledgerPath);
48
+ if (!buf) {
49
+ return createLedger();
50
+ }
51
+ try {
52
+ const ledger = JSON.parse(buf.toString('utf8'));
53
+ // Migrate legacy format: add missing spec fields
54
+ if (!ledger['@context']) ledger['@context'] = CONTEXT;
55
+ if (!ledger.type) ledger.type = 'WebLedger';
56
+ if (!ledger.defaultCurrency) ledger.defaultCurrency = 'satoshi';
57
+ if (!ledger.created) ledger.created = Math.floor(Date.now() / 1000);
58
+ if (!ledger.entries) ledger.entries = [];
59
+ return ledger;
60
+ } catch {
61
+ return createLedger();
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Write a webledger to storage (updates the `updated` timestamp)
67
+ * @param {object} ledger - WebLedger object
68
+ * @param {string} ledgerPath - URL path to ledger file
69
+ * @returns {Promise<boolean>}
70
+ */
71
+ export async function writeLedger(ledger, ledgerPath = DEFAULT_PATH) {
72
+ ledger.updated = Math.floor(Date.now() / 1000);
73
+ return storage.write(ledgerPath, JSON.stringify(ledger, null, 2));
74
+ }
75
+
76
+ /**
77
+ * Get balance for a URI
78
+ * @param {object} ledger - WebLedger object
79
+ * @param {string} uri - Agent URI (e.g. did:nostr:...)
80
+ * @returns {number} Balance as integer
81
+ */
82
+ export function getBalance(ledger, uri) {
83
+ const entry = ledger.entries.find(e => e.url === uri);
84
+ if (!entry) return 0;
85
+ // Handle both simple string and array amount formats
86
+ if (Array.isArray(entry.amount)) {
87
+ const sat = entry.amount.find(a => a.currency === 'satoshi' || a.currency === 'sat');
88
+ return sat ? parseInt(sat.value, 10) || 0 : 0;
89
+ }
90
+ return parseInt(entry.amount, 10) || 0;
91
+ }
92
+
93
+ /**
94
+ * Set balance for a URI
95
+ * @param {object} ledger - WebLedger object
96
+ * @param {string} uri - Agent URI
97
+ * @param {number} amount - New balance
98
+ */
99
+ export function setBalance(ledger, uri, amount) {
100
+ const entry = ledger.entries.find(e => e.url === uri);
101
+ if (entry) {
102
+ entry.amount = String(amount);
103
+ } else {
104
+ ledger.entries.push({ type: 'Entry', url: uri, amount: String(amount) });
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Credit (add to) a balance
110
+ * @param {object} ledger - WebLedger object
111
+ * @param {string} uri - Agent URI
112
+ * @param {number} amount - Amount to add
113
+ * @returns {number} New balance
114
+ */
115
+ export function credit(ledger, uri, amount) {
116
+ const current = getBalance(ledger, uri);
117
+ const newBalance = current + amount;
118
+ setBalance(ledger, uri, newBalance);
119
+ return newBalance;
120
+ }
121
+
122
+ /**
123
+ * Debit (subtract from) a balance
124
+ * @param {object} ledger - WebLedger object
125
+ * @param {string} uri - Agent URI
126
+ * @param {number} amount - Amount to subtract
127
+ * @returns {{success: boolean, balance: number}} Result
128
+ */
129
+ export function debit(ledger, uri, amount) {
130
+ const current = getBalance(ledger, uri);
131
+ if (current < amount) {
132
+ return { success: false, balance: current };
133
+ }
134
+ const newBalance = current - amount;
135
+ setBalance(ledger, uri, newBalance);
136
+ return { success: true, balance: newBalance };
137
+ }
138
+
139
+ /**
140
+ * List all entries with non-zero balances
141
+ * @param {object} ledger - WebLedger object
142
+ * @returns {Array<{url: string, amount: number}>}
143
+ */
144
+ export function listBalances(ledger) {
145
+ return ledger.entries
146
+ .map(e => ({ url: e.url, amount: getBalance(ledger, e.url) }))
147
+ .filter(e => e.amount > 0);
148
+ }
149
+
150
+ /**
151
+ * Remove entries with zero balance
152
+ * @param {object} ledger - WebLedger object
153
+ */
154
+ export function compact(ledger) {
155
+ ledger.entries = ledger.entries.filter(e => {
156
+ const bal = getBalance(ledger, e.url);
157
+ return bal > 0;
158
+ });
159
+ }
160
+
161
+ // Re-export the default path for consumers
162
+ export { DEFAULT_PATH as LEDGER_PATH };
package/test/helpers.js CHANGED
@@ -24,7 +24,7 @@ export async function startTestServer(options = {}) {
24
24
  // Clean up any existing test data
25
25
  await fs.emptyDir(TEST_DATA_DIR);
26
26
 
27
- server = createServer({ logger: false, ...options });
27
+ server = createServer({ logger: false, forceCloseConnections: true, ...options });
28
28
  // Use port 0 to let OS assign available port
29
29
  await server.listen({ port: 0, host: '127.0.0.1' });
30
30
 
@@ -39,7 +39,6 @@ export async function startTestServer(options = {}) {
39
39
  */
40
40
  export async function stopTestServer() {
41
41
  if (server) {
42
- // Force close all connections to avoid hanging
43
42
  await server.close();
44
43
  server = null;
45
44
  }
package/test/idp.test.js CHANGED
@@ -8,28 +8,42 @@ import { createServer } from '../src/server.js';
8
8
  import fs from 'fs-extra';
9
9
  import path from 'path';
10
10
 
11
- const TEST_PORT = 3099;
12
11
  const TEST_HOST = 'localhost';
13
- const BASE_URL = `http://${TEST_HOST}:${TEST_PORT}`;
14
- const DATA_DIR = './test-data-idp';
12
+ import { createServer as createNetServer } from 'net';
13
+
14
+ /** Get an available port by briefly binding to port 0 */
15
+ async function getAvailablePort() {
16
+ return new Promise((resolve, reject) => {
17
+ const srv = createNetServer();
18
+ srv.on('error', (err) => reject(err));
19
+ srv.listen(0, TEST_HOST, () => {
20
+ const port = srv.address().port;
21
+ srv.close(() => resolve(port));
22
+ });
23
+ });
24
+ }
15
25
 
16
26
  describe('Identity Provider', () => {
17
27
  let server;
28
+ let baseUrl;
29
+ const DATA_DIR = './test-data-idp';
18
30
 
19
31
  before(async () => {
20
- // Clean up any existing test data
21
32
  await fs.remove(DATA_DIR);
22
33
  await fs.ensureDir(DATA_DIR);
23
34
 
24
- // Create server with IdP enabled
35
+ const port = await getAvailablePort();
36
+ baseUrl = `http://${TEST_HOST}:${port}`;
37
+
25
38
  server = createServer({
26
39
  logger: false,
27
40
  root: DATA_DIR,
28
41
  idp: true,
29
- idpIssuer: BASE_URL,
42
+ idpIssuer: baseUrl,
43
+ forceCloseConnections: true,
30
44
  });
31
45
 
32
- await server.listen({ port: TEST_PORT, host: TEST_HOST });
46
+ await server.listen({ port, host: TEST_HOST });
33
47
  });
34
48
 
35
49
  after(async () => {
@@ -39,19 +53,19 @@ describe('Identity Provider', () => {
39
53
 
40
54
  describe('OIDC Discovery', () => {
41
55
  it('should serve /.well-known/openid-configuration', async () => {
42
- const res = await fetch(`${BASE_URL}/.well-known/openid-configuration`);
56
+ const res = await fetch(`${baseUrl}/.well-known/openid-configuration`);
43
57
  assert.strictEqual(res.status, 200);
44
58
 
45
59
  const config = await res.json();
46
60
  // Issuer has trailing slash for CTH compatibility
47
- assert.strictEqual(config.issuer, BASE_URL + '/');
61
+ assert.strictEqual(config.issuer, baseUrl + '/');
48
62
  assert.ok(config.authorization_endpoint);
49
63
  assert.ok(config.token_endpoint);
50
64
  assert.ok(config.jwks_uri);
51
65
  });
52
66
 
53
67
  it('should include required Solid-OIDC endpoints', async () => {
54
- const res = await fetch(`${BASE_URL}/.well-known/openid-configuration`);
68
+ const res = await fetch(`${baseUrl}/.well-known/openid-configuration`);
55
69
  const config = await res.json();
56
70
 
57
71
  assert.ok(config.registration_endpoint, 'should have registration endpoint');
@@ -60,7 +74,7 @@ describe('Identity Provider', () => {
60
74
  });
61
75
 
62
76
  it('should serve /.well-known/jwks.json', async () => {
63
- const res = await fetch(`${BASE_URL}/.well-known/jwks.json`);
77
+ const res = await fetch(`${baseUrl}/.well-known/jwks.json`);
64
78
  assert.strictEqual(res.status, 200);
65
79
 
66
80
  const jwks = await res.json();
@@ -73,7 +87,7 @@ describe('Identity Provider', () => {
73
87
 
74
88
  describe('Pod Creation with IdP', () => {
75
89
  it('should require email when IdP is enabled', async () => {
76
- const res = await fetch(`${BASE_URL}/.pods`, {
90
+ const res = await fetch(`${baseUrl}/.pods`, {
77
91
  method: 'POST',
78
92
  headers: { 'Content-Type': 'application/json' },
79
93
  body: JSON.stringify({ name: 'noemail' }),
@@ -85,7 +99,7 @@ describe('Identity Provider', () => {
85
99
  });
86
100
 
87
101
  it('should require password when IdP is enabled', async () => {
88
- const res = await fetch(`${BASE_URL}/.pods`, {
102
+ const res = await fetch(`${baseUrl}/.pods`, {
89
103
  method: 'POST',
90
104
  headers: { 'Content-Type': 'application/json' },
91
105
  body: JSON.stringify({ name: 'nopass', email: 'test@example.com' }),
@@ -98,7 +112,7 @@ describe('Identity Provider', () => {
98
112
 
99
113
  it('should create pod with account', async () => {
100
114
  const uniqueId = Date.now();
101
- const res = await fetch(`${BASE_URL}/.pods`, {
115
+ const res = await fetch(`${baseUrl}/.pods`, {
102
116
  method: 'POST',
103
117
  headers: { 'Content-Type': 'application/json' },
104
118
  body: JSON.stringify({
@@ -125,7 +139,7 @@ describe('Identity Provider', () => {
125
139
  const duplicateEmail = `duplicate${uniqueId}@example.com`;
126
140
 
127
141
  // First user
128
- await fetch(`${BASE_URL}/.pods`, {
142
+ await fetch(`${baseUrl}/.pods`, {
129
143
  method: 'POST',
130
144
  headers: { 'Content-Type': 'application/json' },
131
145
  body: JSON.stringify({
@@ -136,7 +150,7 @@ describe('Identity Provider', () => {
136
150
  });
137
151
 
138
152
  // Second user with same email
139
- const res = await fetch(`${BASE_URL}/.pods`, {
153
+ const res = await fetch(`${baseUrl}/.pods`, {
140
154
  method: 'POST',
141
155
  headers: { 'Content-Type': 'application/json' },
142
156
  body: JSON.stringify({
@@ -154,15 +168,10 @@ describe('Identity Provider', () => {
154
168
 
155
169
  describe('Login Interaction', () => {
156
170
  it('should respond to authorization endpoint', async () => {
157
- // Start an authorization flow
158
- // Various responses are acceptable - 302/303 (redirect), 400 (bad request), 404 (no route)
159
- // This just verifies the server handles the request
160
- const res = await fetch(`${BASE_URL}/idp/auth?client_id=test&redirect_uri=http://localhost&response_type=code&scope=openid`, {
171
+ const res = await fetch(`${baseUrl}/idp/auth?client_id=test&redirect_uri=http://localhost&response_type=code&scope=openid`, {
161
172
  redirect: 'manual',
162
173
  });
163
174
 
164
- // oidc-provider mounted via middie may return different status codes
165
- // The important thing is it doesn't crash and returns a valid HTTP response
166
175
  assert.ok(res.status >= 200 && res.status < 600, `got valid HTTP status ${res.status}`);
167
176
  });
168
177
  });
@@ -170,20 +179,25 @@ describe('Identity Provider', () => {
170
179
 
171
180
  describe('Identity Provider - Accounts', () => {
172
181
  let server;
182
+ let accountsUrl;
173
183
  const ACCOUNTS_DATA_DIR = './test-data-idp-accounts';
174
184
 
175
185
  before(async () => {
176
186
  await fs.remove(ACCOUNTS_DATA_DIR);
177
187
  await fs.ensureDir(ACCOUNTS_DATA_DIR);
178
188
 
189
+ const port = await getAvailablePort();
190
+ accountsUrl = `http://${TEST_HOST}:${port}`;
191
+
179
192
  server = createServer({
180
193
  logger: false,
181
194
  root: ACCOUNTS_DATA_DIR,
182
195
  idp: true,
183
- idpIssuer: `http://${TEST_HOST}:${TEST_PORT + 1}`,
196
+ idpIssuer: accountsUrl,
197
+ forceCloseConnections: true,
184
198
  });
185
199
 
186
- await server.listen({ port: TEST_PORT + 1, host: TEST_HOST });
200
+ await server.listen({ port, host: TEST_HOST });
187
201
  });
188
202
 
189
203
  after(async () => {
@@ -195,7 +209,7 @@ describe('Identity Provider - Accounts', () => {
195
209
  const uniqueName = `stored${Date.now()}`;
196
210
  const uniqueEmail = `stored${Date.now()}@example.com`;
197
211
 
198
- const res = await fetch(`http://${TEST_HOST}:${TEST_PORT + 1}/.pods`, {
212
+ const res = await fetch(`${accountsUrl}/.pods`, {
199
213
  method: 'POST',
200
214
  headers: { 'Content-Type': 'application/json' },
201
215
  body: JSON.stringify({
@@ -221,7 +235,7 @@ describe('Identity Provider - Accounts', () => {
221
235
  const uniqueName = `hashed${Date.now()}`;
222
236
  const uniqueEmail = `hashed${Date.now()}@example.com`;
223
237
 
224
- const res = await fetch(`http://${TEST_HOST}:${TEST_PORT + 1}/.pods`, {
238
+ const res = await fetch(`${accountsUrl}/.pods`, {
225
239
  method: 'POST',
226
240
  headers: { 'Content-Type': 'application/json' },
227
241
  body: JSON.stringify({
@@ -248,24 +262,26 @@ describe('Identity Provider - Accounts', () => {
248
262
 
249
263
  describe('Identity Provider - Credentials Endpoint', () => {
250
264
  let server;
251
- // Use same data dir as other tests (DATA_ROOT is cached at module load)
265
+ let credsUrl;
252
266
  const CREDS_DATA_DIR = './data';
253
- const CREDS_PORT = 3101;
254
- const CREDS_URL = `http://${TEST_HOST}:${CREDS_PORT}`;
255
267
 
256
268
  before(async () => {
257
269
  await fs.emptyDir(CREDS_DATA_DIR);
258
270
 
271
+ const port = await getAvailablePort();
272
+ credsUrl = `http://${TEST_HOST}:${port}`;
273
+
259
274
  server = createServer({
260
275
  logger: false,
261
276
  idp: true,
262
- idpIssuer: CREDS_URL,
277
+ idpIssuer: credsUrl,
278
+ forceCloseConnections: true,
263
279
  });
264
280
 
265
- await server.listen({ port: CREDS_PORT, host: TEST_HOST });
281
+ await server.listen({ port, host: TEST_HOST });
266
282
 
267
283
  // Create a test user
268
- const res = await fetch(`${CREDS_URL}/.pods`, {
284
+ const res = await fetch(`${credsUrl}/.pods`, {
269
285
  method: 'POST',
270
286
  headers: { 'Content-Type': 'application/json' },
271
287
  body: JSON.stringify({
@@ -286,7 +302,7 @@ describe('Identity Provider - Credentials Endpoint', () => {
286
302
 
287
303
  describe('GET /idp/credentials', () => {
288
304
  it('should return endpoint info', async () => {
289
- const res = await fetch(`${CREDS_URL}/idp/credentials`);
305
+ const res = await fetch(`${credsUrl}/idp/credentials`);
290
306
  assert.strictEqual(res.status, 200);
291
307
 
292
308
  const info = await res.json();
@@ -299,7 +315,7 @@ describe('Identity Provider - Credentials Endpoint', () => {
299
315
 
300
316
  describe('POST /idp/credentials', () => {
301
317
  it('should return 400 for missing credentials', async () => {
302
- const res = await fetch(`${CREDS_URL}/idp/credentials`, {
318
+ const res = await fetch(`${credsUrl}/idp/credentials`, {
303
319
  method: 'POST',
304
320
  headers: { 'Content-Type': 'application/json' },
305
321
  body: JSON.stringify({}),
@@ -311,7 +327,7 @@ describe('Identity Provider - Credentials Endpoint', () => {
311
327
  });
312
328
 
313
329
  it('should return 401 for wrong password', async () => {
314
- const res = await fetch(`${CREDS_URL}/idp/credentials`, {
330
+ const res = await fetch(`${credsUrl}/idp/credentials`, {
315
331
  method: 'POST',
316
332
  headers: { 'Content-Type': 'application/json' },
317
333
  body: JSON.stringify({
@@ -326,7 +342,7 @@ describe('Identity Provider - Credentials Endpoint', () => {
326
342
  });
327
343
 
328
344
  it('should return 401 for unknown email', async () => {
329
- const res = await fetch(`${CREDS_URL}/idp/credentials`, {
345
+ const res = await fetch(`${credsUrl}/idp/credentials`, {
330
346
  method: 'POST',
331
347
  headers: { 'Content-Type': 'application/json' },
332
348
  body: JSON.stringify({
@@ -339,7 +355,7 @@ describe('Identity Provider - Credentials Endpoint', () => {
339
355
  });
340
356
 
341
357
  it('should return access token for valid credentials', async () => {
342
- const res = await fetch(`${CREDS_URL}/idp/credentials`, {
358
+ const res = await fetch(`${credsUrl}/idp/credentials`, {
343
359
  method: 'POST',
344
360
  headers: { 'Content-Type': 'application/json' },
345
361
  body: JSON.stringify({
@@ -358,7 +374,7 @@ describe('Identity Provider - Credentials Endpoint', () => {
358
374
  });
359
375
 
360
376
  it('should return JWT token with webid claim', async () => {
361
- const res = await fetch(`${CREDS_URL}/idp/credentials`, {
377
+ const res = await fetch(`${credsUrl}/idp/credentials`, {
362
378
  method: 'POST',
363
379
  headers: { 'Content-Type': 'application/json' },
364
380
  body: JSON.stringify({
@@ -382,7 +398,7 @@ describe('Identity Provider - Credentials Endpoint', () => {
382
398
  });
383
399
 
384
400
  it('should work with form-encoded body', async () => {
385
- const res = await fetch(`${CREDS_URL}/idp/credentials`, {
401
+ const res = await fetch(`${credsUrl}/idp/credentials`, {
386
402
  method: 'POST',
387
403
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
388
404
  body: 'email=credtest%40example.com&password=testpassword123',
@@ -395,7 +411,7 @@ describe('Identity Provider - Credentials Endpoint', () => {
395
411
 
396
412
  it('should allow using token to access protected resource', async () => {
397
413
  // Get access token
398
- const tokenRes = await fetch(`${CREDS_URL}/idp/credentials`, {
414
+ const tokenRes = await fetch(`${credsUrl}/idp/credentials`, {
399
415
  method: 'POST',
400
416
  headers: { 'Content-Type': 'application/json' },
401
417
  body: JSON.stringify({
@@ -407,7 +423,7 @@ describe('Identity Provider - Credentials Endpoint', () => {
407
423
  const { access_token } = await tokenRes.json();
408
424
 
409
425
  // Try to access private resource
410
- const res = await fetch(`${CREDS_URL}/credtest/private/`, {
426
+ const res = await fetch(`${credsUrl}/credtest/private/`, {
411
427
  headers: { 'Authorization': `Bearer ${access_token}` },
412
428
  });
413
429
 
@@ -255,6 +255,7 @@ async function runTests() {
255
255
  console.log('Test 2 result: PASSED');
256
256
 
257
257
  console.log('\n=== All tests passed ===');
258
+ process.exit(0);
258
259
  } catch (err) {
259
260
  console.error('\n=== Test FAILED ===');
260
261
  console.error(err.message);