6060import fs from 'node:fs' ;
6161import path from 'node:path' ;
6262import crypto from 'node:crypto' ;
63+ import { uiPage } from './ui.js' ;
6364
6465// ---------------------------------------------------------------- pure model
6566// Exported for unit tests: everything below operates on plain state
@@ -109,6 +110,19 @@ export function capacityOf(state, x, y, cur) {
109110
110111const MAX_HOPS = 8 ;
111112
113+ /**
114+ * One agent, one spelling. getAgent returns the pod WebID in its
115+ * `/profile/card.jsonld#me` document form, while pods conventionally
116+ * reference `/profile/card#me` — the same agent as two different strings,
117+ * which would silently SPLIT the trust graph (a line's debtor never matches
118+ * the authenticated sender). Canonicalize the .jsonld document form down to
119+ * the fragment form at every id entry point (getAgent results AND
120+ * peer/to/agent params). Non-WebID ids (did:nostr, …) pass through.
121+ */
122+ export const normalizeAgent = ( id ) => ( typeof id === 'string'
123+ ? id . replace ( / \/ p r o f i l e \/ c a r d \. j s o n l d # / , '/profile/card#' )
124+ : id ) ;
125+
112126/**
113127 * BFS shortest path from→to where EVERY hop carries amountMicro.
114128 * Neighbours of x are peers that extended x credit OR share a balance with x
@@ -226,7 +240,7 @@ export async function activate(api) {
226240 . send ( JSON . stringify ( obj , null , 2 ) ) ;
227241
228242 async function requireAgent ( request , reply ) {
229- const agent = await api . auth . getAgent ( request ) ;
243+ const agent = normalizeAgent ( await api . auth . getAgent ( request ) ) ;
230244 if ( ! agent ) { json ( reply , 401 , { error : 'authentication required' } ) ; return null ; }
231245 return agent ;
232246 }
@@ -239,7 +253,7 @@ export async function activate(api) {
239253
240254 // ------------------------------------------------------------------ whoami
241255 api . fastify . get ( `${ prefix } /api/whoami` , async ( request , reply ) => {
242- const agent = await api . auth . getAgent ( request ) ;
256+ const agent = normalizeAgent ( await api . auth . getAgent ( request ) ) ;
243257 return json ( reply , 200 , { agent } ) ;
244258 } ) ;
245259
@@ -263,7 +277,7 @@ export async function activate(api) {
263277
264278 // ---------------------------------------------------------------- balances
265279 api . fastify . get ( `${ prefix } /api/balances` , ( request , reply ) => {
266- const agent = request . query . agent ;
280+ const agent = normalizeAgent ( request . query . agent ) ;
267281 if ( ! agent ) return json ( reply , 400 , { error : 'agent query parameter required' } ) ;
268282 const positions = [ ] ;
269283 const net = { } ; // currency → micro
@@ -290,7 +304,7 @@ export async function activate(api) {
290304 api . fastify . post ( `${ prefix } /api/trustlines` , async ( request , reply ) => {
291305 const agent = await requireAgent ( request , reply ) ; if ( ! agent ) return reply ;
292306 const body = request . body || { } ;
293- const peer = typeof body . peer === 'string' ? body . peer : null ;
307+ const peer = typeof body . peer === 'string' ? normalizeAgent ( body . peer ) : null ;
294308 const currency = parseCurrency ( body . currency ) ;
295309 const limit = body . limit === 0 ? 0n : toMicro ( body . limit ) ;
296310 if ( ! peer || peer . includes ( SEP ) ) return json ( reply , 400 , { error : 'peer (agent id) required' } ) ;
@@ -311,7 +325,7 @@ export async function activate(api) {
311325 api . fastify . post ( `${ prefix } /api/trustlines/remove` , async ( request , reply ) => {
312326 const agent = await requireAgent ( request , reply ) ; if ( ! agent ) return reply ;
313327 const body = request . body || { } ;
314- const peer = typeof body . peer === 'string' ? body . peer : null ;
328+ const peer = typeof body . peer === 'string' ? normalizeAgent ( body . peer ) : null ;
315329 const currency = parseCurrency ( body . currency ) ;
316330 if ( ! peer || ! currency ) return json ( reply , 400 , { error : 'peer and currency required' } ) ;
317331 const k = lineKey ( agent , peer , currency ) ;
@@ -331,7 +345,7 @@ export async function activate(api) {
331345 const currency = parseCurrency ( q . currency ) ;
332346 const amount = toMicro ( Number ( q . amount ) ) ;
333347 if ( ! q . from || ! q . to || ! currency || amount === null ) return null ;
334- return { from : q . from , to : q . to , currency, amount } ;
348+ return { from : normalizeAgent ( q . from ) , to : normalizeAgent ( q . to ) , currency, amount } ;
335349 }
336350 api . fastify . get ( `${ prefix } /api/path` , ( request , reply ) => {
337351 const q = pathQuery ( request . query ) ;
@@ -345,7 +359,7 @@ export async function activate(api) {
345359 api . fastify . post ( `${ prefix } /api/payments` , async ( request , reply ) => {
346360 const agent = await requireAgent ( request , reply ) ; if ( ! agent ) return reply ;
347361 const body = request . body || { } ;
348- const to = typeof body . to === 'string' ? body . to : null ;
362+ const to = typeof body . to === 'string' ? normalizeAgent ( body . to ) : null ;
349363 const currency = parseCurrency ( body . currency ) ;
350364 const amount = toMicro ( body . amount ) ;
351365 if ( ! to || to === agent ) return json ( reply , 400 , { error : 'to (another agent id) required' } ) ;
@@ -365,7 +379,7 @@ export async function activate(api) {
365379 api . fastify . post ( `${ prefix } /api/settle` , async ( request , reply ) => {
366380 const agent = await requireAgent ( request , reply ) ; if ( ! agent ) return reply ;
367381 const body = request . body || { } ;
368- const peer = typeof body . peer === 'string' ? body . peer : null ;
382+ const peer = typeof body . peer === 'string' ? normalizeAgent ( body . peer ) : null ;
369383 const currency = parseCurrency ( body . currency ) ;
370384 const amount = toMicro ( body . amount ) ;
371385 if ( ! peer || ! currency || amount === null ) {
@@ -412,95 +426,3 @@ export async function activate(api) {
412426
413427const bigMax = ( a , b ) => ( a > b ? a : b ) ;
414428
415- // --------------------------------------------------------------------- UI page
416- // Server-rendered shell + fetch against the JSON api. Auth = a pasted pod
417- // bearer kept in localStorage (the forge-style login widget is a later wave).
418- function uiPage ( prefix ) {
419- return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
420- <title>Ripple — trustlines</title>
421- <style>
422- :root{--bg:#f6f7f5;--card:#fff;--ink:#1c2422;--soft:#5b6a66;--line:#d8ded9;--acc:#2c6e5a;--warn:#9a6417;--bad:#a6362f;
423- font-size:15px}
424- @media (prefers-color-scheme:dark){:root{--bg:#101614;--card:#182019;--ink:#e6ebe7;--soft:#93a29b;--line:#2b3630;--acc:#5bbe9c;--warn:#d69a4a;--bad:#e2776e}}
425- *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,sans-serif}
426- .wrap{max-width:880px;margin:0 auto;padding:24px 16px 80px}
427- h1{font-family:ui-monospace,monospace;font-size:1.35rem;margin:.2rem 0 .3rem}
428- h2{font-size:1rem;margin:0 0 10px}
429- p.sub{color:var(--soft);margin:0 0 20px;font-size:.92rem}
430- .card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin:0 0 16px}
431- label{display:block;font-size:.75rem;text-transform:uppercase;letter-spacing:.06em;color:var(--soft);margin:8px 0 4px}
432- input,select{width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--ink);font:13px ui-monospace,monospace}
433- button{margin-top:10px;padding:8px 16px;border:1px solid var(--acc);border-radius:7px;background:var(--acc);color:#fff;font:600 13px ui-monospace,monospace;cursor:pointer}
434- button.ghost{background:transparent;color:var(--acc)}
435- table{width:100%;border-collapse:collapse;font:12.5px ui-monospace,monospace}
436- td,th{text-align:left;padding:6px 8px;border-top:1px solid var(--line);word-break:break-all}
437- th{color:var(--soft);font-weight:600;font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;border-top:0}
438- .msg{font:12.5px ui-monospace,monospace;margin-top:10px;color:var(--soft);word-break:break-all}
439- .msg.ok{color:var(--acc)}.msg.bad{color:var(--bad)}
440- .who{font:12.5px ui-monospace,monospace;color:var(--acc);word-break:break-all}
441- .grid{display:grid;gap:16px}@media(min-width:720px){.grid{grid-template-columns:1fr 1fr}}
442- </style>
443- <div class="wrap">
444- <h1>Ripple <span style="color:var(--soft);font-weight:400">· trustlines</span></h1>
445- <p class="sub">Fugger-classic mutual credit: extend trust, pay through chains of it, settle out of band.
446- Payments route automatically through credit already granted — that's the whole trick.</p>
447-
448- <div class="card"><h2>Identity</h2>
449- <label for="tok">Pod bearer token (POST /idp/credentials)</label>
450- <input id="tok" placeholder="paste access_token"><button id="save">Use token</button>
451- <div class="who" id="who">anonymous</div></div>
452-
453- <div class="grid">
454- <div class="card"><h2>Extend trust</h2>
455- <label for="tpeer">Peer (WebID / did)</label><input id="tpeer">
456- <label for="tcur">Currency</label><input id="tcur" value="USD">
457- <label for="tlim">Limit (0 = freeze)</label><input id="tlim" value="100">
458- <button id="btrust">Set trustline</button><div class="msg" id="mtrust"></div></div>
459-
460- <div class="card"><h2>Pay</h2>
461- <label for="pto">To</label><input id="pto">
462- <label for="pcur">Currency</label><input id="pcur" value="USD">
463- <label for="pamt">Amount</label><input id="pamt" value="10">
464- <button class="ghost" id="bpath">Find route</button> <button id="bpay">Send payment</button>
465- <div class="msg" id="mpay"></div></div>
466- </div>
467-
468- <div class="card"><h2>Settle (record repayment received)</h2>
469- <label for="speer">Peer who paid you back</label><input id="speer">
470- <label for="scur">Currency</label><input id="scur" value="USD">
471- <label for="samt">Amount</label><input id="samt" value="10">
472- <button id="bsettle">Record settlement</button><div class="msg" id="msettle"></div></div>
473-
474- <div class="card"><h2>Trust graph</h2><div id="graph">loading…</div></div>
475- <div class="card"><h2>Transition log <span id="chain" style="color:var(--soft);font-weight:400"></span></h2><div id="log"></div></div>
476- </div>
477- <script>
478- "use strict";
479- const P=${ JSON . stringify ( prefix ) } ;
480- const $=id=>document.getElementById(id);
481- const tok=()=>localStorage.getItem('rippleToken')||'';
482- const hdrs=()=>tok()?{authorization:'Bearer '+tok(),'content-type':'application/json'}:{'content-type':'application/json'};
483- const api=(p,opt)=>fetch(P+'/api'+p,opt).then(async r=>({ok:r.ok,status:r.status,body:await r.json().catch(()=>({}))}));
484- const msg=(id,r,okText)=>{const e=$(id);e.className='msg '+(r.ok?'ok':'bad');e.textContent=r.ok?okText:(r.body.error||('error '+r.status))};
485- async function who(){const r=await api('/whoami',{headers:hdrs()});$('who').textContent=r.body.agent||'anonymous';}
486- async function graph(){const r=await api('/graph');const g=r.body;
487- const tl=g.trustlines.map(l=>'<tr><td>'+esc(l.creditor)+'</td><td>'+esc(l.debtor)+'</td><td>'+l.currency+'</td><td>'+l.limit+'</td><td>'+l.debt+'</td><td>'+l.available+'</td></tr>').join('');
488- const bl=g.balances.map(b=>'<tr><td>'+esc(b.debtor)+'</td><td>owes</td><td>'+esc(b.creditor)+'</td><td>'+b.amount+' '+b.currency+'</td></tr>').join('');
489- $('graph').innerHTML='<table><tr><th>Creditor</th><th>Debtor</th><th>Cur</th><th>Limit</th><th>Debt</th><th>Avail</th></tr>'+(tl||'<tr><td colspan=6 style="color:var(--soft)">no trustlines yet</td></tr>')+'</table>'
490- +(bl?'<h2 style="margin-top:14px">IOUs</h2><table>'+bl+'</table>':'');}
491- async function log(){const r=await api('/log?limit=15');const v=await api('/log/verify');
492- $('chain').textContent='· seq '+r.body.seq+' · chain '+(v.body.valid?'✓ valid':'✗ BROKEN');
493- $('log').innerHTML='<table><tr><th>#</th><th>Actor</th><th>Type</th><th>Params</th></tr>'+r.body.entries.slice().reverse().map(e=>'<tr><td>'+e.seq+'</td><td>'+esc(short(e.actor))+'</td><td>'+e.type+'</td><td>'+esc(JSON.stringify(e.params))+'</td></tr>').join('')+'</table>';}
494- const esc=s=>String(s??'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));
495- const short=s=>s&&s.length>42?s.slice(0,20)+'…'+s.slice(-16):s;
496- const refresh=()=>{who();graph();log();};
497- $('save').onclick=()=>{localStorage.setItem('rippleToken',$('tok').value.trim());refresh();};
498- $('btrust').onclick=async()=>{const r=await api('/trustlines',{method:'POST',headers:hdrs(),body:JSON.stringify({peer:$('tpeer').value.trim(),currency:$('tcur').value.trim(),limit:Number($('tlim').value)})});msg('mtrust',r,'trustline set (seq '+(r.body.entry||{}).seq+')');refresh();};
499- $('bpath').onclick=async()=>{const meR=await api('/whoami',{headers:hdrs()});const from=meR.body.agent;if(!from){msg('mpay',{ok:false,body:{error:'set a token first'}});return}
500- const r=await api('/path?from='+encodeURIComponent(from)+'&to='+encodeURIComponent($('pto').value.trim())+'¤cy='+encodeURIComponent($('pcur').value.trim())+'&amount='+encodeURIComponent($('pamt').value));
501- msg('mpay',r,r.ok?('route: '+r.body.path.map(short).join(' → ')):'');};
502- $('bpay').onclick=async()=>{const r=await api('/payments',{method:'POST',headers:hdrs(),body:JSON.stringify({to:$('pto').value.trim(),currency:$('pcur').value.trim(),amount:Number($('pamt').value)})});msg('mpay',r,r.ok?('paid via '+r.body.payment.path.length+' node path'):'');refresh();};
503- $('bsettle').onclick=async()=>{const r=await api('/settle',{method:'POST',headers:hdrs(),body:JSON.stringify({peer:$('speer').value.trim(),currency:$('scur').value.trim(),amount:Number($('samt').value)})});msg('msettle',r,r.ok?('settled — remaining '+r.body.settled.remaining):'');refresh();};
504- $('tok').value=tok();refresh();setInterval(()=>{graph();log();},5000);
505- </script>` ;
506- }
0 commit comments