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/src/mrc20.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
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
|
+
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
17
|
+
|
|
18
|
+
const MRC20_PROFILE = 'mono.mrc20.v0.1';
|
|
19
|
+
const TRANSFER_OP = 'urn:mono:op:transfer';
|
|
20
|
+
|
|
21
|
+
// --- BIP-341 key chaining constants ---
|
|
22
|
+
const SECP_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141');
|
|
23
|
+
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
|
24
|
+
const BECH32M = 0x2bc830a3;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* JSON Canonicalization Scheme (RFC 8785)
|
|
28
|
+
* Produces deterministic JSON — sorted keys, no whitespace.
|
|
29
|
+
* @param {*} obj
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function jcs(obj) {
|
|
33
|
+
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
34
|
+
if (Array.isArray(obj)) return '[' + obj.map(v => jcs(v)).join(',') + ']';
|
|
35
|
+
const keys = Object.keys(obj).sort();
|
|
36
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + jcs(obj[k])).join(',') + '}';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* SHA-256 hex digest of a string
|
|
41
|
+
* @param {string} str
|
|
42
|
+
* @returns {string} Hex hash
|
|
43
|
+
*/
|
|
44
|
+
export function sha256Hex(str) {
|
|
45
|
+
return crypto.createHash('sha256').update(str).digest('hex');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Verify state chain link: state.prev must equal SHA-256(JCS(prevState))
|
|
50
|
+
* @param {object} state - Current state
|
|
51
|
+
* @param {object} prevState - Previous state
|
|
52
|
+
* @returns {{valid: boolean, error?: string}}
|
|
53
|
+
*/
|
|
54
|
+
export function verifyStateLink(state, prevState) {
|
|
55
|
+
if (!state || !prevState) {
|
|
56
|
+
return { valid: false, error: 'Missing state or prevState' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const expectedPrev = sha256Hex(jcs(prevState));
|
|
60
|
+
if (state.prev !== expectedPrev) {
|
|
61
|
+
return { valid: false, error: `State chain break: expected prev ${expectedPrev}, got ${state.prev}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Verify sequence number
|
|
65
|
+
if (typeof state.seq === 'number' && typeof prevState.seq === 'number') {
|
|
66
|
+
if (state.seq !== prevState.seq + 1) {
|
|
67
|
+
return { valid: false, error: `Sequence mismatch: expected ${prevState.seq + 1}, got ${state.seq}` };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { valid: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Validate that a state object is a valid MRC20 state
|
|
76
|
+
* @param {object} state
|
|
77
|
+
* @returns {{valid: boolean, error?: string}}
|
|
78
|
+
*/
|
|
79
|
+
export function validateMrc20State(state) {
|
|
80
|
+
if (!state || typeof state !== 'object') {
|
|
81
|
+
return { valid: false, error: 'State must be an object' };
|
|
82
|
+
}
|
|
83
|
+
if (state.profile !== MRC20_PROFILE) {
|
|
84
|
+
return { valid: false, error: `Invalid profile: expected ${MRC20_PROFILE}, got ${state.profile}` };
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(state.ops)) {
|
|
87
|
+
return { valid: false, error: 'State must have ops array' };
|
|
88
|
+
}
|
|
89
|
+
if (typeof state.prev !== 'string') {
|
|
90
|
+
return { valid: false, error: 'State must have prev hash' };
|
|
91
|
+
}
|
|
92
|
+
return { valid: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extract transfer operations targeting a specific address
|
|
97
|
+
* @param {object} state - MRC20 state
|
|
98
|
+
* @param {string} toAddress - Recipient address to filter by
|
|
99
|
+
* @returns {Array<{from: string, to: string, amt: number}>} Matching transfers
|
|
100
|
+
*/
|
|
101
|
+
export function extractTransfersTo(state, toAddress) {
|
|
102
|
+
if (!state.ops || !Array.isArray(state.ops)) return [];
|
|
103
|
+
return state.ops.filter(op =>
|
|
104
|
+
op.op === TRANSFER_OP && op.to === toAddress && typeof op.amt === 'number' && op.amt > 0
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get total amount transferred to an address in a state
|
|
110
|
+
* @param {object} state - MRC20 state
|
|
111
|
+
* @param {string} toAddress - Recipient address
|
|
112
|
+
* @returns {number} Total amount transferred
|
|
113
|
+
*/
|
|
114
|
+
export function totalTransferredTo(state, toAddress) {
|
|
115
|
+
const transfers = extractTransfersTo(state, toAddress);
|
|
116
|
+
return transfers.reduce((sum, op) => sum + op.amt, 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Verify an MRC20 deposit: validate state chain + extract transfer amount
|
|
121
|
+
* @param {object} params
|
|
122
|
+
* @param {object} params.state - New state containing transfer ops
|
|
123
|
+
* @param {object} params.prevState - Previous state (for chain verification)
|
|
124
|
+
* @param {string} params.toAddress - Pod's address to check transfers against
|
|
125
|
+
* @returns {{valid: boolean, amount: number, ticker?: string, error?: string}}
|
|
126
|
+
*/
|
|
127
|
+
export function verifyMrc20Deposit(params) {
|
|
128
|
+
const { state, prevState, toAddress } = params;
|
|
129
|
+
|
|
130
|
+
// Validate MRC20 format
|
|
131
|
+
const stateCheck = validateMrc20State(state);
|
|
132
|
+
if (!stateCheck.valid) return { valid: false, amount: 0, error: stateCheck.error };
|
|
133
|
+
|
|
134
|
+
const prevCheck = validateMrc20State(prevState);
|
|
135
|
+
if (!prevCheck.valid) return { valid: false, amount: 0, error: `prevState: ${prevCheck.error}` };
|
|
136
|
+
|
|
137
|
+
// Verify state chain link
|
|
138
|
+
const linkCheck = verifyStateLink(state, prevState);
|
|
139
|
+
if (!linkCheck.valid) return { valid: false, amount: 0, error: linkCheck.error };
|
|
140
|
+
|
|
141
|
+
// Extract transfers to pod
|
|
142
|
+
const amount = totalTransferredTo(state, toAddress);
|
|
143
|
+
if (amount <= 0) {
|
|
144
|
+
return { valid: false, amount: 0, error: `No transfers to ${toAddress} found in state ops` };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
valid: true,
|
|
149
|
+
amount,
|
|
150
|
+
ticker: state.ticker || 'UNKNOWN'
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- BIP-341 key chaining (blocktrails anchor verification) ---
|
|
155
|
+
|
|
156
|
+
function sha256Bytes(data) {
|
|
157
|
+
const buf = data instanceof Uint8Array ? Buffer.from(data) : Buffer.from(data, 'utf8');
|
|
158
|
+
return new Uint8Array(crypto.createHash('sha256').update(buf).digest());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function concatBytes(...arrays) {
|
|
162
|
+
const total = arrays.reduce((s, a) => s + a.length, 0);
|
|
163
|
+
const result = new Uint8Array(total);
|
|
164
|
+
let off = 0;
|
|
165
|
+
for (const a of arrays) { result.set(a, off); off += a.length; }
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function bytesToBigInt(bytes) {
|
|
170
|
+
let r = 0n;
|
|
171
|
+
for (const b of bytes) r = (r << 8n) | BigInt(b);
|
|
172
|
+
return r;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function hexToU8(hex) {
|
|
176
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
177
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
178
|
+
return bytes;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function bytesToHex(bytes) {
|
|
182
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function taggedHash(tag, ...msgs) {
|
|
186
|
+
const tagHash = sha256Bytes(Buffer.from(tag, 'utf8'));
|
|
187
|
+
return sha256Bytes(concatBytes(tagHash, tagHash, ...msgs));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function btScalar(pubkeyCompressed, state) {
|
|
191
|
+
const xOnly = pubkeyCompressed.slice(1);
|
|
192
|
+
const stateBytes = typeof state === 'string' ? Buffer.from(state, 'utf8') : state;
|
|
193
|
+
const sh = sha256Bytes(stateBytes);
|
|
194
|
+
return bytesToBigInt(taggedHash('TapTweak', xOnly, sh)) % SECP_N;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function btDeriveChainedPubkey(pubkeyBase, states) {
|
|
198
|
+
let P = secp256k1.ProjectivePoint.fromHex(bytesToHex(pubkeyBase));
|
|
199
|
+
let cur = pubkeyBase;
|
|
200
|
+
for (const s of states) {
|
|
201
|
+
const t = btScalar(cur, s);
|
|
202
|
+
P = P.add(secp256k1.ProjectivePoint.BASE.multiply(t));
|
|
203
|
+
cur = new Uint8Array(P.toRawBytes(true));
|
|
204
|
+
}
|
|
205
|
+
return cur;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- Bech32m encoding ---
|
|
209
|
+
|
|
210
|
+
function convertBits(data, from, to, pad) {
|
|
211
|
+
let acc = 0, bits = 0;
|
|
212
|
+
const ret = [], maxv = (1 << to) - 1;
|
|
213
|
+
for (const v of data) {
|
|
214
|
+
acc = (acc << from) | v;
|
|
215
|
+
bits += from;
|
|
216
|
+
while (bits >= to) { bits -= to; ret.push((acc >> bits) & maxv); }
|
|
217
|
+
}
|
|
218
|
+
if (pad && bits > 0) ret.push((acc << (to - bits)) & maxv);
|
|
219
|
+
return ret;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function hrpExpand(hrp) {
|
|
223
|
+
const r = [];
|
|
224
|
+
for (const c of hrp) r.push(c.charCodeAt(0) >> 5);
|
|
225
|
+
r.push(0);
|
|
226
|
+
for (const c of hrp) r.push(c.charCodeAt(0) & 31);
|
|
227
|
+
return r;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function polymod(values) {
|
|
231
|
+
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
|
232
|
+
let chk = 1;
|
|
233
|
+
for (const v of values) {
|
|
234
|
+
const b = chk >> 25;
|
|
235
|
+
chk = ((chk & 0x1ffffff) << 5) ^ v;
|
|
236
|
+
for (let i = 0; i < 5; i++) if ((b >> i) & 1) chk ^= GEN[i];
|
|
237
|
+
}
|
|
238
|
+
return chk;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function bech32mEncode(hrp, version, program) {
|
|
242
|
+
const conv = convertBits(program, 8, 5, true);
|
|
243
|
+
const values = [version, ...conv];
|
|
244
|
+
const enc = [...hrpExpand(hrp), ...values, 0, 0, 0, 0, 0, 0];
|
|
245
|
+
const mod = polymod(enc) ^ BECH32M;
|
|
246
|
+
const checksum = [0, 1, 2, 3, 4, 5].map(i => (mod >> (5 * (5 - i))) & 31);
|
|
247
|
+
let result = hrp + '1';
|
|
248
|
+
for (const v of [...values, ...checksum]) result += BECH32_CHARSET[v];
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Derive the taproot address for a state chain
|
|
254
|
+
* @param {string} pubkeyHex - Issuer's compressed pubkey (66-char hex)
|
|
255
|
+
* @param {string[]} states - JCS-encoded state strings
|
|
256
|
+
* @param {string} [network='testnet4'] - 'testnet4' or 'mainnet'
|
|
257
|
+
* @returns {string} Bech32m taproot address
|
|
258
|
+
*/
|
|
259
|
+
export function btAddress(pubkeyHex, states, network = 'testnet4') {
|
|
260
|
+
const pubkeyBase = hexToU8(pubkeyHex);
|
|
261
|
+
const P = btDeriveChainedPubkey(pubkeyBase, states);
|
|
262
|
+
const xOnly = P.slice(1);
|
|
263
|
+
const hrp = network === 'mainnet' ? 'bc' : 'tb';
|
|
264
|
+
return bech32mEncode(hrp, 1, xOnly);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Verify that an MRC20 state is anchored to a Bitcoin UTXO
|
|
269
|
+
* @param {object} params
|
|
270
|
+
* @param {object} params.state - Current state with transfer ops
|
|
271
|
+
* @param {object} params.prevState - Previous state (chain verification)
|
|
272
|
+
* @param {string} params.toAddress - Pod's address for transfer verification
|
|
273
|
+
* @param {string} params.pubkey - Issuer's compressed pubkey (66-char hex)
|
|
274
|
+
* @param {string[]} params.stateStrings - All JCS state strings (genesis to current)
|
|
275
|
+
* @param {string} [params.mempoolUrl] - Mempool API base URL
|
|
276
|
+
* @param {string} [params.network] - 'testnet4' or 'mainnet'
|
|
277
|
+
* @returns {Promise<{valid: boolean, amount?: number, ticker?: string, address?: string, error?: string}>}
|
|
278
|
+
*/
|
|
279
|
+
export async function verifyMrc20Anchor(params) {
|
|
280
|
+
const {
|
|
281
|
+
state, prevState, toAddress, pubkey, stateStrings,
|
|
282
|
+
mempoolUrl = 'https://mempool.space/testnet4',
|
|
283
|
+
network = 'testnet4'
|
|
284
|
+
} = params;
|
|
285
|
+
|
|
286
|
+
// 1. Standard deposit verification (chain integrity + transfers)
|
|
287
|
+
const depositCheck = verifyMrc20Deposit({ state, prevState, toAddress });
|
|
288
|
+
if (!depositCheck.valid) return depositCheck;
|
|
289
|
+
|
|
290
|
+
// 2. Verify stateStrings is provided and non-empty
|
|
291
|
+
if (!Array.isArray(stateStrings) || stateStrings.length === 0) {
|
|
292
|
+
return { valid: false, amount: 0, error: 'stateStrings required for anchor verification' };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 3. Verify pubkey is provided
|
|
296
|
+
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 66) {
|
|
297
|
+
return { valid: false, amount: 0, error: 'pubkey must be a 66-char compressed pubkey hex' };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 4. Verify the last stateString matches the current state
|
|
301
|
+
const currentJcs = jcs(state);
|
|
302
|
+
const lastStateString = stateStrings[stateStrings.length - 1];
|
|
303
|
+
if (currentJcs !== lastStateString) {
|
|
304
|
+
return { valid: false, amount: 0, error: 'Last stateString does not match JCS(state)' };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 5. Derive expected taproot address
|
|
308
|
+
let address;
|
|
309
|
+
try {
|
|
310
|
+
address = btAddress(pubkey, stateStrings, network);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
return { valid: false, amount: 0, error: `Key derivation failed: ${err.message}` };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 6. Query mempool for UTXO at derived address
|
|
316
|
+
try {
|
|
317
|
+
const resp = await fetch(`${mempoolUrl}/api/address/${address}/utxo`);
|
|
318
|
+
if (!resp.ok) {
|
|
319
|
+
return { valid: false, amount: 0, error: `Mempool API error: ${resp.status}` };
|
|
320
|
+
}
|
|
321
|
+
const utxos = await resp.json();
|
|
322
|
+
if (!Array.isArray(utxos) || utxos.length === 0) {
|
|
323
|
+
return { valid: false, amount: 0, error: `No UTXO at derived address ${address}` };
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
return { valid: false, amount: 0, error: `Mempool lookup failed: ${err.message}` };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
valid: true,
|
|
331
|
+
amount: depositCheck.amount,
|
|
332
|
+
ticker: depositCheck.ticker,
|
|
333
|
+
address
|
|
334
|
+
};
|
|
335
|
+
}
|
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;
|
package/src/webledger.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Ledger — spec-compliant balance tracking
|
|
3
|
+
*
|
|
4
|
+
* Implements the Web Ledgers specification (https://webledgers.org/)
|
|
5
|
+
* for mapping URIs to numerical balances. Default unit: satoshi.
|
|
6
|
+
*
|
|
7
|
+
* JSON-LD context: https://w3id.org/webledgers
|
|
8
|
+
*
|
|
9
|
+
* @module webledger
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as storage from './storage/filesystem.js';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PATH = '/.well-known/webledgers/webledgers.json';
|
|
15
|
+
const CONTEXT = 'https://w3id.org/webledgers';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create an empty spec-compliant ledger
|
|
19
|
+
* @param {object} options
|
|
20
|
+
* @param {string} options.name - Ledger name
|
|
21
|
+
* @param {string} options.description - Ledger description
|
|
22
|
+
* @param {string} options.id - Ledger URI identifier
|
|
23
|
+
* @param {string} options.defaultCurrency - Default currency (default 'satoshi')
|
|
24
|
+
* @returns {object} Empty WebLedger object
|
|
25
|
+
*/
|
|
26
|
+
export function createLedger(options = {}) {
|
|
27
|
+
const now = Math.floor(Date.now() / 1000);
|
|
28
|
+
return {
|
|
29
|
+
'@context': CONTEXT,
|
|
30
|
+
type: 'WebLedger',
|
|
31
|
+
...(options.id && { id: options.id }),
|
|
32
|
+
name: options.name ?? 'Pod Credits',
|
|
33
|
+
description: options.description ?? 'Paid API balance ledger',
|
|
34
|
+
defaultCurrency: options.defaultCurrency ?? 'satoshi',
|
|
35
|
+
created: now,
|
|
36
|
+
updated: now,
|
|
37
|
+
entries: []
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read a webledger from storage
|
|
43
|
+
* @param {string} ledgerPath - URL path to ledger file
|
|
44
|
+
* @returns {Promise<object>} WebLedger object
|
|
45
|
+
*/
|
|
46
|
+
export async function readLedger(ledgerPath = DEFAULT_PATH) {
|
|
47
|
+
const buf = await storage.read(ledgerPath);
|
|
48
|
+
if (!buf) {
|
|
49
|
+
return createLedger();
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const ledger = JSON.parse(buf.toString('utf8'));
|
|
53
|
+
// Migrate legacy format: add missing spec fields
|
|
54
|
+
if (!ledger['@context']) ledger['@context'] = CONTEXT;
|
|
55
|
+
if (!ledger.type) ledger.type = 'WebLedger';
|
|
56
|
+
if (!ledger.defaultCurrency) ledger.defaultCurrency = 'satoshi';
|
|
57
|
+
if (!ledger.created) ledger.created = Math.floor(Date.now() / 1000);
|
|
58
|
+
if (!ledger.entries) ledger.entries = [];
|
|
59
|
+
return ledger;
|
|
60
|
+
} catch {
|
|
61
|
+
return createLedger();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Write a webledger to storage (updates the `updated` timestamp)
|
|
67
|
+
* @param {object} ledger - WebLedger object
|
|
68
|
+
* @param {string} ledgerPath - URL path to ledger file
|
|
69
|
+
* @returns {Promise<boolean>}
|
|
70
|
+
*/
|
|
71
|
+
export async function writeLedger(ledger, ledgerPath = DEFAULT_PATH) {
|
|
72
|
+
ledger.updated = Math.floor(Date.now() / 1000);
|
|
73
|
+
return storage.write(ledgerPath, JSON.stringify(ledger, null, 2));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get balance for a URI
|
|
78
|
+
* @param {object} ledger - WebLedger object
|
|
79
|
+
* @param {string} uri - Agent URI (e.g. did:nostr:...)
|
|
80
|
+
* @returns {number} Balance as integer
|
|
81
|
+
*/
|
|
82
|
+
export function getBalance(ledger, uri) {
|
|
83
|
+
const entry = ledger.entries.find(e => e.url === uri);
|
|
84
|
+
if (!entry) return 0;
|
|
85
|
+
// Handle both simple string and array amount formats
|
|
86
|
+
if (Array.isArray(entry.amount)) {
|
|
87
|
+
const sat = entry.amount.find(a => a.currency === 'satoshi' || a.currency === 'sat');
|
|
88
|
+
return sat ? parseInt(sat.value, 10) || 0 : 0;
|
|
89
|
+
}
|
|
90
|
+
return parseInt(entry.amount, 10) || 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Set balance for a URI
|
|
95
|
+
* @param {object} ledger - WebLedger object
|
|
96
|
+
* @param {string} uri - Agent URI
|
|
97
|
+
* @param {number} amount - New balance
|
|
98
|
+
*/
|
|
99
|
+
export function setBalance(ledger, uri, amount) {
|
|
100
|
+
const entry = ledger.entries.find(e => e.url === uri);
|
|
101
|
+
if (entry) {
|
|
102
|
+
entry.amount = String(amount);
|
|
103
|
+
} else {
|
|
104
|
+
ledger.entries.push({ type: 'Entry', url: uri, amount: String(amount) });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Credit (add to) a balance
|
|
110
|
+
* @param {object} ledger - WebLedger object
|
|
111
|
+
* @param {string} uri - Agent URI
|
|
112
|
+
* @param {number} amount - Amount to add
|
|
113
|
+
* @returns {number} New balance
|
|
114
|
+
*/
|
|
115
|
+
export function credit(ledger, uri, amount) {
|
|
116
|
+
const current = getBalance(ledger, uri);
|
|
117
|
+
const newBalance = current + amount;
|
|
118
|
+
setBalance(ledger, uri, newBalance);
|
|
119
|
+
return newBalance;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Debit (subtract from) a balance
|
|
124
|
+
* @param {object} ledger - WebLedger object
|
|
125
|
+
* @param {string} uri - Agent URI
|
|
126
|
+
* @param {number} amount - Amount to subtract
|
|
127
|
+
* @returns {{success: boolean, balance: number}} Result
|
|
128
|
+
*/
|
|
129
|
+
export function debit(ledger, uri, amount) {
|
|
130
|
+
const current = getBalance(ledger, uri);
|
|
131
|
+
if (current < amount) {
|
|
132
|
+
return { success: false, balance: current };
|
|
133
|
+
}
|
|
134
|
+
const newBalance = current - amount;
|
|
135
|
+
setBalance(ledger, uri, newBalance);
|
|
136
|
+
return { success: true, balance: newBalance };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* List all entries with non-zero balances
|
|
141
|
+
* @param {object} ledger - WebLedger object
|
|
142
|
+
* @returns {Array<{url: string, amount: number}>}
|
|
143
|
+
*/
|
|
144
|
+
export function listBalances(ledger) {
|
|
145
|
+
return ledger.entries
|
|
146
|
+
.map(e => ({ url: e.url, amount: getBalance(ledger, e.url) }))
|
|
147
|
+
.filter(e => e.amount > 0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Remove entries with zero balance
|
|
152
|
+
* @param {object} ledger - WebLedger object
|
|
153
|
+
*/
|
|
154
|
+
export function compact(ledger) {
|
|
155
|
+
ledger.entries = ledger.entries.filter(e => {
|
|
156
|
+
const bal = getBalance(ledger, e.url);
|
|
157
|
+
return bal > 0;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Re-export the default path for consumers
|
|
162
|
+
export { DEFAULT_PATH as LEDGER_PATH };
|
package/test/helpers.js
CHANGED
|
@@ -24,7 +24,7 @@ export async function startTestServer(options = {}) {
|
|
|
24
24
|
// Clean up any existing test data
|
|
25
25
|
await fs.emptyDir(TEST_DATA_DIR);
|
|
26
26
|
|
|
27
|
-
server = createServer({ logger: false, ...options });
|
|
27
|
+
server = createServer({ logger: false, forceCloseConnections: true, ...options });
|
|
28
28
|
// Use port 0 to let OS assign available port
|
|
29
29
|
await server.listen({ port: 0, host: '127.0.0.1' });
|
|
30
30
|
|
|
@@ -39,7 +39,6 @@ export async function startTestServer(options = {}) {
|
|
|
39
39
|
*/
|
|
40
40
|
export async function stopTestServer() {
|
|
41
41
|
if (server) {
|
|
42
|
-
// Force close all connections to avoid hanging
|
|
43
42
|
await server.close();
|
|
44
43
|
server = null;
|
|
45
44
|
}
|