@paydlux/agent 0.1.8 → 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/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); });