@paydlux/agent 0.1.9 → 0.1.10

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
@@ -84,11 +84,17 @@ one key. So the approval half is split off:
84
84
  npx tsx stellar/phone-check.ts # 17 checks: seller alone cannot pay; phone alone cannot pay
85
85
  npx tsx stellar/e2e-phone.ts # desk with no approver key still settles
86
86
  npx tsx phone-app/xdr-check.ts # 32 checks: the phone's own decoder is byte-exact with the SDK
87
- npx tsx phone-app/desk-app-check.ts # 28 checks: the real app over real HTTP, end to end
87
+ npx tsx phone-app/desk-app-check.ts # 57 checks: the real app over real HTTP, end to end
88
88
  ```
89
89
 
90
90
  The seller builds the payment and signs it with their own half. The human opens
91
- one link — `/phone/?id=...` — and the approver app in `phone-app/` shows them the
91
+ one link — `/phone/?k=...` — and the approver app in `phone-app/` shows them the
92
+ `https://desk.paydlux.com/phone/` link opens an installable app: it has a manifest
93
+ and icons, so a phone can put it on the home screen and it runs full-screen. The
94
+ shell (html, js, icons) is public because it holds no secret and no payment data
95
+ and must load before the key does; every route that returns data or moves money
96
+ (`/queue`, `/challenge`, `/approve`, `/signed`, `/deny`) still answers 403 without
97
+ the portal key.
92
98
  amount, the token, the payee and the deadline, then signs on the device.
93
99
 
94
100
  The app is three static files with no dependencies and no build step. It carries
package/dist/cli.js CHANGED
@@ -407,6 +407,49 @@ removed from ${path}. this machine can no longer approve anything by itself.
407
407
  sell with: paydlux sell --split --tls
408
408
  `);
409
409
  }
410
+ // --------------------------------------------------------------------- withdraw
411
+ /**
412
+ * Take your money back out of the vault. This is a normal 2-of-2 payment:
413
+ * principal (this laptop) + approver (your phone, or the laptop until you run
414
+ * `approver --forget`). The agent key cannot do this — it has no path to an
415
+ * arbitrary destination, which is the whole point of the vault.
416
+ *
417
+ * The XLM the vault pays in ledger rent stays locked while the account exists.
418
+ * Closing the vault (not done here) returns that too.
419
+ */
420
+ async function withdraw() {
421
+ const { data } = loadVault();
422
+ const to = arg("to");
423
+ const amount = arg("amount");
424
+ const all = flag("all");
425
+ if (!to)
426
+ bad(`where should it go?\n\n paydlux withdraw --to G… [--all | --amount 8]\n\n --to can be any Stellar account, including your Binance deposit address.`);
427
+ const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, {
428
+ principal: pub(data.keys.principal),
429
+ approver: data.keys.approver ? pub(data.keys.approver) : undefined,
430
+ agent: pub(data.keys.agent),
431
+ }, (data.network ?? "testnet"));
432
+ if (data.token.code === "USDC")
433
+ vault.useUsdc();
434
+ const bal = await vault.balance();
435
+ const want = all ? bal : amount;
436
+ if (!want)
437
+ bad(`say how much: --all, or --amount 8 (vault holds ${bal} ${data.token.code})`);
438
+ if (Number(want) > Number(bal))
439
+ bad(`the vault holds ${bal} ${data.token.code}, not ${want}.`);
440
+ const keys = data.keys ?? {};
441
+ const principal = Keypair.fromSecret(keys.principal);
442
+ const approver = keys.approver ? Keypair.fromSecret(keys.approver) : null;
443
+ if (!approver) {
444
+ console.log(`this laptop holds only one of the two keys, so the withdrawal needs your phone.
445
+ open: ${flag("tls") ? `https://${PUBLIC_HOST}` : "http://127.0.0.1:4022"}/?k=${data.desk?.portalKey ?? "…"}`);
446
+ bad("then run this again — approvals from the phone land automatically.");
447
+ }
448
+ console.log(`sending ${want} ${data.token.code} from the vault to ${to} …`);
449
+ const res = await vault.release(to, String(want), principal, approver, "paydlux withdraw");
450
+ console.log(`done. tx ${res?.hash}`);
451
+ console.log(`vault now holds ${await vault.balance()} ${data.token.code}`);
452
+ }
410
453
  // ------------------------------------------------------------------------- main
411
454
  const HELP = `paydlux — get paid by AI agents, with a human in the loop.
412
455
 
@@ -419,6 +462,9 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
419
462
  paydlux approver [--forget]
420
463
  print the human half of the vault, then optionally delete it from this
421
464
  machine so only the phone can approve.
465
+ paydlux withdraw --to G… [--all | --amount N]
466
+ take your money back out of the vault. Needs both human keys; the agent
467
+ key can never do this.
422
468
 
423
469
  one file holds it all: ./paydlux-vault.json (0600, never commit it)
