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 +1 -0
- package/README.md +54 -2
- package/bin/jss.js +10 -0
- package/package.json +2 -1
- package/src/config.js +12 -1
- package/src/handlers/pay.js +248 -0
- package/src/mrc20.js +146 -0
- package/src/server.js +23 -0
- package/src/webledger.js +162 -0
- package/test/helpers.js +1 -2
- package/test/idp.test.js +57 -41
- package/test/{live-reload.test.js → live-reload.standalone.js} +1 -0
- package/test/mrc20.test.js +237 -0
- package/test/pay.test.js +231 -0
- package/test/webledger.test.js +174 -0
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: **
|
|
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
|
-
│
|
|
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.
|
|
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;
|