@paydlux/agent 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -44,9 +44,35 @@ stellar/onboard.ts keys → funded vault → printed agent key
44
44
  ```bash
45
45
  npm i
46
46
  npx tsx stellar/demo.ts # 17 checks, live testnet
47
- npx tsx stellar/story.ts --public --split # unguessable link; approve on your phone
47
+ npx tsx stellar/story.ts --tls --split # https://desk.paydlux.com; approve on your phone
48
48
  ```
49
49
 
50
+ `--tls` is the one to use for anything real. It runs a named Cloudflare tunnel
51
+ so the desk is served over `https://desk.paydlux.com` with a real certificate,
52
+ instead of a random `trycloudflare.com` hostname (`--public`) that nothing tells
53
+ the human is *your* desk. Setup is one-time and free:
54
+
55
+ ```bash
56
+ cloudflared tunnel login
57
+ cloudflared tunnel create paydlux-desk
58
+ cloudflared tunnel route dns paydlux-desk desk.paydlux.com
59
+ ```
60
+
61
+ Then `~/.cloudflared/config.yml` points the hostname at `http://127.0.0.1:4022`.
62
+ The tunnel's credentials file stays in `~/.cloudflared/` and must never be
63
+ committed: whoever holds it can serve your hostname.
64
+
65
+ What the desk enforces once it is public (`npx tsx stellar/tls-check.ts`, 19 checks):
66
+
67
+ - plain `http://` is 301'd to `https://` and never served, with HSTS set
68
+ - the desk listens on `127.0.0.1` only, and refuses any `Host` that is not
69
+ `desk.paydlux.com` — so the local port cannot be driven directly to skip TLS
70
+ - a desk configured with a public hostname **refuses to start** without a portal
71
+ key, rather than coming up as an open approval button
72
+ - the portal key also rides in an `HttpOnly; SameSite=Strict; Secure` cookie, so
73
+ the approver app can load its own scripts (a `<script src>` cannot inherit
74
+ `?k=` from the page URL) without the key ever appearing in the DOM
75
+
50
76
  No wallet, no faucet, no config.
51
77
 
52
78
  ### The human key does not live on the seller's machine
package/dist/desk.d.ts CHANGED
@@ -38,6 +38,15 @@ export type DeskConfig = {
38
38
  /** If set, the portal and its actions require this key in the URL.
39
39
  * Required before putting the desk on any public URL. */
40
40
  portalKey?: string;
41
+ /**
42
+ * The hostname this desk is served on, e.g. "desk.paydlux.com". When set, the
43
+ * desk answers ONLY requests for that Host. A named Cloudflare tunnel gives
44
+ * real TLS on your own domain, but the local port it forwards to is still
45
+ * plain HTTP: if anything on this machine (or network) reached 127.0.0.1:4022
46
+ * directly it would bypass TLS and the HSTS/cookie protections the domain
47
+ * gives you. Pinning the Host means the only way in is through the tunnel.
48
+ */
49
+ publicHost?: string;
41
50
  };
42
51
  export declare class ApprovalDesk {
43
52
  private records;
package/dist/desk.js CHANGED
@@ -23,25 +23,34 @@ import { createApprovalChallenge, submitApproved, challengeQrPayload, } from "./
23
23
  * The approver app is plain static files under phone-app/. Read on each request
24
24
  * rather than bundled, so the desk and the app can never drift apart.
25
25
  */
26
- const APP_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "phone-app");
27
- const appCache = new Map();
28
- function readAppFile(name) {
26
+ const HERE = dirname(fileURLToPath(import.meta.url));
27
+ const APP_DIR = join(HERE, "..", "phone-app");
28
+ const PORTAL_DIR = join(HERE, "..", "portal-app");
29
+ const staticCache = new Map();
30
+ /**
31
+ * Static files are read on each process start rather than bundled, so the desk
32
+ * and its front ends can never drift apart. The name is stripped to [a-z.]
33
+ * before it touches a path, so a request can't walk out of the directory.
34
+ */
35
+ function readStatic(dir, name, label) {
29
36
  const safe = name.replace(/[^a-z.]/g, "");
30
- const hit = appCache.get(safe);
37
+ const hit = staticCache.get(dir + "/" + safe);
31
38
  if (hit)
32
39
  return hit;
33
40
  let text;
34
41
  try {
35
- text = readFileSync(join(APP_DIR, safe), "utf8");
42
+ text = readFileSync(join(dir, safe), "utf8");
36
43
  }
37
44
  catch {
38
- // A package install that shipped without phone-app/ should say so instead of
45
+ // A package install that shipped without the folder should say so instead of
39
46
  // throwing out of the request handler.
40
- throw new Error("approver app is missing from this install (expected " + join(APP_DIR, safe) + ")");
47
+ throw new Error(label + " is missing from this install (expected " + join(dir, safe) + ")");
41
48
  }
42
- appCache.set(safe, text);
49
+ staticCache.set(dir + "/" + safe, text);
43
50
  return text;
44
51
  }
52
+ const readAppFile = (name) => readStatic(APP_DIR, name, "approver app");
53
+ const readPortalFile = (name) => readStatic(PORTAL_DIR, name, "portal app");
45
54
  /** A portal link is a capability: whoever holds it can approve. So it is long,
46
55
  * random, and compared without leaking timing. */
47
56
  export function newPortalKey() { return randomBytes(16).toString("base64url"); }
@@ -97,6 +106,20 @@ function readBody(req) {
97
106
  req.on("error", reject);
98
107
  });