424
470
  `;
@@ -436,6 +482,8 @@ export async function main() {
436
482
  return status();
437
483
  if (cmd === "approver")
438
484
  return approver();
485
+ if (cmd === "withdraw")
486
+ return withdraw();
439
487
  bad(`unknown command "${cmd}".\n\n${HELP}`);
440
488
  }
441
489
  // Run only when this file is the entry point, so importing it in a test is safe.
package/dist/desk.d.ts CHANGED
@@ -85,7 +85,23 @@ export declare class ApprovalDesk {
85
85
  * ledger rejects it if the signature is not over this exact transaction.
86
86
  */
87
87
  complete(id: string, signedXdr: string): Promise<DeskRecord>;
88
+ /**
89
+ * The human said no.
90
+ *
91
+ * `awaiting_phone` is a valid state to deny from. Opening the phone link
92
+ * builds the challenge on the spot (so a human needs one tap, not two), which
93
+ * means the first thing a person sees on their phone is already
94
+ * `awaiting_phone` — refusing to deny from that state left the Reject button
95
+ * dead, which was verified on live testnet. Denying is always allowed up to
96
+ * the point the transaction is submitted; after that it is too late.
97
+ */
88
98
  deny(id: string, reason?: string): DeskRecord;
99
+ /**
100
+ * What the phone app is allowed to see. Never the paid `content` — that is
101
+ * what the buyer has not paid for yet — and never the raw challenge XDR,
102
+ * which the app fetches per item so the desk can re-check the binding.
103
+ */
104
+ queue(): Record<string, unknown>[];
89
105
  /** Serve the pending list as JSON (the portal polls this). */
90
106
  api(handler: (req: IncomingMessage, res: ServerResponse) => void): (req: IncomingMessage, res: ServerResponse) => void;
91
107
  listen(port?: number): Promise<void>;
package/dist/desk.js CHANGED
@@ -31,25 +31,34 @@ const staticCache = new Map();
31
31
  * Static files are read on each process start rather than bundled, so the desk
32
32
  * and its front ends can never drift apart. The name is stripped to [a-z.]
33
33
  * before it touches a path, so a request can't walk out of the directory.
34
+ *
35
+ * PNGs are returned as bytes: reading an image as utf8 text replaces every
36
+ * invalid sequence with U+FFFD and ships a corrupt icon to the phone.
34
37
  */
35
38
  function readStatic(dir, name, label) {
36
- const safe = name.replace(/[^a-z.]/g, "");
39
+ const safe = name.replace(/[^a-z0-9._-]/g, "");
37
40
  const hit = staticCache.get(dir + "/" + safe);
38
41
  if (hit)
39
42
  return hit;
40
43
  let text;
41
44
  try {
42
- text = readFileSync(join(dir, safe), "utf8");
45
+ text = safe.endsWith(".png")
46
+ ? readFileSync(join(dir, safe))
47
+ : readFileSync(join(dir, safe), "utf8");
43
48
  }
44
49
  catch {
45
- // A package install that shipped without the folder should say so instead of
46
- // throwing out of the request handler.
47
- throw new Error(label + " is missing from this install (expected " + join(dir, safe) + ")");
50
+ // A missing file is a 404, not a crash. Throwing here took the whole desk
51
+ // down over one bad URL: the request handler is not a place to raise.
52
+ return null;
48
53
  }
49
54
  staticCache.set(dir + "/" + safe, text);
50
55
  return text;
51
56
  }
52
57
  const readAppFile = (name) => readStatic(APP_DIR, name, "approver app");
58
+ const notFound = (res) => {
59
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
60
+ res.end("404");
61
+ };
53
62
  const readPortalFile = (name) => readStatic(PORTAL_DIR, name, "portal app");
54
63
  /** A portal link is a capability: whoever holds it can approve. So it is long,
55
64
  * random, and compared without leaking timing. */
@@ -258,16 +267,51 @@ export class ApprovalDesk {
258
267
  }
259
268
  return rec;
260
269
  }
270
+ /**
271
+ * The human said no.
272
+ *
273
+ * `awaiting_phone` is a valid state to deny from. Opening the phone link
274
+ * builds the challenge on the spot (so a human needs one tap, not two), which
275
+ * means the first thing a person sees on their phone is already
276
+ * `awaiting_phone` — refusing to deny from that state left the Reject button
277
+ * dead, which was verified on live testnet. Denying is always allowed up to
278
+ * the point the transaction is submitted; after that it is too late.
279
+ */
261
280
  deny(id, reason = "denied by the approver") {
262
281
  const rec = this.records.get(id);
263
282
  if (!rec)
264
283
  throw new Error("unknown handle " + id);
265
- if (rec.status !== "pending")
266
- throw new Error(`already ${rec.status}`);
284
+ if (rec.status === "settled")
285
+ throw new Error("already settled — the money has moved, it cannot be denied");
286
+ if (rec.status === "denied")
287
+ return rec; // idempotent: the app may retry
267
288
  rec.status = "denied";
268
289
  rec.reason = reason;
290
+ rec.challenge = undefined; // the phone's answer is now worthless
291
+ rec.signedXdr = undefined;
269
292
  return rec;
270
293
  }
294
+ /**
295
+ * What the phone app is allowed to see. Never the paid `content` — that is
296
+ * what the buyer has not paid for yet — and never the raw challenge XDR,
297
+ * which the app fetches per item so the desk can re-check the binding.
298
+ */
299
+ queue() {
300
+ return this.list().map((r) => ({
301
+ id: r.id,
302
+ at: r.at,
303
+ uri: r.uri,
304
+ price: r.price,
305
+ status: r.status,
306
+ reason: r.reason,
307
+ tx: r.tx,
308
+ /** what the buyer says it wants. Agent-controlled text: render as text. */
309
+ wants: (r.body ?? "").slice(0, 200),
310
+ token: this.cfg.vault.token.getCode(),
311
+ expiresAt: r.challenge?.summary.expiresAt,
312
+ hash: r.challenge?.hash,
313
+ }));
314
+ }
271
315
  /** Serve the pending list as JSON (the portal polls this). */
272
316
  api(handler) { return handler; }
273
317
  listen(port = this.cfg.port) {
@@ -329,6 +373,65 @@ export class ApprovalDesk {
329
373
  res.end("ok");
330
374
  return;
331
375
  }
376
+ // The approver app's static shell is served BEFORE the key gate, on
377
+ // purpose. It is index.html, app.js, xdr.js, the icons and the manifest:
378
+ // client code with no secret and no payment data in it, the same as the
379
+ // portal's own portal.js. Serving it unauthenticated is what lets a phone
380
+ // install it to a home screen from a plain URL, with the seller's key
381
+ // arriving separately in the link. Everything that carries data or moves
382
+ // money — /queue, /challenge, /list, /approve, /deny, /signed — stays
383
+ // behind the gate below.
384
+ //
385
+ // The app uses relative script srcs, so it must be served from a
386
+ // directory URL: /phone without the slash would resolve app.js to /app.js.
387
+ if (url.pathname === "/phone") {
388
+ res.writeHead(302, { location: "/phone/" + url.search });
389
+ res.end();
390
+ return;
391
+ }
392
+ if (url.pathname === "/phone/" || url.pathname === "/phone/index.html") {
393
+ res.writeHead(200, {
394
+ "content-type": "text/html; charset=utf-8",
395
+ // The approver key goes in IndexedDB behind a non-extractable
396
+ // CryptoKey; an injected script is the realistic threat, so lock the
397
+ // origin. No inline script on the page: app.js and xdr.js are
398
+ // separate files, so script-src 'self' actually holds. connect-src
399
+ // 'self' is what stops the app talking anywhere but the desk that
400
+ // served it.
401
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
402
+ "img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
403
+ "form-action 'none'; frame-ancestors 'none'",
404
+ "referrer-policy": "no-referrer",
405
+ "x-content-type-options": "nosniff",
406
+ "cache-control": "no-store",
407
+ });
408
+ const page = readAppFile("index.html");
409
+ if (page === null)
410
+ return notFound(res);
411
+ res.end(page);
412
+ return;
413
+ }
414
+ if (url.pathname.startsWith("/phone/")) {
415
+ const file = url.pathname.slice("/phone/".length);
416
+ // Icons and the manifest are part of installing the app to a home
417
+ // screen, so they are served alongside the code. Names are still
418
+ // stripped to a safe set before they touch a path.
419
+ if (!/^[a-z0-9_-]+\.(js|png|webmanifest)$/.test(file))
420
+ return notFound(res);
421
+ const type = file.endsWith(".png") ? "image/png"
422
+ : file.endsWith(".webmanifest") ? "application/manifest+json"
423
+ : "text/javascript";
424
+ const body = readAppFile(file);
425
+ if (body === null)
426
+ return notFound(res);
427
+ res.writeHead(200, {
428
+ "content-type": type + (file.endsWith(".png") ? "" : "; charset=utf-8"),
429
+ "cache-control": "no-store",
430
+ "x-content-type-options": "nosniff",
431
+ });
432
+ res.end(body);
433
+ return;
434
+ }
332
435
  // A public demo link must not be an open approval button. If a portalKey
333
436
  // is configured, every route needs it, or the desk answers 403.
334
437
  //
@@ -364,56 +467,22 @@ export class ApprovalDesk {
364
467
  // approval page and an injected string can't become executable script.
365
468
  if (url.pathname === "/portal.js" || url.pathname === "/portal.css") {
366
469
  const file = url.pathname.slice(1);
470
+ const body = readPortalFile(file);
471
+ if (body === null)
472
+ return notFound(res);
367
473
  res.writeHead(200, {
368
474
  "content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
369
475
  "x-content-type-options": "nosniff",
370
476
  });
371
- res.end(readPortalFile(file));
477
+ res.end(body);
372
478
  return;
373
479
  }
374
480
  if (url.pathname === "/list")
375
481
  return send(200, this.list());
376
- // The approver app. Served from the desk so the human opens one link on
377
- // their phone instead of pasting XDR around. The app itself makes no
378
- // requests after loading, and the portalKey gate above already covers
379
- // these paths: without the key, nothing here is reachable.
380
- // The app uses relative script srcs, so it must be served from a
381
- // directory URL. /phone without the slash would resolve app.js to
382
- // /app.js, so send it to the canonical path with the key intact.
383
- if (url.pathname === "/phone") {
384
- res.writeHead(302, { location: "/phone/" + url.search });
385
- res.end();
386
- return;
387
- }
388
- if (url.pathname === "/phone/" || url.pathname === "/phone/index.html") {
389
- res.writeHead(200, {
390
- "content-type": "text/html; charset=utf-8",
391
- // The key lives in IndexedDB behind a non-extractable CryptoKey;
392
- // an injected script is the realistic threat, so lock the origin.
393
- // No inline script on the page: app.js and xdr.js are separate
394
- // files, so script-src 'self' actually holds. connect-src 'self' is
395
- // what stops the app talking anywhere but the desk that served it.
396
- "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
397
- "img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
398
- "form-action 'none'; frame-ancestors 'none'",
399
- "referrer-policy": "no-referrer",
400
- "x-content-type-options": "nosniff",
401
- });
402
- res.end(readAppFile("index.html"));
403
- return;
404
- }
405
- if (url.pathname.startsWith("/phone/")) {
406
- const file = url.pathname.slice("/phone/".length);
407
- if (!/^[a-z]+\.(js|css)$/.test(file))
408
- return send(404, { error: "not found" });
409
- res.writeHead(200, {
410
- "content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
411
- "cache-control": "no-store",
412
- "x-content-type-options": "nosniff",
413
- });
414
- res.end(readAppFile(file));
415
- return;
416
- }
482
+ // The approver app's list view. Key-gated like everything else, and it
483
+ // carries no paid content and no challenge XDR.
484
+ if (url.pathname === "/queue")
485
+ return send(200, this.queue());
417
486
  // The app fetches the pending transaction by handle rather than asking
418
487
  // the human to paste 400 characters of base64 on a phone.
419
488
  const ch = url.pathname.match(/^\/challenge\/([\w-]+)$/);
@@ -431,6 +500,11 @@ export class ApprovalDesk {
431
500
  return send(200, {
432
501
  id: rec.id, network: rec.challenge.network,
433
502
  hash: rec.challenge.hash, xdr: rec.challenge.xdr,
503
+ // what the human is being asked to read. Agent-controlled text: the
504
+ // app renders it as text, never as markup.
505
+ wants: (rec.body ?? "").slice(0, 200),
506
+ memo: rec.challenge.summary.memo ?? "",
507
+ expiresAt: rec.challenge.summary.expiresAt,
434
508
  });
435
509
  }
436
510
  const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
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,10 @@
16
16
  "phone-app/index.html",
17
17
  "phone-app/app.js",
18
18
  "phone-app/xdr.js",
19
+ "phone-app/app.css",
20
+ "phone-app/manifest.webmanifest",
21
+ "phone-app/icon-192.png",
22
+ "phone-app/icon-512.png",
19
23
  "portal-app/portal.js",
20
24
  "portal-app/portal.css",
21
25
  "README.md",
package/phone-app/app.js CHANGED
@@ -1,253 +1,432 @@
1
- // The approver app's own logic. Loaded by phone-app/index.html and served by the
2
- // desk at /phone/app.js. Kept in a separate file so the Content-Security-Policy
3
- // (script-src 'self') is real rather than defeated by an inline <script>.
4
- (function () {
5
- "use strict";
6
- const X = window.PaydluxXdr;
7
- const $ = (id) => document.getElementById(id);
8
- const DB = "paydlux-approver", STORE = "keys";
9
-
10
- let signingKey = null;
11
-
12
- function idb(mode, fn) {
13
- return new Promise((res, rej) => {
14
- const open = indexedDB.open(DB, 1);
15
- open.onupgradeneeded = () => open.result.createObjectStore(STORE);
16
- open.onerror = () => rej(open.error);
17
- open.onsuccess = () => {
18
- const tx = open.result.transaction(STORE, mode);
19
- const req = fn(tx.objectStore(STORE));
20
- req.onsuccess = () => res(req.result);
21
- req.onerror = () => rej(req.error);
22
- };
23
- });
24
- }
25
- const saveKey = (k) => idb("readwrite", (s) => s.put(k, "approver"));
26
- const loadKey = () => idb("readonly", (s) => s.get("approver"));
27
- const dropKey = () => idb("readwrite", (s) => s.delete("approver"));
28
-
29
- function base64urlToBytes(s) {
30
- const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
31
- const out = new Uint8Array(bin.length);
32
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
33
- return out;
34
- }
1
+ // Paydlux approver — the client half.
2
+ //
3
+ // Two jobs, and only two: hold the approver key on this device, and sign the
4
+ // transaction hash for a payment a human has read.
5
+ //
6
+ // Rules this file is written to:
7
+ // * The seed is imported into a NON-extractable WebCrypto key and then
8
+ // dropped. What is persisted is the CryptoKey, not the seed. An injected
9
+ // script can call sign(); it cannot read the seed.
10
+ // * Signing is local. The bytes signed are the transaction hash the app
11
+ // computed itself from the XDR it fetched — never a hash the server handed
12
+ // over as an opaque "please sign this".
13
+ // * Every string that came from a buyer's agent (request path, request body,
14
+ // reason) is written with textContent. Never innerHTML. This page authorises
15
+ // payments; one unescaped field there is remote code execution on the
16
+ // approver's phone.
17
+ // * One origin: the desk that served this app. No other fetch is made.
18
+ "use strict";
35
19
 
36
- async function showReady(pub) {
37
- $("who").textContent = X.encodeAccountEd25519(pub);
38
- $("enrol").classList.add("hidden");
39
- $("ready").classList.remove("hidden");
40
- }
20
+ const X = globalThis.PaydluxXdr;
21
+ const $ = (id) => document.getElementById(id);
22
+ const show = (id, on = true) => $(id).classList.toggle("hidden", !on);
23
+ const set = (id, v) => { $(id).textContent = v == null || v === "" ? "\u2014" : String(v); };
24
+ const err = (id, msg) => { $(id).textContent = msg || ""; };
41
25
 
42
- // ------------------------------------------------------------- enrolment
43
- $("enrolBtn").addEventListener("click", async () => {
44
- $("enrolErr").textContent = "";
45
- const seed = $("seed").value.trim();
46
- try {
47
- // Imported twice on purpose: once extractable purely to read the PUBLIC
48
- // half back for display, once non-extractable as the key we keep.
49
- const seedBytes = X.decodeSecretSeed(seed);
50
- const pkcs8 = new Uint8Array(16 + 32);
51
- pkcs8.set([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20], 0);
52
- pkcs8.set(seedBytes, 16);
53
-
54
- // "verify" is not a legal usage for a private Ed25519 key — the browser
55
- // refuses the import outright. Sign only.
56
- const tmp = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
57
- const jwk = await crypto.subtle.exportKey("jwk", tmp);
58
- const pub = base64urlToBytes(jwk.x);
59
-
60
- signingKey = await X.importSecretSeed(seed); // non-extractable
61
- seedBytes.fill(0); pkcs8.fill(0); $("seed").value = "";
62
-
63
- // What is persisted is the non-extractable CryptoKey object, not the seed.
64
- // Whatever backs IndexedDB can use that key to sign; no code path can read
65
- // the seed out of it. Losing the database leaks nothing; stealing it leaks
66
- // nothing either.
67
- await saveKey({ key: signingKey, x: jwk.x });
68
- await showReady(pub);
69
- } catch (e) {
70
- $("enrolErr").textContent = e.message;
71
- }
26
+ // ------------------------------------------------------------------ storage
27
+ // The approver key goes in IndexedDB, not localStorage. localStorage can only
28
+ // hold strings, which would force us to re-encode the key as something
29
+ // readable; IndexedDB stores the CryptoKey object itself, and a
30
+ // non-extractable key has no readable form to store.
31
+ const DB = "paydlux-approver";
32
+ const idb = () => new Promise((res, rej) => {
33
+ const open = indexedDB.open(DB, 1);
34
+ open.onupgradeneeded = () => open.result.createObjectStore("keys");
35
+ open.onsuccess = () => res(open.result);
36
+ open.onerror = () => rej(open.error);
37
+ });
38
+ async function idbPut(value) {
39
+ const db = await idb();
40
+ return new Promise((res, rej) => {
41
+ const t = db.transaction("keys", "readwrite");
42
+ t.objectStore("keys").put(value, "approver");
43
+ t.oncomplete = () => res();
44
+ t.onerror = () => rej(t.error);
45
+ });
46
+ }
47
+ async function idbGet() {
48
+ const db = await idb();
49
+ return new Promise((res, rej) => {
50
+ const t = db.transaction("keys", "readonly");
51
+ const g = t.objectStore("keys").get("approver");
52
+ g.onsuccess = () => res(g.result ?? null);
53
+ g.onerror = () => rej(g.error);
72
54
  });
55
+ }
56
+ async function idbDel() {
57
+ const db = await idb();
58
+ return new Promise((res, rej) => {
59
+ const t = db.transaction("keys", "readwrite");
60
+ t.objectStore("keys").delete("approver");
61
+ t.oncomplete = () => res();
62
+ t.onerror = () => rej(t.error);
63
+ });
64
+ }
73
65
 
74
- $("forget").addEventListener("click", async () => {
75
- signingKey = null;
76
- await dropKey();
77
- location.reload();
66
+ // The approval link carries the seller's portal key. It is the thing that makes
67
+ // this app useful, so it is kept on the device after the seller opens it once.
68
+ const LINK = "paydlux.link";
69
+ const linkParams = () => {
70
+ const raw = localStorage.getItem(LINK) || "";
71
+ const u = new URL(raw, location.href);
72
+ return u.searchParams;
73
+ };
74
+ const key = () => linkParams().get("k") || "";
75
+
76
+ // ------------------------------------------------------------------- api
77
+ // Same origin always: the desk that served this file.
78
+ async function api(path, opts = {}) {
79
+ const sep = path.includes("?") ? "&" : "?";
80
+ const res = await fetch(path + sep + "k=" + encodeURIComponent(key()), {
81
+ ...opts,
82
+ headers: { ...(opts.headers || {}) },
78
83
  });
84
+ const text = await res.text();
85
+ let body = null;
86
+ try { body = text ? JSON.parse(text) : null; } catch { /* not json */ }
87
+ if (!res.ok) {
88
+ throw new Error((body && (body.reason || body.error)) || `the desk answered ${res.status}`);
89
+ }
90
+ return body;
91
+ }
79
92
 
80
- // --------------------------------------------------------------- review
81
- let pending = null;
82
-
83
- /**
84
- * Deep link from the desk: /phone/?id=pay_0001&k=PORTALKEY
85
- * The human taps a link instead of pasting 400 characters of base64 on a
86
- * phone. The desk returns its own record of the challenge, so the amount the
87
- * phone displays is the amount the desk recorded.
88
- */
89
- async function loadFromDesk() {
90
- const q = new URLSearchParams(location.search);
91
- const id = q.get("id"), k = q.get("k");
92
- if (!id) return;
93
- try {
94
- const res = await fetch("/challenge/" + encodeURIComponent(id) +
95
- (k ? "?k=" + encodeURIComponent(k) : ""), { cache: "no-store" });
96
- if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.status);
97
- const rec = await res.json();
98
- $("xdr").value = rec.xdr;
99
- pending = { fromDesk: true, id, k };
100
- await renderChallenge(rec.xdr);
101
- $("readErr").textContent = "";
102
- } catch (e) {
103
- $("readErr").textContent = "could not load " + id + ": " + e.message;
93
+ // ---------------------------------------------------------------- key state
94
+ let approverKey = null; // the non-extractable CryptoKey
95
+ let approverPublic = null; // its G... address, for display
96
+ let approverPubBytes = null; // its 32 raw bytes, for addSignature
97
+
98
+ async function loadKey() {
99
+ try { approverKey = await idbGet(); } catch { approverKey = null; }
100
+ if (!approverKey) return false;
101
+ // A non-extractable key cannot be asked for its own public bytes, so they were
102
+ // captured at enrolment and kept next to it. The address is for display; the
103
+ // raw bytes are what addSignature needs to attach the signature.
104
+ approverPublic = localStorage.getItem("paydlux.pub") || null;
105
+ const raw = localStorage.getItem("paydlux.pubraw") || "";
106
+ approverPubBytes = raw ? X.hexToBytes(raw) : null;
107
+ return true;
108
+ }
109
+
110
+ async function enrol(seed) {
111
+ const trimmed = seed.trim();
112
+ // Import once as non-extractable (what we keep) and once as extractable
113
+ // purely to read back the public half, which the temporary key is then
114
+ // discarded for. The seed itself is never stored anywhere.
115
+ const { key: kp, pub, address } = await X.importSecretSeedPair(trimmed);
116
+ approverKey = kp;
117
+ approverPublic = address;
118
+ approverPubBytes = pub;
119
+ await idbPut(kp);
120
+ localStorage.setItem("paydlux.pub", address);
121
+ localStorage.setItem("paydlux.pubraw", X.hex(pub));
122
+ $("seed").value = "";
123
+ return true;
124
+ }
125
+
126
+ // ------------------------------------------------------------------ render
127
+ function paintKeyState() {
128
+ show("enrol", !approverKey);
129
+ show("ready", !!approverKey);
130
+ if (approverKey) set("who", approverPublic || "imported on this device");
131
+ }
132
+
133
+ function paintLinkState() {
134
+ const have = !!key();
135
+ show("nolink", !have);
136
+ show("queue", have);
137
+ if (!have) show("paste", false); // no link, no queue: nothing to fall back to yet
138
+ if (!have) $("link").value = "";
139
+ }
140
+
141
+ const ago = (iso) => {
142
+ const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
143
+ if (s < 60) return Math.round(s) + "s ago";
144
+ if (s < 3600) return Math.round(s / 60) + "m ago";
145
+ return Math.round(s / 3600) + "h ago";
146
+ };
147
+
148
+ const STATUS_TAG = {
149
+ pending: ["warn", "waiting"],
150
+ awaiting_phone: ["warn", "on your phone"],
151
+ settled: ["ok", "paid"],
152
+ denied: ["bad", "rejected"],
153
+ failed: ["bad", "failed"],
154
+ expired: ["bad", "expired"],
155
+ };
156
+
157
+ /** A queue row. Built from DOM nodes so agent text can never be markup. */
158
+ function row(rec) {
159
+ const wrap = document.createElement("div");
160
+ wrap.className = "item" + (rec.status === "settled" || rec.status === "denied" ? " hist" : "");
161
+
162
+ const top = document.createElement("div");
163
+ top.className = "top";
164
+ const amt = document.createElement("div");
165
+ amt.className = "amt";
166
+ amt.textContent = `${rec.price} ${rec.token || ""}`;
167
+ const [, cls] = STATUS_TAG[rec.status] || ["warn", rec.status];
168
+ const tag = document.createElement("span");
169
+ tag.className = "tag " + cls;
170
+ tag.textContent = (STATUS_TAG[rec.status] || [null, rec.status])[1] || rec.status;
171
+ top.append(amt, tag);
172
+
173
+ const uri = document.createElement("div");
174
+ uri.className = "uri";
175
+ uri.textContent = rec.uri || "";
176
+
177
+ const wants = document.createElement("div");
178
+ wants.className = "wants";
179
+ wants.textContent = rec.wants || "";
180
+
181
+ const foot = document.createElement("div");
182
+ foot.className = "foot";
183
+ const when = document.createElement("span");
184
+ when.className = "ago";
185
+ when.textContent = ago(rec.at) + (rec.tx ? " \u00b7 " + rec.tx.slice(0, 12) + "\u2026" : "");
186
+ foot.append(when);
187
+
188
+ const actionable = rec.status === "pending" || rec.status === "awaiting_phone";
189
+ if (actionable) {
190
+ const btns = document.createElement("div");
191
+ btns.className = "btns";
192
+ const yes = document.createElement("button");
193
+ yes.textContent = "Review";
194
+ yes.addEventListener("click", () => openReview(rec.id));
195
+ const no = document.createElement("button");
196
+ no.className = "danger";
197
+ no.textContent = "Reject";
198
+ no.addEventListener("click", () => reject(rec.id));
199
+ btns.append(yes, no);
200
+ foot.append(btns);
201
+ }
202
+
203
+ wrap.append(top, uri, wants, foot);
204
+ return wrap;
205
+ }
206
+
207
+ let queueTimer = null;
208
+ async function refreshQueue() {
209
+ if (!key()) return;
210
+ try {
211
+ const list = await api("/queue");
212
+ const live = list.filter((r) => r.status === "pending" || r.status === "awaiting_phone");
213
+ const past = list.filter((r) => r.status !== "pending" && r.status !== "awaiting_phone").slice(0, 5);
214
+ const box = $("qlist");
215
+ box.replaceChildren();
216
+ if (!live.length && !past.length) {
217
+ const e = document.createElement("div");
218
+ e.className = "empty";
219
+ e.textContent = "nothing waiting";
220
+ box.append(e);
104
221
  }
222
+ live.forEach((r) => box.append(row(r)));
223
+ past.forEach((r) => box.append(row(r)));
224
+ // The hand-paste box is the fallback for a seller who sends a transaction by
225
+ // message instead of a link. It appears when there is nothing in the queue,
226
+ // so a fresh device never meets "paste the transaction" before its payments.
227
+ show("paste", !live.length);
228
+ set("qwhen", new Date().toLocaleTimeString());
229
+ err("qerr", "");
230
+ } catch (e) {
231
+ err("qerr", e.message + " — if this is new, open the seller's approval link again");
105
232
  }
233
+ }
106
234
 
107
- async function renderChallenge(xdrText) {
108
- const env = X.parseEnvelope(xdrText);
235
+ // ----------------------------------------------------------------- review
236
+ let current = null; // { id, xdr, hash, summary }
237
+
238
+ async function openReview(id) {
239
+ err("qerr", "");
240
+ try {
241
+ const rec = await api("/challenge/" + encodeURIComponent(id));
242
+ const env = X.parseEnvelope(rec.xdr);
109
243
  const op = env.tx.ops[0];
110
- const testnet = await X.transactionHash(xdrText, X.NETWORK_ID.testnet);
111
- const publicNet = await X.transactionHash(xdrText, X.NETWORK_ID.public);
112
- pending = Object.assign({}, pending, {
113
- xdrText, env, hashes: { testnet: testnet.hash, public: publicNet.hash },
114
- });
244
+ if (env.tx.ops.length !== 1 || op.type !== X.OP_PAYMENT) {
245
+ throw new Error("this transaction is not a single payment \u2014 refusing to sign it");
246
+ }
247
+ const { hash } = await X.transactionHash(rec.xdr, X.NETWORK_ID[rec.network] || rec.network);
248
+ if (rec.hash && hash !== rec.hash) {
249
+ throw new Error("the transaction does not hash to what the desk advertised \u2014 refusing");
250
+ }
251
+ current = {
252
+ id, xdr: rec.xdr, hash,
253
+ amount: X.formatAmount(op.amount),
254
+ token: op.asset.code || "XLM",
255
+ issuer: op.asset.issuer || "",
256
+ payee: X.encodeAccountEd25519(op.destination.pub),
257
+ vault: X.encodeAccountEd25519(env.tx.source.pub),
258
+ memo: op.source ? "(payment from a secondary key)" : (rec.memo || ""),
259
+ deadline: rec.expiresAt || env.tx.cond?.timeBounds?.max || "",
260
+ network: rec.network,
261
+ wants: rec.wants || "",
262
+ };
263
+ set("amount", `${current.amount} ${current.token}`);
264
+ set("token", current.issuer ? `${current.token} (${current.issuer.slice(0, 12)}\u2026)` : current.token);
265
+ set("payee", current.payee);
266
+ set("vault", current.vault);
267
+ set("wants", current.wants);
268
+ set("memo", current.memo);
269
+ set("deadline", current.deadline ? new Date(Number(current.deadline) * 1000).toLocaleString() : "none");
270
+ set("network", current.network === "public" ? "Stellar mainnet" : current.network);
271
+ set("hash", current.hash);
272
+ err("signErr", "");
273
+ show("review", true);
274
+ show("queue", false);
275
+ $("review").scrollIntoView({ behavior: "smooth", block: "start" });
276
+ } catch (e) {
277
+ err("qerr", e.message);
278
+ }
279
+ }
115
280
 
116
- $("amount").textContent = X.formatAmount(op.amount) + " " + op.asset.code;
117
- $("token").textContent = op.asset.issuer
118
- ? op.asset.code + " issued by " + op.asset.issuer
119
- : "XLM (native)";
120
- $("payee").textContent = X.encodeAccountEd25519(op.destination.pub);
121
- $("vault").textContent = X.encodeAccountEd25519(env.tx.source.pub);
122
- $("memo").textContent = env.tx.memo || "(none)";
123
- const max = env.tx.bounds && env.tx.bounds.maxTime;
124
- $("deadline").textContent = max && max !== "0"
125
- ? new Date(Number(max) * 1000).toLocaleString() + " (" + remaining(Number(max)) + ")"
126
- : "none — this transaction never expires";
127
-
128
- // Which network an envelope belongs to is NOT inside the envelope, so the
129
- // human picks it. Signing for the wrong network yields a signature the
130
- // ledger refuses; it cannot silently move funds somewhere else.
131
- $("hash").innerHTML =
132
- '<label class="tag ok"><input type="radio" name="net" value="testnet" checked> testnet</label> '
133
- + testnet.hash +
134
- '<br><label class="tag warn"><input type="radio" name="net" value="public"> mainnet</label> '
135
- + publicNet.hash;
136
- $("review").classList.remove("hidden");
281
+ function backToQueue() {
282
+ current = null;
283
+ show("review", false);
284
+ show("result", false);
285
+ show("queue", !!key());
286
+ refreshQueue();
287
+ }
288
+
289
+ // ------------------------------------------------------------------ sign
290
+ async function signAndSubmit() {
291
+ if (!current) return;
292
+ if (!approverKey) { err("signErr", "no approver key on this device"); return; }
293
+ const button = $("approve");
294
+ button.disabled = true;
295
+ try {
296
+ // Re-hash at the moment of signing. What is signed is what was on screen.
297
+ const { hash } = await X.transactionHash(current.xdr, X.NETWORK_ID[current.network] || current.network);
298
+ if (hash !== current.hash) throw new Error("the transaction changed under you \u2014 nothing was signed");
299
+ const sig = new Uint8Array(await crypto.subtle.sign("Ed25519", approverKey, X.signaturePreimage(hash)));
300
+ if (!approverPubBytes) throw new Error("this device lost the approver's public key — re-import it");
301
+ const signed = X.addSignature(current.xdr, sig, approverPubBytes);
302
+ const out = await api("/signed/" + encodeURIComponent(current.id), {
303
+ method: "POST",
304
+ headers: { "content-type": "application/json" },
305
+ body: JSON.stringify({ signedXdr: signed }),
306
+ });
307
+ set("signed", signed);
308
+ const tag = $("resultTag");
309
+ tag.className = "tag " + (out.status === "settled" ? "ok" : "bad");
310
+ tag.textContent = out.status === "settled" ? "Paid" : out.status;
311
+ if (out.tx) set("resultLabel", `Signed \u00b7 ledger tx ${out.tx}`);
312
+ if (out.reason) set("resultLabel", out.reason);
313
+ show("result", true);
314
+ show("review", false);
315
+ } catch (e) {
316
+ err("signErr", e.message);
317
+ } finally {
318
+ button.disabled = false;
137
319
  }
320
+ }
138
321
 
139
- function remaining(unix) {
140
- const s = unix - Math.floor(Date.now() / 1000);
141
- if (s <= 0) return "EXPIRED";
142
- if (s < 120) return s + "s left";
143
- return Math.round(s / 60) + " min left";
322
+ async function reject(id) {
323
+ try {
324
+ await api("/deny/" + encodeURIComponent(id), { method: "POST" });
325
+ refreshQueue();
326
+ } catch (e) {
327
+ err("qerr", e.message);
144
328
  }
329
+ }
145
330
 
146
- $("read").addEventListener("click", async () => {
147
- $("readErr").textContent = "";
148
- $("review").classList.add("hidden");
149
- const xdrText = $("xdr").value.trim().replace(/\s+/g, "");
150
- try {
151
- await renderChallenge(xdrText);
152
- } catch (e) {
153
- $("readErr").textContent = e.message;
331
+ // ------------------------------------------------------- hand-pasted xdr
332
+ // For a seller who sends the pending transaction by hand. There is no trusted
333
+ // source for it, so the app makes the human type back the hash the seller
334
+ // quoted: signing then proves the human saw the same bytes, not a swapped one.
335
+ async function readPasted() {
336
+ err("readErr", "");
337
+ try {
338
+ if (!approverKey || !approverPubBytes) throw new Error("import the approver key first");
339
+ const xdr = $("xdr").value.trim();
340
+ if (!xdr) throw new Error("paste the transaction first");
341
+ const env = X.parseEnvelope(xdr);
342
+ if (env.tx.ops.length !== 1 || env.tx.ops[0].type !== X.OP_PAYMENT) {
343
+ throw new Error("not a single payment \u2014 refusing");
154
344
  }
155
- });
345
+ const op = env.tx.ops[0];
346
+ const local = location.hostname === "localhost" || /^127\./.test(location.hostname);
347
+ const net = local ? "testnet" : "public";
348
+ const { hash } = await X.transactionHash(xdr, X.NETWORK_ID[net]);
349
+ // No trusted source, so the human proves they saw these exact bytes by
350
+ // typing back the hash the seller quoted. A swapped transaction hashes
351
+ // differently and this check fails.
352
+ const want = prompt(
353
+ `Network: ${net}\n` +
354
+ `This pays ${X.formatAmount(op.amount)} ${op.asset.code || "XLM"} to ` +
355
+ `${X.encodeAccountEd25519(op.destination.pub)}.\n\n` +
356
+ `Type the first 12 characters of the hash the seller quoted to confirm you are ` +
357
+ `signing the same transaction.`);
358
+ if (!want) return;
359
+ if (want.trim().toLowerCase() !== hash.slice(0, 12)) {
360
+ throw new Error("that is not the hash the seller quoted \u2014 nothing was signed");
361
+ }
362
+ const sig = new Uint8Array(await crypto.subtle.sign("Ed25519", approverKey, X.signaturePreimage(hash)));
363
+ set("signed", X.addSignature(xdr, sig, approverPubBytes));
364
+ set("resultLabel", "Signed \u2014 send this back to the seller");
365
+ $("resultTag").className = "tag ok";
366
+ $("resultTag").textContent = "Signed";
367
+ show("result", true);
368
+ } catch (e) {
369
+ err("readErr", e.message);
370
+ }
371
+ }
156
372
 
157
- $("reject").addEventListener("click", () => reset());
158
- $("again").addEventListener("click", () => reset());
159
- function reset() {
160
- pending = null;
161
- $("result").classList.add("hidden");
162
- $("review").classList.add("hidden");
163
- $("xdr").value = "";
373
+ // ------------------------------------------------------------------ boot
374
+ async function boot() {
375
+ // A deep link from the seller's desk saves the approval link automatically.
376
+ const here = new URL(location.href);
377
+ if (here.searchParams.get("k")) {
378
+ const u = new URL(location.pathname, location.origin);
379
+ const p = new URLSearchParams();
380
+ for (const [k2, v] of here.searchParams) p.set(k2, v);
381
+ localStorage.setItem(LINK, u.pathname + "?" + p.toString());
164
382
  }
165
383
 
166
- // ---------------------------------------------------------------- sign
167
- $("approve").addEventListener("click", async () => {
168
- $("signErr").textContent = "";
169
- if (!signingKey) {
170
- $("signErr").textContent = "Import the approver key first.";
171
- return;
172
- }
173
- if (!pending) return;
174
-
175
- const picked = document.querySelector("input[name=net]:checked");
176
- const which = picked ? picked.value : "testnet";
177
- const network = which === "testnet" ? X.NETWORK_ID.testnet : X.NETWORK_ID.public;
178
-
179
- // A pasted transaction has no trusted source, so the human proves they were
180
- // shown this exact one by typing its hash back. A transaction fetched from
181
- // the desk over the portal key is already bound to the desk's own record,
182
- // which the desk re-checks before submitting.
183
- if (!pending.fromDesk) {
184
- const quoted = prompt("Type the first 16 characters of the hash the seller quoted you:");
185
- if (!quoted) return;
186
- const q = quoted.trim().toLowerCase();
187
- if (q !== pending.hashes.testnet.slice(0, 16) && q !== pending.hashes.public.slice(0, 16)) {
188
- $("signErr").textContent = "That hash matches neither. Nothing was signed.";
189
- return;
190
- }
191
- }
384
+ await loadKey();
385
+ paintKeyState();
386
+ paintLinkState();
192
387
 
388
+ $("enrolBtn").addEventListener("click", async () => {
389
+ err("enrolErr", "");
193
390
  try {
194
- const { hash } = await X.transactionHash(pending.xdrText, network);
195
- // The signature covers the 32 raw hash bytes. sha256-ing the hex text
196
- // instead yields a well-formed envelope the ledger refuses with BAD_AUTH.
197
- const sig = new Uint8Array(await crypto.subtle.sign(
198
- "Ed25519", signingKey, X.signaturePreimage(hash)));
199
- const rec = await loadKey();
200
- const signed = X.addSignature(pending.xdrText, sig, base64urlToBytes(rec.x));
201
-
202
- if (pending.fromDesk) {
203
- const res = await fetch("/signed/" + encodeURIComponent(pending.id) +
204
- (pending.k ? "?k=" + encodeURIComponent(pending.k) : ""), {
205
- method: "POST",
206
- headers: { "content-type": "application/json" },
207
- body: JSON.stringify({ signedXdr: signed }),
208
- });
209
- const out = await res.json().catch(() => ({}));
210
- if (out.status === "settled") {
211
- // Show the ledger hash and keep the panel up: this is the only proof
212
- // the human gets that the payment actually landed. reset() would hide
213
- // it again, so clear the form by hand instead.
214
- pending = null;
215
- $("xdr").value = "";
216
- $("review").classList.add("hidden");
217
- $("signed").textContent = "settled on ledger\n" + (out.tx || "");
218
- $("result").classList.remove("hidden");
219
- return;
220
- }
221
- $("signErr").textContent = "the desk refused it: " +
222
- (out.reason || out.error || out.status || res.status);
223
- return;
224
- }
225
- $("signed").textContent = signed;
226
- $("result").classList.remove("hidden");
391
+ await enrol($("seed").value);
392
+ paintKeyState();
393
+ refreshQueue();
227
394
  } catch (e) {
228
- $("signErr").textContent = e.message;
395
+ err("enrolErr", e.message);
229
396
  }
230
397
  });
231
-
398
+ $("forget").addEventListener("click", async () => {
399
+ await idbDel();
400
+ localStorage.removeItem("paydlux.pub");
401
+ approverKey = null; approverPublic = null;
402
+ paintKeyState();
403
+ });
404
+ $("linkBtn").addEventListener("click", () => {
405
+ const raw = $("link").value.trim();
406
+ if (!raw) return;
407
+ const u = new URL(raw, location.origin);
408
+ if (!u.searchParams.get("k")) { err("linkErr", "that link has no approval key in it"); return; }
409
+ localStorage.setItem(LINK, u.pathname + "?" + u.searchParams.toString());
410
+ err("linkErr", "");
411
+ paintLinkState();
412
+ refreshQueue();
413
+ });
414
+ $("approve").addEventListener("click", signAndSubmit);
415
+ $("reject").addEventListener("click", () => current && reject(current.id) && backToQueue());
416
+ $("again").addEventListener("click", backToQueue);
417
+ $("read").addEventListener("click", readPasted);
232
418
  $("copy").addEventListener("click", async () => {
233
- try {
234
- await navigator.clipboard.writeText($("signed").textContent);
235
- $("copy").textContent = "Copied";
236
- } catch (e) {
237
- $("copy").textContent = "Select the text above and copy it manually";
238
- }
419
+ try { await navigator.clipboard.writeText($("signed").textContent); $("copy").textContent = "Copied"; }
420
+ catch { $("copy").textContent = "Copy failed \u2014 select the text"; }
421
+ setTimeout(() => { $("copy").textContent = "Copy"; }, 1500);
239
422
  });
240
423
 
241
- // On reload, pull the non-extractable CryptoKey back out of IndexedDB. It can
242
- // sign; it cannot be exported. The seed itself is never stored anywhere.
243
- (async () => {
244
- try {
245
- const rec = await loadKey();
246
- if (rec && rec.key instanceof CryptoKey) {
247
- signingKey = rec.key;
248
- await showReady(base64urlToBytes(rec.x));
249
- }
250
- } catch (e) { /* enrolment UI stays up */ }
251
- await loadFromDesk();
252
- })();
253
- })();
424
+ if (key()) {
425
+ await refreshQueue();
426
+ queueTimer = setInterval(refreshQueue, 4000);
427
+ const id = new URLSearchParams(localStorage.getItem(LINK)?.split("?")[1] || "").get("id");
428
+ if (id) openReview(id);
429
+ }
430
+ }
431
+
432
+ boot().catch((e) => { err("qerr", e.message); });
Binary file
Binary file
@@ -4,16 +4,22 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
6
  <meta name="theme-color" content="#0b0f14">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <meta name="apple-mobile-web-app-title" content="Paydlux">
7
10
  <title>Paydlux Approver</title>
11
+ <link rel="manifest" href="manifest.webmanifest">
12
+ <link rel="icon" href="icon-192.png" sizes="192x192">
13
+ <link rel="apple-touch-icon" href="icon-192.png">
8
14
  <!--
9
- The approver half of a Paydlux vault.
15
+ The approver half of a Paydlux vault: a phone app, not a page you paste into.
10
16
 
11
- This page holds ONE key: the approver's. It cannot spend anything by itself.
12
- It reads a pending payment, shows a human what they are approving, and signs
13
- the transaction hash. Signing is a purely local operation: no network call can
14
- change what was signed.
17
+ This app holds ONE key: the approver's. It cannot spend anything by itself.
18
+ It lists the payments waiting on you, shows a human what they are approving,
19
+ and signs the transaction hash. Signing is a purely local operation: no
20
+ network call can change what was signed.
15
21
 
16
- The page talks to one origin only — the desk that served it — to fetch the
22
+ The app talks to one origin only — the desk that served it — to fetch the
17
23
  pending transaction and hand back the signature. connect-src 'self' in the
18
24
  desk's Content-Security-Policy enforces that.
19
25
 
@@ -28,7 +34,10 @@
28
34
  transaction without reading it. The human reading the amount and payee is part
29
35
  of the security model, not a UX step.
30
36
 
31
- Serve it over http(s) on a device you control: npx tsx tools/phone-serve.ts
37
+ The static shell (this file, app.js, xdr.js, the icons) carries no secrets and
38
+ no payment data, so the desk serves it without the portal key — that is what
39
+ lets it be installed to a home screen. Every route that carries data or moves
40
+ money still needs the key.
32
41
  -->
33
42
  <style>
34
43
  :root {
@@ -67,13 +76,30 @@
67
76
  .tag { display:inline-block; font-size:11px; padding:3px 8px; border-radius:999px; letter-spacing:.06em; text-transform:uppercase }
68
77
  .tag.ok { background:rgba(61,220,151,.14); color:var(--ok) }
69
78
  .tag.warn { background:rgba(255,180,84,.14); color:var(--warn) }
70
- .tag.bad { background:rgba(255,107,107,.14); color:var(--bad) }
79
+ .tag.bad { background:rgba(257,107,107,.14); color:var(--bad) }
71
80
  .err { color:var(--bad); font-size:13px; margin-top:8px; word-break:break-word }
72
81
  .hint { color:var(--dim); font-size:12px; margin-top:8px }
73
82
  .hidden { display:none }
74
83
  .out { background:#0d131a; border:1px solid var(--line); border-radius:10px; padding:10px;
75
84
  font:12px/1.4 ui-monospace,Menlo,monospace; word-break:break-all; max-height:180px; overflow:auto }
76
85
  .who { font-family:ui-monospace,Menlo,monospace; font-size:12px; color:var(--dim); word-break:break-all }
86
+
87
+ /* ------------------------------------------------------------ queue */
88
+ .qhead { display:flex; align-items:baseline; justify-content:space-between; margin-bottom:6px }
89
+ .qhead h2 { font-size:13px; margin:0; text-transform:uppercase; letter-spacing:.06em; color:var(--dim) }
90
+ .qhead .when { font-size:11px; color:var(--dim) }
91
+ .item { border:1px solid var(--line); border-radius:12px; padding:12px; margin-top:10px; background:#0d131a }
92
+ .item .top { display:flex; justify-content:space-between; align-items:baseline; gap:10px }
93
+ .item .amt { font-size:22px; font-weight:800; font-variant-numeric:tabular-nums }
94
+ .item .uri { font-size:12px; color:var(--dim); word-break:break-all; margin-top:2px }
95
+ .item .wants { font-size:13px; margin-top:6px; word-break:break-word }
96
+ .item .foot { display:flex; justify-content:space-between; align-items:center; gap:10px; margin-top:8px }
97
+ .item .ago { font-size:11px; color:var(--dim) }
98
+ .item .btns { display:flex; gap:8px }
99
+ .item .btns button { width:auto; margin:0; padding:9px 16px; font-size:14px }
100
+ .empty { color:var(--dim); font-size:14px; padding:14px 2px }
101
+ .hist { opacity:.62 }
102
+ .hist .amt { font-size:16px }
77
103
  </style>
78
104
  </head>
79
105
  <body>
@@ -104,21 +130,38 @@
104
130
  <button class="ghost" id="forget">Remove key from this device</button>
105
131
  </div>
106
132
 
107
- <!-- --------------------------------------------------------- approval -->
108
- <div class="panel">
109
- <label for="xdr">Pending transaction (paste the XDR from the seller)</label>
110
- <textarea id="xdr" autocorrect="off" autocapitalize="off" spellcheck="false"
111
- placeholder="Paste the transaction the seller sent you"></textarea>
112
- <button id="read">Read it</button>
113
- <div class="err" id="readErr"></div>
133
+ <!-- -------------------------------------------------------- not linked -->
134
+ <div class="panel hidden" id="nolink">
135
+ <div class="tag bad">No approval link</div>
136
+ <div class="hint" style="margin-top:10px">
137
+ This app shows payments only to whoever holds the seller's approval link. Open the
138
+ link the seller sent you once &mdash; the app then remembers it on this device.
139
+ </div>
140
+ <label for="link">Approval link</label>
141
+ <input id="link" type="url" autocorrect="off" autocapitalize="off" spellcheck="false"
142
+ placeholder="https://desk.paydlux.com/phone/?k=...">
143
+ <button id="linkBtn">Use this link</button>
144
+ <div class="err" id="linkErr"></div>
114
145
  </div>
115
146
 
147
+ <!-- ------------------------------------------------------------- queue -->
148
+ <div class="panel hidden" id="queue">
149
+ <div class="qhead">
150
+ <h2>Waiting for you</h2>
151
+ <span class="when" id="qwhen"></span>
152
+ </div>
153
+ <div id="qlist"><div class="empty">loading&hellip;</div></div>
154
+ <div class="err" id="qerr"></div>
155
+ </div>
156
+
157
+ <!-- ----------------------------------------------------------- review -->
116
158
  <div class="panel hidden" id="review">
117
159
  <div class="amount" id="amount"></div>
118
160
  <dl class="kv">
119
161
  <dt>Token</dt><dd id="token"></dd>
120
162
  <dt>Payee</dt><dd id="payee"></dd>
121
163
  <dt>From</dt><dd id="vault"></dd>
164
+ <dt>Wants</dt><dd id="wants"></dd>
122
165
  <dt>Reference</dt><dd id="memo"></dd>
123
166
  <dt>Deadline</dt><dd id="deadline"></dd>
124
167
  <dt>Network</dt><dd id="network"></dd>
@@ -133,10 +176,30 @@
133
176
  <div class="err" id="signErr"></div>
134
177
  </div>
135
178
 
136
- <!-- ----------------------------------------------------------- result -->
179
+ <!-- --------------------------------------------------------- pasted xdr -->
180
+ <!-- Hidden until there is nothing in the queue. A human who just imported a key
181
+ should see their payments, not a box asking them to paste 400 characters of
182
+ base64 they were never asked for. This path is the fallback for when the
183
+ seller sends a transaction by message instead of a link. -->
184
+ <div class="panel hidden" id="paste">
185
+ <details>
186
+ <summary style="font-size:13px;color:var(--dim)">Sign a transaction the seller sent by hand</summary>
187
+ <label for="xdr">Pending transaction (XDR)</label>
188
+ <textarea id="xdr" autocorrect="off" autocapitalize="off" spellcheck="false"
189
+ placeholder="Paste the transaction the seller sent you"></textarea>
190
+ <button id="read">Read it</button>
191
+ <div class="hint">
192
+ A hand-sent transaction has no trusted source, so the app will make you type back
193
+ the hash the seller quoted before it signs.
194
+ </div>
195
+ <div class="err" id="readErr"></div>
196
+ </details>
197
+ </div>
198
+
199
+ <!-- ------------------------------------------------------------ result -->
137
200
  <div class="panel hidden" id="result">
138
- <div class="tag ok">Signed</div>
139
- <label>Signed transaction &mdash; send this back to the seller</label>
201
+ <div class="tag ok" id="resultTag">Signed</div>
202
+ <label id="resultLabel">Signed transaction &mdash; send this back to the seller</label>
140
203
  <div class="out" id="signed"></div>
141
204
  <button id="copy">Copy</button>
142
205
  <button class="ghost" id="again">Done</button>
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "Paydlux Approver",
3
+ "short_name": "Paydlux",
4
+ "description": "Signs Paydlux payment approvals on this device. The approver key never leaves it.",
5
+ "start_url": "/phone/",
6
+ "scope": "/phone/",
7
+ "display": "standalone",
8
+ "orientation": "portrait",
9
+ "background_color": "#0b0f14",
10
+ "theme_color": "#0b0f14",
11
+ "icons": [
12
+ { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
13
+ { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
14
+ ]
15
+ }
package/phone-app/xdr.js CHANGED
@@ -172,6 +172,49 @@
172
172
  * The public half is captured at enrolment and stored separately.
173
173
  */
174
174
  async function importSecretSeed(seedStr) {
175
+ const pkcs8 = pkcs8FromSeed(seedStr);
176
+ const privateKey = await crypto.subtle.importKey(
177
+ "pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]);
178
+ pkcs8.fill(0); // drop the plaintext copy as soon as we can
179
+ return privateKey;
180
+ }
181
+
182
+ /**
183
+ * Import a seed AND read back its public half, in one step.
184
+ *
185
+ * A non-extractable key cannot be asked for its own public bytes, and the
186
+ * approver app needs those bytes to add its signature to a transaction. So the
187
+ * seed is imported a second time as an extractable key purely to export the
188
+ * 32-byte public half; that temporary key is discarded immediately and never
189
+ * stored. What the app keeps is the non-extractable key plus a public value
190
+ * that is public anyway.
191
+ *
192
+ * The export goes through JWK rather than "raw": WebCrypto defines raw only for
193
+ * OKP *public* keys, so exporting raw from a private key is refused (verified
194
+ * on Node 26). The JWK form of an Ed25519 private key carries the public half
195
+ * in `x`, base64url.
196
+ */
197
+ async function importSecretSeedPair(seedStr) {
198
+ const key = await importSecretSeed(seedStr);
199
+ const tmp = await crypto.subtle.importKey(
200
+ "pkcs8", pkcs8FromSeed(seedStr), { name: "Ed25519" }, true, ["sign"]);
201
+ let pub;
202
+ try {
203
+ const jwk = await crypto.subtle.exportKey("jwk", tmp);
204
+ pub = base64UrlToBytes(jwk.x);
205
+ } catch {
206
+ pub = new Uint8Array(await crypto.subtle.exportKey("raw", tmp));
207
+ }
208
+ if (pub.length !== 32) throw new Error("unexpected public key length " + pub.length);
209
+ return { key, pub, address: encodeAccountEd25519(pub) };
210
+ }
211
+
212
+ function base64UrlToBytes(s) {
213
+ if (typeof s !== "string" || !s) throw new Error("missing public key in the exported key");
214
+ return base64ToBytes(s.replace(/-/g, "+").replace(/_/g, "/") + "==".slice(0, (4 - s.length % 4) % 4));
215
+ }
216
+
217
+ function pkcs8FromSeed(seedStr) {
175
218
  const seed = decodeSecretSeed(seedStr);
176
219
  const pkcs8 = new Uint8Array(16 + 32);
177
220
  pkcs8.set([
@@ -179,10 +222,7 @@
179
222
  0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
180
223
  ], 0);
181
224
  pkcs8.set(seed, 16);
182
- const privateKey = await crypto.subtle.importKey(
183
- "pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]);
184
- seed.fill(0); // drop the plaintext copy as soon as we can
185
- return privateKey;
225
+ return pkcs8;
186
226
  }
187
227
 
188
228
  // ------------------------------------------------------------------ parsing
@@ -429,7 +469,7 @@
429
469
  parseEnvelope, transactionHash, addSignature, formatAmount,
430
470
  hex, hexToBytes, signaturePreimage,
431
471
  encodeAccountEd25519, decodeAccountEd25519,
432
- decodeSecretSeed, encodeSecretSeed, importSecretSeed,
472
+ decodeSecretSeed, encodeSecretSeed, importSecretSeed, importSecretSeedPair,
433
473
  NETWORK_ID, crc16Xmodem,
434
474
  ENVELOPE_TYPE_TX, OP_PAYMENT, STROOPS,
435
475
  };