javascript-solid-server 0.0.100 → 0.0.102

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/LICENSE CHANGED
@@ -1,6 +1,7 @@
1
1
  GNU AFFERO GENERAL PUBLIC LICENSE
2
2
  Version 3, 19 November 2007
3
3
 
4
+ Copyright (C) 2025-2026 Melvin Carvalho
4
5
  Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
6
  Everyone is permitted to copy and distribute verbatim copies
6
7
  of this license document, but changing it is not allowed.
package/README.md CHANGED
@@ -46,6 +46,7 @@ A minimal, fast, JSON-LD native Solid server.
46
46
  - **Nostr Relay** - Integrated NIP-01/NIP-11/NIP-16 relay on the same port (`wss://your.pod/relay`)
47
47
  - **Invite-Only Registration** - CLI-managed invite codes for controlled signups
48
48
  - **Storage Quotas** - Per-user storage limits with CLI management
49
+ - **HTTP 402 Paid Access** - Monetize API endpoints with per-request sat payments (`--pay`)
49
50
  - **Security** - Blocks access to dotfiles (`.git/`, `.env`, etc.) except Solid-specific ones
50
51
 
51
52
  ### HTTP Methods
@@ -151,6 +152,10 @@ jss --help # Show help
151
152
  | `--public` | Allow unauthenticated access (skip WAC) | false |
152
153
  | `--read-only` | Disable PUT/DELETE/PATCH methods | false |
153
154
  | `--live-reload` | Auto-refresh browser on file changes | false |