99
108
  }
109
+ /** Read one cookie out of the request header. No dependency for this. */
110
+ function cookieOf(req, name) {
111
+ const raw = req.headers.cookie;
112
+ if (!raw)
113
+ return null;
114
+ for (const part of raw.split(";")) {
115
+ const eq = part.indexOf("=");
116
+ if (eq < 0)
117
+ continue;
118
+ if (part.slice(0, eq).trim() === name)
119
+ return decodeURIComponent(part.slice(eq + 1).trim());
120
+ }
121
+ return null;
122
+ }
100
123
  function same(a, b) {
101
124
  const x = Buffer.from(a), y = Buffer.from(b);
102
125
  return x.length === y.length && timingSafeEqual(x, y);
@@ -230,16 +253,83 @@ export class ApprovalDesk {
230
253
  /** Serve the pending list as JSON (the portal polls this). */
231
254
  api(handler) { return handler; }
232
255
  listen(port = this.cfg.port) {
256
+ // A desk reachable from outside this machine must be gated. This is a
257
+ // startup refusal rather than a runtime one because the dangerous case is
258
+ // forgetting a flag: a public desk with no portal key is an open approval
259
+ // button, and it would otherwise start up looking completely healthy.
260
+ if (this.cfg.publicHost && !this.cfg.portalKey) {
261
+ throw new Error("a desk served on " + this.cfg.publicHost + " needs a portalKey; " +
262
+ "refusing to start an ungated approval desk");
263
+ }
233
264
  const srv = createServer(async (req, res) => {
234
265
  // (server handle kept below so close() can stop it)
235
266
  const url = new URL(req.url ?? "/", "http://localhost");
267
+ // Order matters. The Host pin runs first so a request that did not come
268
+ // through the tunnel is refused before the desk reveals anything about
269
+ // itself, including whether a portal key looks correct.
270
+ if (this.cfg.publicHost) {
271
+ const want = this.cfg.publicHost.toLowerCase();
272
+ const got = String(req.headers.host ?? "").toLowerCase().replace(/:\d+$/, "");
273
+ if (got !== want) {
274
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
275
+ res.end("403 — this desk only answers on " + want + ".");
276
+ return;
277
+ }
278
+ // Cloudflare terminates TLS and forwards both http and https to us, so
279
+ // http://desk.paydlux.com answers too unless we send it away. A human
280
+ // approving a payment over plain HTTP is handing the portal key and the
281
+ // transaction to anyone on the path, so plain HTTP gets a redirect and
282
+ // nothing else. x-forwarded-proto is set by Cloudflare, not the client.
283
+ if (String(req.headers["x-forwarded-proto"] ?? "").split(",")[0].trim() === "http") {
284
+ res.writeHead(301, { location: "https://" + want + (req.url ?? "/") });
285
+ res.end();
286
+ return;
287
+ }
288
+ // Once HTTPS is established, tell the browser to never try plain HTTP
289
+ // again for this host.
290
+ res.setHeader("strict-transport-security", "max-age=31536000; includeSubDomains");
291
+ }
292
+ // Security headers on every response, including the 403. These are set
293
+ // before routing so no branch can forget them.
294
+ //
295
+ // default-src 'self' plus script-src 'self' is the load-bearing one: the
296
+ // portal renders text the buyer's agent chose (its request path and body),
297
+ // so if an escaping bug ever let markup through, CSP still stops it from
298
+ // running or phoning home. Escaping is the fix; CSP is the net under it.
299
+ // frame-ancestors stops another site framing the Approve button, and
300
+ // no-referrer keeps the portal key out of any third-party Referer header.
301
+ res.setHeader("content-security-policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; " +
302
+ "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'");
303
+ res.setHeader("x-frame-options", "DENY");
304
+ res.setHeader("referrer-policy", "no-referrer");
305
+ res.setHeader("x-content-type-options", "nosniff");
306
+ res.setHeader("cache-control", "no-store");
307
+ // Liveness probe, before the portal-key gate: it proves the desk and its
308
+ // TLS path are up without revealing anything. No data, safe to poll.
309
+ if (url.pathname === "/healthz") {
310
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
311
+ res.end("ok");
312
+ return;
313
+ }
236
314
  // A public demo link must not be an open approval button. If a portalKey
237
315
  // is configured, every route needs it, or the desk answers 403.
238
- if (this.cfg.portalKey && !same(url.searchParams.get("k") ?? "", this.cfg.portalKey)) {
316
+ //
317
+ // The key may arrive in the URL (the link the human opened) or in a
318
+ // cookie the desk set when that link was first validated. The cookie is
319
+ // what makes the approver app work: its <script src="xdr.js"> cannot
320
+ // inherit ?k= from the page URL, so without a cookie the app 403s on its
321
+ // own code and shows a blank form. HttpOnly + SameSite=Strict + Secure
322
+ // keeps it out of the DOM and off any cross-site request.
323
+ const presented = url.searchParams.get("k") ?? cookieOf(req, "pd_portal");
324
+ if (this.cfg.portalKey && !same(presented ?? "", this.cfg.portalKey)) {
239
325
  res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
240
326
  res.end("403 — this approval link was never issued or has been rotated.");
241
327
  return;
242
328
  }
329
+ if (this.cfg.portalKey && url.searchParams.get("k") !== null) {
330
+ res.setHeader("set-cookie", `pd_portal=${this.cfg.portalKey}; Path=/; HttpOnly; SameSite=Strict; ` +
331
+ `Secure; Max-Age=${12 * 3600}`);
332
+ }
243
333
  const qs = url.search;
244
334
  const send = (code, obj) => {
245
335
  res.writeHead(code, { "content-type": "application/json" });
@@ -251,6 +341,18 @@ export class ApprovalDesk {
251
341
  res.end(portalPage(this.list(), this.cfg.vault.address, this.cfg.vault.network, qs));
252
342
  return;
253
343
  }
344
+ // The portal's own css and js. Served as separate files, never inline,
345
+ // so script-src 'self' and style-src 'self' actually hold on the
346
+ // approval page and an injected string can't become executable script.
347
+ if (url.pathname === "/portal.js" || url.pathname === "/portal.css") {
348
+ const file = url.pathname.slice(1);
349
+ res.writeHead(200, {
350
+ "content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
351
+ "x-content-type-options": "nosniff",
352
+ });
353
+ res.end(readPortalFile(file));
354
+ return;
355
+ }
254
356
  if (url.pathname === "/list")
255
357
  return send(200, this.list());
256
358
  // The approver app. Served from the desk so the human opens one link on
@@ -332,7 +434,10 @@ export class ApprovalDesk {
332
434
  }
333
435
  });
334
436
  this.server = srv;
335
- return new Promise((r) => srv.listen(port, () => r()));
437
+ // Loopback only. The public path is the Cloudflare tunnel, which connects
438
+ // back to 127.0.0.1; binding to 0.0.0.0 would expose a plain-HTTP desk to
439
+ // the LAN and defeat both the TLS and the Host pin above.
440
+ return new Promise((r) => srv.listen(port, "127.0.0.1", () => r()));
336
441
  }
337
442
  /** Stop serving. Without this the process never exits. */
338
443
  close() {
package/dist/gateway.d.ts CHANGED
@@ -30,6 +30,8 @@ export type GatewayConfig = {
30
30
  deskPort?: number;
31
31
  /** gate the approval portal behind a single unguessable link */
32
32
  portalKey?: string;
33
+ /** hostname the desk is publicly served on; pins the Host header. See DeskConfig. */
34
+ publicHost?: string;
33
35
  };
34
36
  export type PaymentProof = {
35
37
  /** pre-authorised envelope, base64 XDR, as issued by the vault */
package/dist/gateway.js CHANGED
@@ -36,6 +36,7 @@ export class Gateway {
36
36
  port: cfg.deskPort ?? 4022,
37
37
  portalKey: cfg.portalKey,
38
38
  split: cfg.split,
39
+ publicHost: cfg.publicHost,
39
40
  });
40
41
  }
41
42
  /** Mandates the vault currently holds, so the gateway can hand them out. */
package/dist/portal.d.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { DeskRecord } from "./desk.ts";
3
+ /**
4
+ * HTML escaping. Not optional: the request path and body are attacker-chosen
5
+ * text that lands inside the one page that can authorise a payment.
6
+ */
7
+ export declare function esc(v: unknown): string;
3
8
  export declare function portalPage(records: DeskRecord[], vault: string, network: string, qs?: string): string;
4
9
  export declare function servePortal(req: IncomingMessage, res: ServerResponse, html: string): void;
package/dist/portal.js CHANGED
@@ -1,3 +1,15 @@
1
+ /**
2
+ * HTML escaping. Not optional: the request path and body are attacker-chosen
3
+ * text that lands inside the one page that can authorise a payment.
4
+ */
5
+ export function esc(v) {
6
+ return String(v ?? "")
7
+ .replace(/&/g, "&amp;")
8
+ .replace(/</g, "&lt;")
9
+ .replace(/>/g, "&gt;")
10
+ .replace(/"/g, "&quot;")
11
+ .replace(/'/g, "&#39;");
12
+ }
1
13
  export function portalPage(records, vault, network, qs = "") {
2
14
  const rows = records.length
3
15
  ? records.map((r) => render(r, qs)).join("\n")
@@ -5,72 +17,40 @@ export function portalPage(records, vault, network, qs = "") {
5
17
  return `<!doctype html><html><head>
6
18
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
7
19
  <title>Paydlux — approvals</title>
8
- <style>
9
- :root{--ink:#0d0f0e;--line:#262a28;--text:#eceae4;--muted:#9aa39d;--gold:#e8b04b;--green:#5fbf8b}
10
- *{box-sizing:border-box}
11
- body{margin:0;background:var(--ink);color:var(--text);font:16px/1.5 -apple-system,"Avenir Next",system-ui,sans-serif;padding:24px}
12
- h1{font-size:20px;margin:0 0 4px}
13
- .sub{color:var(--muted);font-size:13px;margin-bottom:24px;word-break:break-all}
14
- table{width:100%;border-collapse:collapse}
15
- th{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.06em;text-align:left;padding:8px;border-bottom:1px solid var(--line)}
16
- td{padding:14px 8px;border-bottom:1px solid var(--line);vertical-align:middle}
17
- .amt{font-weight:700;color:var(--gold);white-space:nowrap}
18
- .uri{font-family:ui-monospace,monospace;font-size:13px}
19
- .body{color:var(--muted);font-size:12px;max-width:340px;word-break:break-word}
20
- button{border:0;border-radius:10px;padding:12px 18px;font-size:15px;font-weight:600;cursor:pointer;display:block;width:100%;margin:4px 0}
21
- .yes{background:var(--green);color:#08110c}
22
- .no{background:transparent;color:var(--muted);border:1px solid var(--line)}
23
- .st{font-size:12px;text-transform:uppercase;letter-spacing:.06em}
24
- .settled{color:var(--green)} .denied,.failed{color:#e07a6b} .pending{color:var(--gold)}
25
- .tx{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted);word-break:break-all}
26
- .qr{font-size:10px;white-space:pre-wrap;background:#141715;padding:8px;border-radius:8px;color:var(--text)}
27
- details summary{cursor:pointer}
28
- .phlink{color:var(--gold);text-decoration:underline;font-size:12px}
29
- .foot{margin-top:28px;color:var(--muted);font-size:12px}
30
- @media(max-width:640px){.body{max-width:180px}}
31
- </style></head><body>
20
+ <link rel="stylesheet" href="portal.css"></head><body>
32
21
  <h1>Paydlux — approvals</h1>
33
- <div class="sub">vault ${vault} · ${network} · refreshed <span id="t">now</span></div>
22
+ <div class="sub">vault ${esc(vault)} · ${esc(network)} · refreshed <span id="t">now</span></div>
34
23
  <table><thead><tr><th>when</th><th>what</th><th>cost</th><th>status</th><th></th></tr></thead>
35
24
  <tbody>${rows}</tbody></table>
36
25
  <div class="foot">Approving signs a Stellar transaction with your key. The vault needs two signatures, so this device alone cannot release funds.</div>
37
- <script>
38
- const Q=${JSON.stringify(qs)};
39
- async function act(kind,id){await fetch('/'+kind+'/'+id+Q,{method:'POST'});load()}
40
- async function load(){const r=await fetch('/list'+Q);const d=await r.json();
41
- document.querySelector('tbody').innerHTML=d.map(row).join('')||'<tr><td colspan="5" class="empty">nothing waiting</td></tr>';
42
- document.getElementById('t').textContent=new Date().toLocaleTimeString()}
43
- function row(x){const st='<span class="st '+x.status+'">'+x.status+'</span>'+(x.tx?'<div class="tx">'+x.tx+'</div>':'')+(x.reason?'<div class="tx">'+x.reason+'</div>':'');
44
- const btns=x.status==='pending'?'<button class="yes" onclick="act(\'approve\',\''+x.id+'\')">Approve</button><button class="no" onclick="act(\'deny\',\''+x.id+'\')">Deny</button>':'';
45
- const ph=x.status==='awaiting_phone'?'<div class="tx">awaiting your phone</div>':'';
46
- return '<tr><td>'+new Date(x.at).toLocaleTimeString()+'</td><td><div class="uri">'+x.uri+'</div><div class="body">'+(x.body||'')+'</div></td><td class="amt">'+x.price+'</td><td>'+st+ph+'</td><td style="min-width:130px">'+btns+'</td></tr>'}
47
- setInterval(load,3000);
48
- </script></body></html>`;
26
+ <script src="portal.js"></script></body></html>`;
49
27
  }
50
28
  function render(r, qs = "") {
51
29
  // qs is the portal key query string ("?k=..."). The approver link has to carry
52
30
  // it, because the desk answers 403 for anything without it.
53
31
  const link = `phone/?id=${encodeURIComponent(r.id)}${qs.replace("?", "&")}`;
32
+ // The id is desk-generated (pay_NNNN) but it still lands in an attribute, so it
33
+ // is escaped like everything else. Buttons are wired by portal.js through
34
+ // data-*, not an inline onclick, so the page needs no 'unsafe-inline'.
54
35
  const btns = r.status === "pending"
55
- ? `<button class="yes" onclick="act('approve','${r.id}')">Approve</button>
56
- <button class="no" onclick="act('deny','${r.id}')">Deny</button>`
36
+ ? `<button class="yes" data-act="approve" data-id="${esc(r.id)}">Approve</button>
37
+ <button class="no" data-act="deny" data-id="${esc(r.id)}">Deny</button>`
57
38
  : "";
58
39
  // Split mode: the desk has no approver key, so approving hands the payment to
59
- // a phone. Show the human exactly what the phone will be asked to sign.
60
- // Split mode: the desk has no approver key, so approving hands the payment to
61
40
  // a phone. Show the human exactly what the phone will be asked to sign, and
62
41
  // give the approver app a link so nobody pastes base64 on a small screen.
63
42
  const phone = r.status === "awaiting_phone" && r.challenge
64
- ? `<div class="tx">awaiting your phone · ${r.challenge.summary.amount} ${r.challenge.summary.token} to ${r.challenge.summary.payee.slice(0, 12)}…</div>
65
- <div class="tx"><a class="phlink" href="${link}">open on your phone to sign</a></div>
66
- <details class="tx"><summary>approval payload for the phone</summary><pre class="qr">${r.qr}</pre></details>`
43
+ ? `<div class="tx">awaiting your phone · ${esc(r.challenge.summary.amount)} ${esc(r.challenge.summary.token)} to ${esc(r.challenge.summary.payee.slice(0, 12))}…</div>
44
+ <div class="tx"><a class="phlink" href="${esc(link)}">open on your phone to sign</a></div>
45
+ <details class="tx"><summary>approval payload for the phone</summary><pre class="qr">${esc(r.qr)}</pre></details>`
67
46
  : "";
68
- const meta = [phone, r.tx && `<div class="tx">${r.tx}</div>`, r.reason && `<div class="tx">${r.reason}</div>`].filter(Boolean).join("");
47
+ const meta = [phone, r.tx && `<div class="tx">${esc(r.tx)}</div>`,
48
+ r.reason && `<div class="tx">${esc(r.reason)}</div>`].filter(Boolean).join("");
69
49
  return `<tr>
70
- <td>${new Date(r.at).toLocaleTimeString()}</td>
71
- <td><div class="uri">${r.uri}</div><div class="body">${r.body || ""}</div></td>
72
- <td class="amt">${r.price}</td>
73
- <td><span class="st ${r.status}">${r.status}</span>${meta}</td>
50
+ <td>${esc(new Date(r.at).toLocaleTimeString())}</td>
51
+ <td><div class="uri">${esc(r.uri)}</div><div class="body">${esc(r.body)}</div></td>
52
+ <td class="amt">${esc(r.price)}</td>
53
+ <td><span class="st ${esc(r.status)}">${esc(r.status)}</span>${meta}</td>
74
54
  <td style="min-width:130px">${btns}</td></tr>`;
75
55
  }
76
56
  export function servePortal(req, res, html) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Payments for AI agents that a human approves. Spending mandates enforced on the Stellar ledger: 2-of-2 vault, pre-authorised transaction envelopes, HTTP 402, USDC.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -16,6 +16,8 @@
16
16
  "phone-app/index.html",
17
17
  "phone-app/app.js",
18
18
  "phone-app/xdr.js",
19
+ "portal-app/portal.js",
20
+ "portal-app/portal.css",
19
21
  "README.md",
20
22
  "LICENSE"
21
23
  ],
@@ -29,8 +31,11 @@
29
31
  "demo:stellar": "tsx stellar/demo.ts",
30
32
  "setup:stellar": "tsx stellar/onboard.ts",
31
33
  "story:stellar": "tsx stellar/story.ts",
34
+ "story:tls": "tsx stellar/story.ts --tls --split",
35
+ "test:tls": "tsx stellar/tls-check.ts",
32
36
  "test": "cd contracts && forge test",
33
37
  "test:stellar": "tsx stellar/demo.ts",
38
+ "test:portal": "tsx stellar/xss-check.ts",
34
39
  "test:phone": "tsx stellar/phone-check.ts && tsx stellar/desk-split-check.ts && tsx stellar/e2e-phone.ts && tsx phone-app/xdr-check.ts && tsx phone-app/app-check.ts && tsx phone-app/desk-app-check.ts",
35
40
  "typecheck": "tsc --noEmit"
36
41
  },
@@ -0,0 +1,22 @@
1
+ :root{--ink:#0d0f0e;--line:#262a28;--text:#eceae4;--muted:#9aa39d;--gold:#e8b04b;--green:#5fbf8b}
2
+ *{box-sizing:border-box}
3
+ body{margin:0;background:var(--ink);color:var(--text);font:16px/1.5 -apple-system,"Avenir Next",system-ui,sans-serif;padding:24px}
4
+ h1{font-size:20px;margin:0 0 4px}
5
+ .sub{color:var(--muted);font-size:13px;margin-bottom:24px;word-break:break-all}
6
+ table{width:100%;border-collapse:collapse}
7
+ th{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.06em;text-align:left;padding:8px;border-bottom:1px solid var(--line)}
8
+ td{padding:14px 8px;border-bottom:1px solid var(--line);vertical-align:middle}
9
+ .amt{font-weight:700;color:var(--gold);white-space:nowrap}
10
+ .uri{font-family:ui-monospace,monospace;font-size:13px;word-break:break-all}
11
+ .body{color:var(--muted);font-size:12px;max-width:340px;word-break:break-word}
12
+ button{border:0;border-radius:10px;padding:12px 18px;font-size:15px;font-weight:600;cursor:pointer;display:block;width:100%;margin:4px 0}
13
+ .yes{background:var(--green);color:#08110c}
14
+ .no{background:transparent;color:var(--muted);border:1px solid var(--line)}
15
+ .st{font-size:12px;text-transform:uppercase;letter-spacing:.06em}
16
+ .settled{color:var(--green)} .denied,.failed{color:#e07a6b} .pending{color:var(--gold)}
17
+ .tx{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted);word-break:break-all}
18
+ .qr{font-size:10px;white-space:pre-wrap;background:#141715;padding:8px;border-radius:8px;color:var(--text)}
19
+ details summary{cursor:pointer}
20
+ .phlink{color:var(--gold);text-decoration:underline;font-size:12px}
21
+ .foot{margin-top:28px;color:var(--muted);font-size:12px}
22
+ @media(max-width:640px){.body{max-width:180px}}
@@ -0,0 +1,72 @@
1
+ // The portal's client half. Served by the desk at /portal.js.
2
+ //
3
+ // It polls /list and re-renders the table. The desk already escapes the rows it
4
+ // sends, but this renderer builds HTML from JSON, so it escapes again: /list is
5
+ // the same agent-controlled text (request path and body) arriving through a
6
+ // second path, and one unescaped field here is enough to run script on the page
7
+ // that authorises payments.
8
+ //
9
+ // No inline handlers: buttons carry data-act/data-id and are wired here, so the
10
+ // page's Content-Security-Policy can forbid inline script entirely.
11
+ //
12
+ // This must render what the server-side render() in stellar/portal.ts renders.
13
+ // The page loads with server-rendered rows, then this replaces them every few
14
+ // seconds — anything missing here disappears from the human's screen a moment
15
+ // after they act, which is exactly what happened to the phone link.
16
+ const Q = new URLSearchParams(location.search).toString();
17
+ const qs = Q ? "?" + Q : "";
18
+
19
+ const esc = (v) => String(v ?? "")
20
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
21
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
22
+
23
+ const row = (x) => {
24
+ const st = `<span class="st ${esc(x.status)}">${esc(x.status)}</span>`;
25
+ // Split mode: the desk has no approver key, so approving handed the payment to
26
+ // a phone. Keep the link and the amount visible after the poll, or the human
27
+ // presses Approve and then loses the thing they were told to do next.
28
+ const phone = x.status === "awaiting_phone" && x.challenge
29
+ ? `<div class="tx">awaiting your phone · ${esc(x.challenge.summary.amount)} ` +
30
+ `${esc(x.challenge.summary.token)} to ${esc(x.challenge.summary.payee.slice(0, 12))}…</div>` +
31
+ `<div class="tx"><a class="phlink" href="phone/?id=${encodeURIComponent(x.id)}${qs.replace("?", "&")}">` +
32
+ `open on your phone to sign</a></div>` +
33
+ `<details class="tx"><summary>approval payload for the phone</summary>` +
34
+ `<pre class="qr">${esc(x.qr)}</pre></details>`
35
+ : x.status === "awaiting_phone" ? `<div class="tx">awaiting your phone</div>` : "";
36
+ const meta = [
37
+ phone,
38
+ x.tx && `<div class="tx">${esc(x.tx)}</div>`,
39
+ x.reason && `<div class="tx">${esc(x.reason)}</div>`,
40
+ ].filter(Boolean).join("");
41
+ const btns = x.status === "pending"
42
+ ? `<button class="yes" data-act="approve" data-id="${esc(x.id)}">Approve</button>` +
43
+ `<button class="no" data-act="deny" data-id="${esc(x.id)}">Deny</button>`
44
+ : "";
45
+ return `<tr><td>${esc(new Date(x.at).toLocaleTimeString())}</td>` +
46
+ `<td><div class="uri">${esc(x.uri)}</div><div class="body">${esc(x.body)}</div></td>` +
47
+ `<td class="amt">${esc(x.price)}</td><td>${st}${meta}</td>` +
48
+ `<td style="min-width:130px">${btns}</td></tr>`;
49
+ };
50
+
51
+ async function act(kind, id) {
52
+ await fetch("/" + kind + "/" + encodeURIComponent(id) + qs, { method: "POST" });
53
+ load();
54
+ }
55
+
56
+ async function load() {
57
+ const r = await fetch("/list" + qs);
58
+ if (!r.ok) return;
59
+ const d = await r.json();
60
+ document.querySelector("tbody").innerHTML =
61
+ d.map(row).join("") || '<tr><td colspan="5" class="empty">nothing waiting</td></tr>';
62
+ document.getElementById("t").textContent = new Date().toLocaleTimeString();
63
+ }
64
+
65
+ // One delegated listener instead of a handler per button, so nothing about a
66
+ // row ever needs to be executable markup.
67
+ document.addEventListener("click", (e) => {
68
+ const b = e.target.closest("button[data-act]");
69
+ if (b) act(b.dataset.act, b.dataset.id);
70
+ });
71
+
72
+ setInterval(load, 3000);