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 +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 +297 -0
- package/src/mrc20.js +335 -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 +343 -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: **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
|
-
│
|
|
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.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
|
+
}
|