Skip to content

Commit b594066

Browse files
ripple: product UI + WebID normalization
Replace the form-page UI with a product app shell (ripple/ui.js, still server-rendered, zero deps/build): sign-in + account creation against the host IdP (no pasted tokens), an overview dashboard (net position / spendable / receivable tiles, positions, humanized activity), a pay flow whose route previews live through the trust graph before committing, trustline management with usage bars and creditor actions, and a chain-verified activity feed. Verified end-to-end in a real browser (sign-in, route preview dave→bob→carol, payment, books). The browser run surfaced a real bug the tests missed: getAgent returns the /profile/card.jsonld#me document-form WebID while pods reference /profile/card#me — two spellings of one agent, which silently split the trust graph. normalizeAgent() now canonicalizes every id entry point (getAgent results and peer/to/agent params); README documents the finding (any identity-keyed plugin shares this edge; getAgent returning one canonical spelling is a seam candidate). +1 test; full suite 606 green.
1 parent ba2ca00 commit b594066

4 files changed

Lines changed: 583 additions & 101 deletions

File tree

ripple/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@ GET /ripple/api/log/verify recompute + check the chain
8888
settle, then remove. This keeps "you can always withdraw unused credit"
8989
and "you can never vaporize a debt record" simultaneously true.
9090

91+
- **WebID spelling splits the graph — normalize at every id entry point.**
92+
Found live, not in tests: `getAgent` returns the pod WebID in its
93+
`/profile/card.jsonld#me` *document* form, while pods conventionally
94+
reference `/profile/card#me` — the same agent as two different strings. An
95+
identity-keyed graph silently SPLITS on that: a line's debtor never equals
96+
the authenticated sender, and routing finds nothing. `normalizeAgent()`
97+
canonicalizes the `.jsonld` form down to the fragment form on getAgent
98+
results AND every peer/to/agent parameter. Any identity-keyed plugin
99+
(shortlink owners, capability issuers, …) has this same edge; arguably
100+
`getAgent` itself should return one canonical spelling — a small seam
101+
candidate.
102+
91103
- **State-file growth**: every transition rewrites `state.json` including the
92104
full log — the same O(n) append cost class as plugins#6 (relay). Fine for
93105
the MVP scale; an NDJSON append-log is the obvious fix when it matters.

ripple/plugin.js

Lines changed: 22 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
import fs from 'node:fs';
6161
import path from 'node:path';
6262
import 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

110111
const 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(/\/profile\/card\.jsonld#/, '/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

413427
const 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=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[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())+'&currency='+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-
}

ripple/test.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import assert from 'node:assert';
1616
import path from 'node:path';
1717
import { fileURLToPath } from 'node:url';
1818
import { probePort, startJss } from '../helpers.js';
19-
import { toMicro, fromMicro, pairKey, debtOf, capacityOf, findPath, entryHash } from './plugin.js';
19+
import { toMicro, fromMicro, pairKey, debtOf, capacityOf, findPath, entryHash, normalizeAgent } from './plugin.js';
2020

2121
const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url)));
2222
const PLUGIN = path.join(__dirname, 'plugin.js');
@@ -63,6 +63,24 @@ describe('ripple plugin (Fugger-classic trustlines)', () => {
6363
assert.ok(ALICE && BOB && CAROL, 'all three agents resolve');
6464
});
6565

66+
it('agent ids are normalized — card.jsonld#me and card#me are ONE agent', async () => {
67+
// The bug the browser run surfaced: getAgent returns the .jsonld document
68+
// form while pods conventionally reference card#me; unnormalized, a line's
69+
// debtor never matches the authenticated sender and the graph splits.
70+
assert.strictEqual(normalizeAgent('http://x/alice/profile/card.jsonld#me'),
71+
'http://x/alice/profile/card#me');
72+
assert.strictEqual(normalizeAgent('did:nostr:abc123'), 'did:nostr:abc123', 'non-WebIDs pass through');
73+
assert.ok(!ALICE.includes('.jsonld'), 'whoami serves the canonical fragment form');
74+
// A trustline created with the .jsonld PEER spelling must still route.
75+
const r = await post(`${api}/trustlines`, aliceTok,
76+
{ peer: BOB.replace('/profile/card#', '/profile/card.jsonld#'), currency: 'XNORM', limit: 5 });
77+
assert.strictEqual(r.status, 201);
78+
assert.strictEqual((await r.json()).trustline.debtor, BOB, 'peer input normalized to the canonical form');
79+
const p = await fetch(`${api}/path?from=${encodeURIComponent(BOB)}&to=${encodeURIComponent(ALICE)}&currency=XNORM&amount=5`);
80+
assert.strictEqual(p.status, 200, 'the two spellings route as one agent');
81+
await post(`${api}/trustlines/remove`, aliceTok, { peer: BOB, currency: 'XNORM' });
82+
});
83+
6684
// ---- pure model units ----------------------------------------------------
6785
it('micro-unit conversion round-trips and rejects float garbage', () => {
6886
assert.strictEqual(toMicro(0.1), 100000n);

0 commit comments

Comments
 (0)