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.
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: **279 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.101",
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,248 @@
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 } from '../mrc20.js';
25
+
26
+ const DEFAULT_COST = 1; // satoshis per request
27
+
28
+ // --- Deposit verification via mempool API ---
29
+
30
+ async function verifySatsDeposit(txoUri, mempoolUrl) {
31
+ const match = txoUri.match(/([0-9a-f]{64}):(\d+)/i);
32
+ if (!match) {
33
+ return { valid: false, amount: 0, error: 'Invalid TXO URI format (expected txid:vout)' };
34
+ }
35
+ const [, txid, voutStr] = match;
36
+ const vout = parseInt(voutStr, 10);
37
+
38
+ try {
39
+ const resp = await fetch(`${mempoolUrl}/api/tx/${txid}`);
40
+ if (!resp.ok) return { valid: false, amount: 0, error: 'Transaction not found' };
41
+ const tx = await resp.json();
42
+ const output = tx.vout?.[vout];
43
+ if (!output) return { valid: false, amount: 0, error: `Output index ${vout} not found` };
44
+ return { valid: true, amount: output.value };
45
+ } catch (err) {
46
+ return { valid: false, amount: 0, error: `Mempool API error: ${err.message}` };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Parse deposit request body — returns either a sats TXO URI or MRC20 state proof
52
+ * @param {*} body - Request body (Buffer, string, or parsed object)
53
+ * @returns {{type: 'sats', txo: string} | {type: 'mrc20', state: object, prevState: object} | {type: 'unknown'}}
54
+ */
55
+ function parseDepositBody(body) {
56
+ // Buffer → string first
57
+ if (Buffer.isBuffer(body)) {
58
+ const str = body.toString('utf8').trim();
59
+ // Try JSON parse
60
+ try {
61
+ const obj = JSON.parse(str);
62
+ return classifyDepositObject(obj);
63
+ } catch {
64
+ // Not JSON — treat as TXO URI string
65
+ return { type: 'sats', txo: str };
66
+ }
67
+ }
68
+
69
+ // Already parsed object
70
+ if (body && typeof body === 'object') {
71
+ return classifyDepositObject(body);
72
+ }
73
+
74
+ // String
75
+ if (typeof body === 'string') {
76
+ const trimmed = body.trim();
77
+ try {
78
+ const obj = JSON.parse(trimmed);
79
+ return classifyDepositObject(obj);
80
+ } catch {
81
+ return { type: 'sats', txo: trimmed };
82
+ }
83
+ }
84
+
85
+ return { type: 'unknown' };
86
+ }
87
+
88
+ function classifyDepositObject(obj) {
89
+ // Explicit type field
90
+ if (obj.type === 'mrc20' && obj.state && obj.prevState) {
91
+ return { type: 'mrc20', state: obj.state, prevState: obj.prevState };
92
+ }
93
+ // Auto-detect: if it has state + prevState with MRC20 profile
94
+ if (obj.state?.profile === 'mono.mrc20.v0.1' && obj.prevState) {
95
+ return { type: 'mrc20', state: obj.state, prevState: obj.prevState };
96
+ }
97
+ // Fall back to TXO URI in .txo field
98
+ if (obj.txo) {
99
+ return { type: 'sats', txo: obj.txo };
100
+ }
101
+ return { type: 'unknown' };
102
+ }
103
+
104
+ // --- Check if URL is a /pay/ route ---
105
+
106
+ export function isPayRequest(url) {
107
+ const path = url.split('?')[0];
108
+ return path.startsWith('/pay/') || path === '/pay';
109
+ }
110
+
111
+ // --- preHandler hook for /pay/* routes ---
112
+
113
+ /**
114
+ * Create pay preHandler hook
115
+ * @param {object} options
116
+ * @param {number} options.cost - Cost per request in satoshis (default 1)
117
+ * @param {string} options.mempoolUrl - Mempool API base URL
118
+ * @param {string} options.payAddress - Pod's MRC20 address for receiving token transfers
119
+ * @returns {function} Fastify preHandler hook
120
+ */
121
+ export function createPayHandler(options = {}) {
122
+ const cost = options.cost ?? DEFAULT_COST;
123
+ const mempoolUrl = options.mempoolUrl ?? 'https://mempool.space/testnet4';
124
+ const payAddress = options.payAddress ?? null;
125
+
126
+ return async function payHandler(request, reply) {
127
+ const url = request.url.split('?')[0];
128
+ if (!isPayRequest(request.url)) return;
129
+
130
+ // --- GET /pay/.balance ---
131
+ if (url === '/pay/.balance' && request.method === 'GET') {
132
+ const pubkey = await getNostrPubkey(request);
133
+ if (!pubkey) {
134
+ return reply.code(401).send({ error: 'NIP-98 authentication required' });
135
+ }
136
+ const didUri = pubkeyToDidNostr(pubkey);
137
+ const ledger = await readLedger();
138
+ return reply.send({
139
+ did: didUri,
140
+ balance: getBalance(ledger, didUri),
141
+ cost,
142
+ unit: 'sat'
143
+ });
144
+ }
145
+
146
+ // --- POST /pay/.deposit ---
147
+ if (url === '/pay/.deposit' && request.method === 'POST') {
148
+ const pubkey = await getNostrPubkey(request);
149
+ if (!pubkey) {
150
+ return reply.code(401).send({ error: 'NIP-98 authentication required' });
151
+ }
152
+
153
+ const deposit = parseDepositBody(request.body);
154
+
155
+ // --- MRC20 token deposit ---
156
+ if (deposit.type === 'mrc20') {
157
+ if (!payAddress) {
158
+ return reply.code(400).send({
159
+ error: 'MRC20 deposits not configured (no payAddress set)'
160
+ });
161
+ }
162
+
163
+ const result = verifyMrc20Deposit({
164
+ state: deposit.state,
165
+ prevState: deposit.prevState,
166
+ toAddress: payAddress
167
+ });
168
+
169
+ if (!result.valid) {
170
+ return reply.code(400).send({ error: result.error });
171
+ }
172
+
173
+ const didUri = pubkeyToDidNostr(pubkey);
174
+ const ledger = await readLedger();
175
+ const newBalance = credit(ledger, didUri, result.amount);
176
+ await writeLedger(ledger);
177
+
178
+ return reply.send({
179
+ did: didUri,
180
+ deposited: result.amount,
181
+ ticker: result.ticker,
182
+ balance: newBalance,
183
+ unit: 'token'
184
+ });
185
+ }
186
+
187
+ // --- Sats deposit (TXO URI) ---
188
+ if (deposit.type === 'sats') {
189
+ const result = await verifySatsDeposit(deposit.txo, mempoolUrl);
190
+ if (!result.valid) {
191
+ return reply.code(400).send({ error: result.error });
192
+ }
193
+
194
+ const didUri = pubkeyToDidNostr(pubkey);
195
+ const ledger = await readLedger();
196
+ const newBalance = credit(ledger, didUri, result.amount);
197
+ await writeLedger(ledger);
198
+
199
+ return reply.send({
200
+ did: didUri,
201
+ deposited: result.amount,
202
+ balance: newBalance,
203
+ unit: 'sat'
204
+ });
205
+ }
206
+
207
+ return reply.code(400).send({
208
+ error: 'Invalid deposit format. Send a TXO URI string or MRC20 state proof.',
209
+ formats: {
210
+ sats: 'POST body: "<txid>:<vout>" or {"txo": "<txid>:<vout>"}',
211
+ mrc20: 'POST body: {"type": "mrc20", "state": {...}, "prevState": {...}}'
212
+ }
213
+ });
214
+ }
215
+
216
+ // --- GET/HEAD /pay/* — paid resource access ---
217
+ if (request.method === 'GET' || request.method === 'HEAD') {
218
+ const pubkey = await getNostrPubkey(request);
219
+ if (!pubkey) {
220
+ return reply.code(401).send({
221
+ error: 'NIP-98 authentication required',
222
+ deposit: '/pay/.deposit'
223
+ });
224
+ }
225
+
226
+ const didUri = pubkeyToDidNostr(pubkey);
227
+ const ledger = await readLedger();
228
+ const { success, balance } = debit(ledger, didUri, cost);
229
+
230
+ if (!success) {
231
+ return reply.code(402).send({
232
+ error: 'Payment Required',
233
+ balance,
234
+ cost,
235
+ unit: 'sat',
236
+ deposit: '/pay/.deposit'
237
+ });
238
+ }
239
+
240
+ await writeLedger(ledger);
241
+ reply.header('X-Balance', String(balance));
242
+ reply.header('X-Cost', String(cost));
243
+ return; // continue to normal resource handler
244
+ }
245
+
246
+ // PUT/DELETE/POST — continue to normal WAC auth + resource handler
247
+ };
248
+ }
package/src/mrc20.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * MRC20 Token Verification
3
+ *
4
+ * Verifies MRC20 state chain integrity and extracts transfer operations.
5
+ * Used by the pay middleware to accept token deposits.
6
+ *
7
+ * MRC20 profile: mono.mrc20.v0.1
8
+ * State chain: each state links to previous via SHA-256 of JCS-encoded state.
9
+ *
10
+ * References:
11
+ * - Blocktrails: https://blocktrails.org/
12
+ * - JCS (RFC 8785): JSON Canonicalization Scheme
13
+ */
14
+
15
+ import crypto from 'crypto';
16
+
17
+ const MRC20_PROFILE = 'mono.mrc20.v0.1';
18
+ const TRANSFER_OP = 'urn:mono:op:transfer';
19
+
20
+ /**
21
+ * JSON Canonicalization Scheme (RFC 8785)
22
+ * Produces deterministic JSON — sorted keys, no whitespace.
23
+ * @param {*} obj
24
+ * @returns {string}
25
+ */
26
+ export function jcs(obj) {
27
+ if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
28
+ if (Array.isArray(obj)) return '[' + obj.map(v => jcs(v)).join(',') + ']';
29
+ const keys = Object.keys(obj).sort();
30
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + jcs(obj[k])).join(',') + '}';
31
+ }
32
+
33
+ /**
34
+ * SHA-256 hex digest of a string
35
+ * @param {string} str
36
+ * @returns {string} Hex hash
37
+ */
38
+ export function sha256Hex(str) {
39
+ return crypto.createHash('sha256').update(str).digest('hex');
40
+ }
41
+
42
+ /**
43
+ * Verify state chain link: state.prev must equal SHA-256(JCS(prevState))
44
+ * @param {object} state - Current state
45
+ * @param {object} prevState - Previous state
46
+ * @returns {{valid: boolean, error?: string}}
47
+ */
48
+ export function verifyStateLink(state, prevState) {
49
+ if (!state || !prevState) {
50
+ return { valid: false, error: 'Missing state or prevState' };
51
+ }
52
+
53
+ const expectedPrev = sha256Hex(jcs(prevState));
54
+ if (state.prev !== expectedPrev) {
55
+ return { valid: false, error: `State chain break: expected prev ${expectedPrev}, got ${state.prev}` };
56
+ }
57
+
58
+ // Verify sequence number
59
+ if (typeof state.seq === 'number' && typeof prevState.seq === 'number') {
60
+ if (state.seq !== prevState.seq + 1) {
61
+ return { valid: false, error: `Sequence mismatch: expected ${prevState.seq + 1}, got ${state.seq}` };
62
+ }
63
+ }
64
+
65
+ return { valid: true };
66
+ }
67
+
68
+ /**
69
+ * Validate that a state object is a valid MRC20 state
70
+ * @param {object} state
71
+ * @returns {{valid: boolean, error?: string}}
72
+ */
73
+ export function validateMrc20State(state) {
74
+ if (!state || typeof state !== 'object') {
75
+ return { valid: false, error: 'State must be an object' };
76
+ }
77
+ if (state.profile !== MRC20_PROFILE) {
78
+ return { valid: false, error: `Invalid profile: expected ${MRC20_PROFILE}, got ${state.profile}` };
79
+ }
80
+ if (!Array.isArray(state.ops)) {
81
+ return { valid: false, error: 'State must have ops array' };
82
+ }
83
+ if (typeof state.prev !== 'string') {
84
+ return { valid: false, error: 'State must have prev hash' };
85
+ }
86
+ return { valid: true };
87
+ }
88
+
89
+ /**
90
+ * Extract transfer operations targeting a specific address
91
+ * @param {object} state - MRC20 state
92
+ * @param {string} toAddress - Recipient address to filter by
93
+ * @returns {Array<{from: string, to: string, amt: number}>} Matching transfers
94
+ */
95
+ export function extractTransfersTo(state, toAddress) {
96
+ if (!state.ops || !Array.isArray(state.ops)) return [];
97
+ return state.ops.filter(op =>
98
+ op.op === TRANSFER_OP && op.to === toAddress && typeof op.amt === 'number' && op.amt > 0
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Get total amount transferred to an address in a state
104
+ * @param {object} state - MRC20 state
105
+ * @param {string} toAddress - Recipient address
106
+ * @returns {number} Total amount transferred
107
+ */
108
+ export function totalTransferredTo(state, toAddress) {
109
+ const transfers = extractTransfersTo(state, toAddress);
110
+ return transfers.reduce((sum, op) => sum + op.amt, 0);
111
+ }
112
+
113
+ /**
114
+ * Verify an MRC20 deposit: validate state chain + extract transfer amount
115
+ * @param {object} params
116
+ * @param {object} params.state - New state containing transfer ops
117
+ * @param {object} params.prevState - Previous state (for chain verification)
118
+ * @param {string} params.toAddress - Pod's address to check transfers against
119
+ * @returns {{valid: boolean, amount: number, ticker?: string, error?: string}}
120
+ */
121
+ export function verifyMrc20Deposit(params) {
122
+ const { state, prevState, toAddress } = params;
123
+
124
+ // Validate MRC20 format
125
+ const stateCheck = validateMrc20State(state);
126
+ if (!stateCheck.valid) return { valid: false, amount: 0, error: stateCheck.error };
127
+
128
+ const prevCheck = validateMrc20State(prevState);
129
+ if (!prevCheck.valid) return { valid: false, amount: 0, error: `prevState: ${prevCheck.error}` };
130
+
131
+ // Verify state chain link
132
+ const linkCheck = verifyStateLink(state, prevState);
133
+ if (!linkCheck.valid) return { valid: false, amount: 0, error: linkCheck.error };
134
+
135
+ // Extract transfers to pod
136
+ const amount = totalTransferredTo(state, toAddress);
137
+ if (amount <= 0) {
138
+ return { valid: false, amount: 0, error: `No transfers to ${toAddress} found in state ops` };
139
+ }
140
+
141
+ return {
142
+ valid: true,
143
+ amount,
144
+ ticker: state.ticker || 'UNKNOWN'
145
+ };
146
+ }
package/src/server.js CHANGED
@@ -14,6 +14,7 @@ import { idpPlugin } from './idp/index.js';
14
14
  import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
15
15
  import { AccessMode } from './wac/parser.js';
16
16
  import { registerNostrRelay } from './nostr/relay.js';
17
+ import { createPayHandler, isPayRequest } from './handlers/pay.js';
17
18
  import { activityPubPlugin, getActorHandler } from './ap/index.js';
18
19
  import { remoteStoragePlugin } from './remotestorage.js';
19
20
  import { dbPlugin } from './db/index.js';
@@ -42,6 +43,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
42
43
  * @param {string} options.apSummary - ActivityPub bio/summary
43
44
  * @param {string} options.apNostrPubkey - Nostr pubkey for identity linking
44
45
  * @param {boolean} options.webidTls - Enable WebID-TLS client certificate auth (default false)
46
+ * @param {boolean} options.pay - Enable HTTP 402 paid /pay/* routes (default false)
47
+ * @param {number} options.payCost - Cost per request in satoshis (default 1)
48
+ * @param {string} options.payMempoolUrl - Mempool API base URL (default testnet4)
49
+ * @param {string} options.payAddress - Pod's MRC20 address for receiving token transfers
45
50
  */
46
51
  export function createServer(options = {}) {
47
52
  // Content negotiation is OFF by default - we're a JSON-LD native server
@@ -90,6 +95,11 @@ export function createServer(options = {}) {
90
95
  const mongoEnabled = options.mongo ?? false;
91
96
  const mongoUrl = options.mongoUrl ?? 'mongodb://localhost:27017';
92
97
  const mongoDatabase = options.mongoDatabase ?? 'solid';
98
+ // HTTP 402 paid /pay/ routes are OFF by default
99
+ const payEnabled = options.pay ?? false;
100
+ const payCost = options.payCost ?? 1;
101
+ const payMempoolUrl = options.payMempoolUrl ?? 'https://mempool.space/testnet4';
102
+ const payAddress = options.payAddress ?? null; // Pod's MRC20 address for token deposits
93
103
 
94
104
  // Set data root via environment variable if provided
95
105
  if (options.root) {
@@ -102,6 +112,8 @@ export function createServer(options = {}) {
102
112
  logger: loggerEnabled ? { level: options.logLevel || 'info' } : false,
103
113
  disableRequestLogging: true,
104
114
  trustProxy: true,
115
+ // Force close connections on server.close() (useful for tests with WebSockets)
116
+ forceCloseConnections: options.forceCloseConnections ?? false,
105
117
  // Handle raw body for non-JSON content
106
118
  bodyLimit: 10 * 1024 * 1024, // 10MB
107
119
  // Gracefully handle client TCP errors (ECONNRESET, EPIPE, etc.)
@@ -311,6 +323,11 @@ export function createServer(options = {}) {
311
323
  return;
312
324
  }
313
325
 
326
+ // Allow pay routes through when pay is enabled (.balance, .deposit)
327
+ if (payEnabled && isPayRequest(request.url)) {
328
+ return;
329
+ }
330
+
314
331
  const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
315
332
  const hasForbiddenDotfile = segments.some(seg =>
316
333
  seg.startsWith('.') &&
@@ -355,6 +372,11 @@ export function createServer(options = {}) {
355
372
  });
356
373
  }
357
374
 
375
+ // HTTP 402 Payment Required handler for /pay/* routes
376
+ if (payEnabled) {
377
+ fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress }));
378
+ }
379
+
358
380
  // Authorization hook - check WAC permissions
359
381
  // Skip for pod creation endpoint (needs special handling)
360
382
  fastify.addHook('preHandler', async (request, reply) => {
@@ -378,6 +400,7 @@ export function createServer(options = {}) {
378
400
  (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
379
401
  isProfileAP ||
380
402
  request.url.startsWith('/storage/') ||
403
+ (payEnabled && isPayRequest(request.url)) ||
381
404
  (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
382
405
  mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
383
406
  return;