155
+ | `--pay` | Enable HTTP 402 paid access for /pay/* | false |
156
+ | `--pay-cost <n>` | Cost per request in satoshis | 1 |
157
+ | `--pay-mempool-url <url>` | Mempool API URL for deposit verification | (testnet4) |
158
+ | `--pay-address <addr>` | Address for receiving deposits | - |
154
159
  | `--mongo` | Enable MongoDB-backed /db/ route | false |
155
160
  | `--mongo-url <url>` | MongoDB connection URL | mongodb://localhost:27017 |
156
161
  | `--mongo-database <name>` | MongoDB database name | solid |
@@ -179,6 +184,9 @@ export JSS_PUBLIC=true
179
184
  export JSS_READ_ONLY=true
180
185
  export JSS_LIVE_RELOAD=true
181
186
  export JSS_SOLIDOS_UI=true
187
+ export JSS_PAY=true
188
+ export JSS_PAY_COST=10
189
+ export JSS_PAY_ADDRESS=your-address
182
190
  export JSS_MONGO=true
183
191
  export JSS_MONGO_URL=mongodb://localhost:27017
184
192
  export JSS_MONGO_DATABASE=solid
@@ -810,6 +818,47 @@ curl -X DELETE http://localhost:3000/db/alice/notes/1 \
810
818
 
811
819
  Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
812
820
 
821
+ ## HTTP 402 Paid Access
822
+
823
+ Monetize API endpoints with per-request satoshi payments. Resources under `/pay/*` require NIP-98 authentication and a positive balance.
824
+
825
+ ```bash
826
+ jss start --pay --pay-cost 10 --pay-address your-address
827
+ ```
828
+
829
+ ### Routes
830
+
831
+ | Method | Path | Description |
832
+ |--------|------|-------------|
833
+ | GET | `/pay/.balance` | Check your balance (NIP-98 auth) |
834
+ | POST | `/pay/.deposit` | Deposit sats via TXO URI (`txid:vout`) |
835
+ | GET | `/pay/*` | Paid resource access (deducts balance) |
836
+
837
+ ### How It Works
838
+
839
+ 1. Authenticate with NIP-98 (Nostr HTTP Auth)
840
+ 2. Check balance at `/pay/.balance`
841
+ 3. Deposit sats by POSTing a TXO URI to `/pay/.deposit`
842
+ 4. Access paid resources — each request deducts the configured cost
843
+ 5. Balance tracked in a [Web Ledger](https://webledgers.org/) at `/.well-known/webledgers/webledgers.json`
844
+
845
+ ### Example
846
+
847
+ ```bash
848
+ # Check balance
849
+ curl -H "Authorization: Nostr <base64-event>" http://localhost:3000/pay/.balance
850
+
851
+ # Deposit (post a confirmed transaction output)
852
+ curl -X POST -H "Authorization: Nostr <base64-event>" \
853
+ http://localhost:3000/pay/.deposit \
854
+ -d "txid:vout"
855
+
856
+ # Access paid resource
857
+ curl -H "Authorization: Nostr <base64-event>" http://localhost:3000/pay/my-resource
858
+ ```
859
+
860
+ Deposit verification uses the mempool API (default: testnet4). The `X-Balance` and `X-Cost` headers are returned on successful paid requests.
861
+
813
862
  ## Authentication
814
863
 
815
864
  ### Simple Tokens (Development)
@@ -1113,7 +1162,7 @@ npm run benchmark
1113
1162
  npm test
1114
1163
  ```
1115
1164
 
1116
- Currently passing: **229 tests** (including 27 conformance tests)
1165
+ Currently passing: **289 tests** (including 27 conformance tests)
1117
1166
 
1118
1167
  ### Conformance Test Harness (CTH)
1119
1168
 
@@ -1155,7 +1204,8 @@ src/
1155
1204
  ├── handlers/
1156
1205
  │ ├── resource.js # GET, PUT, DELETE, HEAD, PATCH
1157
1206
  │ ├── container.js # POST, pod creation
1158
- └── git.js # Git HTTP backend
1207
+ ├── git.js # Git HTTP backend
1208
+ │ └── pay.js # HTTP 402 paid access
1159
1209
  ├── storage/
1160
1210
  │ ├── filesystem.js # File operations
1161
1211
  │ └── quota.js # Storage quota management
@@ -1203,6 +1253,8 @@ src/
1203
1253
  │ ├── collections.js # Followers/following
1204
1254
  │ ├── mastodon.js # Mastodon API (apps, instance, verify_credentials)
1205
1255
  │ └── oauth.js # OAuth 2.0 authorize/token flow
1256
+ ├── webledger.js # Web Ledger balance tracking (webledgers.org)
1257
+ ├── mrc20.js # State chain verification
1206
1258
  ├── remotestorage.js # remoteStorage protocol (draft-dejong-remotestorage-22)
1207
1259
  ├── rdf/
1208
1260
  │ ├── turtle.js # Turtle <-> JSON-LD
package/bin/jss.js CHANGED
@@ -80,6 +80,11 @@ program
80
80
  .option('--public', 'Allow unauthenticated access (skip WAC, open read/write)')
81
81
  .option('--read-only', 'Disable PUT/DELETE/PATCH methods (read-only mode)')
82
82
  .option('--live-reload', 'Inject live reload script into HTML (auto-refresh on changes)')
83
+ .option('--pay', 'Enable HTTP 402 paid access for /pay/* routes')
84
+ .option('--no-pay', 'Disable HTTP 402 paid access')
85
+ .option('--pay-cost <n>', 'Cost per request in satoshis (default: 1)', parseInt)
86
+ .option('--pay-mempool-url <url>', 'Mempool API URL for deposit verification')
87
+ .option('--pay-address <addr>', 'Address for receiving deposits')
83
88
  .option('--mongo', 'Enable MongoDB-backed /db/ route')
84
89
  .option('--no-mongo', 'Disable MongoDB-backed /db/ route')
85
90
  .option('--mongo-url <url>', 'MongoDB connection URL (default: mongodb://localhost:27017)')
@@ -146,6 +151,10 @@ program
146
151
  public: config.public,
147
152
  readOnly: config.readOnly,
148
153
  liveReload: config.liveReload,
154
+ pay: config.pay,
155
+ payCost: config.payCost,
156
+ payMempoolUrl: config.payMempoolUrl,
157
+ payAddress: config.payAddress,
149
158
  mongo: config.mongo,
150
159
  mongoUrl: config.mongoUrl,
151
160
  mongoDatabase: config.mongoDatabase,
@@ -184,6 +193,7 @@ program
184
193
  }
185
194
  console.log(' Do not expose to the internet!');
186
195
  }
196
+ if (config.pay) console.log(` Pay: ${config.payCost} sat/req (402 enabled)`);
187
197
  if (config.mongo) console.log(` MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`);
188
198
  if (config.readOnly) console.log(' Read-only: enabled (PUT/DELETE/PATCH disabled)');
189
199
  console.log('\n Press Ctrl+C to stop\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.100",
3
+ "version": "0.0.102",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "start": "node bin/jss.js start",
20
20
  "dev": "node --watch bin/jss.js start",
21
21
  "test": "node --test --test-concurrency=1 'test/*.test.js'",
22
+ "test:live-reload": "node test/live-reload.standalone.js",
22
23
  "test:cth": "node scripts/test-cth-compat.js",
23
24
  "benchmark": "node benchmark.js"
24
25
  },
package/src/config.js CHANGED
@@ -83,6 +83,12 @@ export const defaults = {
83
83
  // Live reload - inject script to auto-refresh browser on file changes
84
84
  liveReload: false,
85
85
 
86
+ // HTTP 402 paid access
87
+ pay: false,
88
+ payCost: 1,
89
+ payMempoolUrl: 'https://mempool.space/testnet4',
90
+ payAddress: null,
91
+
86
92
  // MongoDB-backed /db/ route
87
93
  mongo: false,
88
94
  mongoUrl: 'mongodb://localhost:27017',
@@ -138,6 +144,10 @@ const envMap = {
138
144
  JSS_PUBLIC: 'public',
139
145
  JSS_READ_ONLY: 'readOnly',
140
146
  JSS_LIVE_RELOAD: 'liveReload',
147
+ JSS_PAY: 'pay',
148
+ JSS_PAY_COST: 'payCost',
149
+ JSS_PAY_MEMPOOL_URL: 'payMempoolUrl',
150
+ JSS_PAY_ADDRESS: 'payAddress',
141
151
  JSS_MONGO: 'mongo',
142
152
  JSS_MONGO_URL: 'mongoUrl',
143
153
  JSS_MONGO_DATABASE: 'mongoDatabase',
@@ -167,7 +177,7 @@ function parseEnvValue(value, key) {
167
177
  if (value.toLowerCase() === 'false') return false;
168
178
 
169
179
  // Numeric values for known numeric keys
170
- if ((key === 'port' || key === 'nostrMaxEvents') && !isNaN(value)) {
180
+ if ((key === 'port' || key === 'nostrMaxEvents' || key === 'payCost') && !isNaN(value)) {
171
181
  return parseInt(value, 10);
172
182
  }
173
183
 
@@ -305,6 +315,7 @@ export function printConfig(config) {
305
315
  console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
306
316
  console.log(` Mashlib: ${config.mashlibModule ? `module (${config.mashlibModule})` : config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`);
307
317
  console.log(` SolidOS UI: ${config.solidosUi ? 'enabled' : 'disabled'}`);
318
+ if (config.pay) console.log(` Pay: ${config.payCost} sat/req`);
308
319
  if (config.mongo) console.log(` MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`);
309
320
  console.log('─'.repeat(40));
310
321
  }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * HTTP 402 Payment Required middleware
3
+ *
4
+ * Enables paid access to resources under /pay/* prefix.
5
+ * Authentication via NIP-98. Balance tracking via Web Ledgers spec.
6
+ *
7
+ * Routes:
8
+ * GET /pay/.balance — check your balance
9
+ * POST /pay/.deposit — deposit sats (TXO URI) or tokens (MRC20 state proof)
10
+ * GET /pay/* — paid resource access (requires balance >= cost)
11
+ * PUT /pay/* — upload resources (standard auth)
12
+ *
13
+ * Ledger: /.well-known/webledgers/webledgers.json (webledgers.org spec)
14
+ *
15
+ * References:
16
+ * - Web Ledgers spec: https://webledgers.org/
17
+ * - NIP-98 HTTP Auth: https://nips.nostr.com/98
18
+ * - TXO URI: https://www.npmjs.com/package/txo_parser
19
+ * - MRC20 profile: https://blocktrails.org/
20
+ */
21
+
22
+ import { getNostrPubkey, pubkeyToDidNostr } from '../auth/nostr.js';
23
+ import { readLedger, writeLedger, getBalance, credit, debit } from '../webledger.js';
24
+ import { verifyMrc20Deposit, verifyMrc20Anchor, jcs } from '../mrc20.js';
25
+ import fs from 'fs-extra';
26
+ import path from 'path';
27
+
28
+ const DEFAULT_COST = 1; // satoshis per request
29
+
30
+ // --- Replay protection ---
31
+ const replayFile = () => path.join(process.env.DATA_ROOT || './data', '.well-known/webledgers/replay.json');
32
+
33
+ async function loadReplaySet() {
34
+ try {
35
+ const data = await fs.readFile(replayFile(), 'utf8');
36
+ return new Set(JSON.parse(data));
37
+ } catch { return new Set(); }
38
+ }
39
+
40
+ async function saveReplaySet(set) {
41
+ await fs.ensureDir(path.dirname(replayFile()));
42
+ await fs.writeFile(replayFile(), JSON.stringify([...set]));
43
+ }
44
+
45
+ async function checkAndRecordState(stateHash) {
46
+ const seen = await loadReplaySet();
47
+ if (seen.has(stateHash)) return false; // replay!
48
+ seen.add(stateHash);
49
+ await saveReplaySet(seen);
50
+ return true;
51
+ }
52
+
53
+ // --- Deposit verification via mempool API ---
54
+
55
+ async function verifySatsDeposit(txoUri, mempoolUrl) {
56
+ const match = txoUri.match(/([0-9a-f]{64}):(\d+)/i);
57
+ if (!match) {
58
+ return { valid: false, amount: 0, error: 'Invalid TXO URI format (expected txid:vout)' };
59
+ }
60
+ const [, txid, voutStr] = match;
61
+ const vout = parseInt(voutStr, 10);
62
+
63
+ try {
64
+ const resp = await fetch(`${mempoolUrl}/api/tx/${txid}`);
65
+ if (!resp.ok) return { valid: false, amount: 0, error: 'Transaction not found' };
66
+ const tx = await resp.json();
67
+ const output = tx.vout?.[vout];
68
+ if (!output) return { valid: false, amount: 0, error: `Output index ${vout} not found` };
69
+ return { valid: true, amount: output.value };
70
+ } catch (err) {
71
+ return { valid: false, amount: 0, error: `Mempool API error: ${err.message}` };
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Parse deposit request body — returns either a sats TXO URI or MRC20 state proof
77
+ * @param {*} body - Request body (Buffer, string, or parsed object)
78
+ * @returns {{type: 'sats', txo: string} | {type: 'mrc20', state: object, prevState: object} | {type: 'unknown'}}
79
+ */
80
+ function parseDepositBody(body) {
81
+ // Buffer → string first
82
+ if (Buffer.isBuffer(body)) {
83
+ const str = body.toString('utf8').trim();
84
+ // Try JSON parse
85
+ try {
86
+ const obj = JSON.parse(str);
87
+ return classifyDepositObject(obj);
88
+ } catch {
89
+ // Not JSON — treat as TXO URI string
90
+ return { type: 'sats', txo: str };
91
+ }
92
+ }
93
+
94
+ // Already parsed object
95
+ if (body && typeof body === 'object') {
96
+ return classifyDepositObject(body);
97
+ }
98
+
99
+ // String
100
+ if (typeof body === 'string') {
101
+ const trimmed = body.trim();
102
+ try {
103
+ const obj = JSON.parse(trimmed);
104
+ return classifyDepositObject(obj);
105
+ } catch {
106
+ return { type: 'sats', txo: trimmed };
107
+ }
108
+ }
109
+
110
+ return { type: 'unknown' };
111
+ }
112
+
113
+ function classifyDepositObject(obj) {
114
+ // Explicit type field
115
+ if (obj.type === 'mrc20' && obj.state && obj.prevState) {
116
+ return { type: 'mrc20', state: obj.state, prevState: obj.prevState, anchor: obj.anchor };
117
+ }
118
+ // Auto-detect: if it has state + prevState with MRC20 profile
119
+ if (obj.state?.profile === 'mono.mrc20.v0.1' && obj.prevState) {
120
+ return { type: 'mrc20', state: obj.state, prevState: obj.prevState, anchor: obj.anchor };
121
+ }
122
+ // Fall back to TXO URI in .txo field
123
+ if (obj.txo) {
124
+ return { type: 'sats', txo: obj.txo };
125
+ }
126
+ return { type: 'unknown' };
127
+ }
128
+
129
+ // --- Check if URL is a /pay/ route ---
130
+
131
+ export function isPayRequest(url) {
132
+ const path = url.split('?')[0];
133
+ return path.startsWith('/pay/') || path === '/pay';
134
+ }
135
+
136
+ // --- preHandler hook for /pay/* routes ---
137
+
138
+ /**
139
+ * Create pay preHandler hook
140
+ * @param {object} options
141
+ * @param {number} options.cost - Cost per request in satoshis (default 1)
142
+ * @param {string} options.mempoolUrl - Mempool API base URL
143
+ * @param {string} options.payAddress - Pod's MRC20 address for receiving token transfers
144
+ * @returns {function} Fastify preHandler hook
145
+ */
146
+ export function createPayHandler(options = {}) {
147
+ const cost = options.cost ?? DEFAULT_COST;
148
+ const mempoolUrl = options.mempoolUrl ?? 'https://mempool.space/testnet4';
149
+ const payAddress = options.payAddress ?? null;
150
+
151
+ return async function payHandler(request, reply) {
152
+ const url = request.url.split('?')[0];
153
+ if (!isPayRequest(request.url)) return;
154
+
155
+ // --- GET /pay/.balance ---
156
+ if (url === '/pay/.balance' && request.method === 'GET') {
157
+ const pubkey = await getNostrPubkey(request);
158
+ if (!pubkey) {
159
+ return reply.code(401).send({ error: 'NIP-98 authentication required' });
160
+ }
161
+ const didUri = pubkeyToDidNostr(pubkey);
162
+ const ledger = await readLedger();
163
+ return reply.send({
164
+ did: didUri,
165
+ balance: getBalance(ledger, didUri),
166
+ cost,
167
+ unit: 'sat'
168
+ });
169
+ }
170
+
171
+ // --- POST /pay/.deposit ---
172
+ if (url === '/pay/.deposit' && request.method === 'POST') {
173
+ const pubkey = await getNostrPubkey(request);
174
+ if (!pubkey) {
175
+ return reply.code(401).send({ error: 'NIP-98 authentication required' });
176
+ }
177
+
178
+ const deposit = parseDepositBody(request.body);
179
+
180
+ // --- MRC20 token deposit ---
181
+ if (deposit.type === 'mrc20') {
182
+ if (!payAddress) {
183
+ return reply.code(400).send({
184
+ error: 'MRC20 deposits not configured (no payAddress set)'
185
+ });
186
+ }
187
+
188
+ // Replay protection: reject duplicate state hashes
189
+ const stateHash = jcs(deposit.state);
190
+ const isNew = await checkAndRecordState(stateHash);
191
+ if (!isNew) {
192
+ return reply.code(400).send({ error: 'Replay: this state has already been used for a deposit' });
193
+ }
194
+
195
+ let result;
196
+
197
+ // Anchor verification (if anchor data provided)
198
+ if (deposit.anchor && deposit.anchor.pubkey && deposit.anchor.stateStrings) {
199
+ result = await verifyMrc20Anchor({
200
+ state: deposit.state,
201
+ prevState: deposit.prevState,
202
+ toAddress: payAddress,
203
+ pubkey: deposit.anchor.pubkey,
204
+ stateStrings: deposit.anchor.stateStrings,
205
+ mempoolUrl,
206
+ network: deposit.anchor.network || 'testnet4'
207
+ });
208
+ } else {
209
+ // Fallback: verify chain integrity only (no anchor check)
210
+ result = verifyMrc20Deposit({
211
+ state: deposit.state,
212
+ prevState: deposit.prevState,
213
+ toAddress: payAddress
214
+ });
215
+ }
216
+
217
+ if (!result.valid) {
218
+ return reply.code(400).send({ error: result.error });
219
+ }
220
+
221
+ const didUri = pubkeyToDidNostr(pubkey);
222
+ const ledger = await readLedger();
223
+ const newBalance = credit(ledger, didUri, result.amount);
224
+ await writeLedger(ledger);
225
+
226
+ return reply.send({
227
+ did: didUri,
228
+ deposited: result.amount,
229
+ ticker: result.ticker,
230
+ balance: newBalance,
231
+ unit: 'token',
232
+ ...(result.address ? { anchor: result.address } : {})
233
+ });
234
+ }
235
+
236
+ // --- Sats deposit (TXO URI) ---
237
+ if (deposit.type === 'sats') {
238
+ const result = await verifySatsDeposit(deposit.txo, mempoolUrl);
239
+ if (!result.valid) {
240
+ return reply.code(400).send({ error: result.error });
241
+ }
242
+
243
+ const didUri = pubkeyToDidNostr(pubkey);
244
+ const ledger = await readLedger();
245
+ const newBalance = credit(ledger, didUri, result.amount);
246
+ await writeLedger(ledger);
247
+
248
+ return reply.send({
249
+ did: didUri,
250
+ deposited: result.amount,
251
+ balance: newBalance,
252
+ unit: 'sat'
253
+ });
254
+ }
255
+
256
+ return reply.code(400).send({
257
+ error: 'Invalid deposit format. Send a TXO URI string or MRC20 state proof.',
258
+ formats: {
259
+ sats: 'POST body: "<txid>:<vout>" or {"txo": "<txid>:<vout>"}',
260
+ mrc20: 'POST body: {"type": "mrc20", "state": {...}, "prevState": {...}}'
261
+ }
262
+ });
263
+ }
264
+
265
+ // --- GET/HEAD /pay/* — paid resource access ---
266
+ if (request.method === 'GET' || request.method === 'HEAD') {
267
+ const pubkey = await getNostrPubkey(request);
268
+ if (!pubkey) {
269
+ return reply.code(401).send({
270
+ error: 'NIP-98 authentication required',
271
+ deposit: '/pay/.deposit'
272
+ });
273
+ }
274
+
275
+ const didUri = pubkeyToDidNostr(pubkey);
276
+ const ledger = await readLedger();
277
+ const { success, balance } = debit(ledger, didUri, cost);
278
+
279
+ if (!success) {
280
+ return reply.code(402).send({
281
+ error: 'Payment Required',
282
+ balance,
283
+ cost,
284
+ unit: 'sat',
285
+ deposit: '/pay/.deposit'
286
+ });
287
+ }
288
+
289
+ await writeLedger(ledger);
290
+ reply.header('X-Balance', String(balance));
291
+ reply.header('X-Cost', String(cost));
292
+ return; // continue to normal resource handler
293
+ }
294
+
295
+ // PUT/DELETE/POST — continue to normal WAC auth + resource handler
296
+ };
297
+ }