@paydlux/agent 0.1.14 → 0.1.16

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/dist/desk.js CHANGED
@@ -175,14 +175,24 @@ export class ApprovalDesk {
175
175
  */
176
176
  sweepExpired() {
177
177
  const now = Date.now();
178
+ // A request nobody has even opened yet gets the same window as one the
179
+ // phone is holding: the seller said how long a human has to decide, and a
180
+ // parked request past that window is not going to be answered. Leaving it
181
+ // `pending` forever means the buyer's retry keeps reusing a handle that
182
+ // will never move, instead of parking a fresh one a human will actually see.
183
+ const decideWindow = (this.cfg.decideWithinSeconds ?? 1800) * 1000;
178
184
  for (const r of this.records.values()) {
179
- if (r.status !== "awaiting_phone" && r.status !== "approved")
180
- continue;
181
- const exp = r.challenge?.summary.expiresAt;
182
- if (!exp || Number(exp) * 1000 > now)
185
+ let deadline = null;
186
+ if (r.status === "pending") {
187
+ deadline = r.at + decideWindow;
188
+ }
189
+ else if (r.status === "awaiting_phone" || r.status === "approved") {
190
+ deadline = r.challenge?.summary.expiresAt ? Number(r.challenge.summary.expiresAt) * 1000 : null;
191
+ }
192
+ if (deadline === null || deadline > now)
183
193
  continue;
184
194
  r.status = "expired";
185
- r.reason = `nobody approved it before the deadline (${new Date(Number(exp) * 1000).toISOString()})`;
195
+ r.reason = `nobody approved it before the deadline (${new Date(deadline).toISOString()})`;
186
196
  console.log(`[desk] ${r.id} expired: the deadline passed with no approval`);
187
197
  }
188
198
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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
+ "phone-app/verify.js",
20
+ "phone-app/sw.js",
19
21
  "phone-app/app.css",
20
22
  "phone-app/manifest.webmanifest",
21
23
  "phone-app/icon-192.png",
@@ -41,7 +43,7 @@
41
43
  "test:stellar": "tsx stellar/demo.ts",
42
44
  "test:portal": "tsx stellar/xss-check.ts",
43
45
  "test:mandate": "tsx stellar/mandate-check.ts",
44
- "test:phone": "tsx stellar/phone-check.ts && tsx stellar/desk-split-check.ts && tsx stellar/mandate-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 && tsx ios/bridge-check.ts",
46
+ "test:phone": "tsx stellar/phone-check.ts && tsx stellar/desk-split-check.ts && tsx stellar/mandate-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 && tsx ios/bridge-check.ts && tsx phone-app/ui-check.ts",
45
47
  "test:ios": "tsx ios/bridge-check.ts",
46
48
  "test:link": "swiftc ios/PaydluxApprover/LinkResolver.swift ios/Checks/linkcheck/main.swift -o \"$TMPDIR/linkcheck\" && \"$TMPDIR/linkcheck\"",
47
49
  "test:seed": "DEVELOPER_DIR=/Library/Developer/CommandLineTools /usr/bin/swiftc -target arm64-apple-macosx14.0 ios/PaydluxApprover/Seed.swift ios/PaydluxApprover/Hex.swift ios/Checks/main.swift -o \"$TMPDIR/seedcheck\" && \"$TMPDIR/seedcheck\"",
package/phone-app/app.js CHANGED
@@ -19,9 +19,14 @@
19
19
 
20
20
  const X = globalThis.PaydluxXdr;
21
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 || ""; };
22
+ // Null-safe on purpose. `$(id).classList` on a missing id throws, and a throw
23
+ // here aborts the rest of the boot sequence — which is how one stale id in
24
+ // paintKeyState left the queue panel permanently hidden and the Approve buttons
25
+ // unreachable in the native app. A missing element is a cosmetic miss, not a
26
+ // reason to stop rendering the queue.
27
+ const show = (id, on = true) => { const el = $(id); if (el) el.classList.toggle("hidden", !on); };
28
+ const set = (id, v) => { const el = $(id); if (el) el.textContent = v == null || v === "" ? "\u2014" : String(v); };
29
+ const err = (id, msg) => { const el = $(id); if (el) el.textContent = msg || ""; };
25
30
 
26
31
  // A short buzz on the two moments that matter. iOS ignores vibrate(); Android
27
32
  // and desktop honour it. Guarded so it is never an uncaught error.
@@ -188,8 +193,11 @@ function paintKeyState() {
188
193
  if (have) {
189
194
  set("who", approverPublic || "held by this app");
190
195
  // In the native app the seed lives in the Keychain and the page has no way
191
- // to write it, so the paste box would be a lie.
192
- if (native.available()) show("seedRow", false);
196
+ // to write it, so the paste box would be a lie — and the remove button
197
+ // worse: idbDel() below clears the browser key, which does not exist here,
198
+ // so it would look like it worked while the real key stayed on the device.
199
+ // Removal belongs to the app (its menu, and the enrol screen), not the page.
200
+ if (native.available()) show("forget", false);
193
201
  }
194
202
  }
195
203
 
@@ -0,0 +1,80 @@
1
+ // Paydlux approver — offline shell cache.
2
+ //
3
+ // What this caches, and what it must never cache:
4
+ //
5
+ // CACHED index.html, app.js, xdr.js, the icons, the manifest. These are the
6
+ // app itself: client code with no secret and no payment data in it,
7
+ // byte-identical for every visitor. Caching them is what lets the
8
+ // installed app launch from the home screen with no signal and still
9
+ // show its own key, instead of a Safari error page.
10
+ //
11
+ // NEVER /queue, /challenge/*, /signed/*, /approve, /deny, /list, /healthz,
12
+ // or anything carrying ?k=. Those hold payment amounts, payee
13
+ // addresses, signed transactions and the portal key. A payment
14
+ // answer left in a cache is a payment answer a later request could
15
+ // be shown instead of a live one — and approving a stale challenge is
16
+ // exactly the failure this app exists to prevent.
17
+ //
18
+ // Strategy: network-first for the shell, so an online phone always runs the
19
+ // current code (a security fix must reach it on the next open, not the next
20
+ // reinstall). Cache is the fallback when the network is not there at all.
21
+ const SHELL = [
22
+ "./",
23
+ "./app.js",
24
+ "./xdr.js",
25
+ "./verify.js",
26
+ "./manifest.webmanifest",
27
+ "./icon-192.png",
28
+ "./icon-512.png",
29
+ ];
30
+ const CACHE = "paydlux-approver-v3";
31
+
32
+ // A path is cacheable only if it is one of the shell files, by exact name.
33
+ // Everything else — including any future API route — falls through untouched.
34
+ const isShell = (url) => {
35
+ if (url.search !== "") return false; // any query = never cached
36
+ const file = url.pathname.replace(/^.*\/phone\//, "").replace(/\/$/, "") || "index.html";
37
+ return ["", "index.html", "app.js", "xdr.js", "verify.js", "manifest.webmanifest",
38
+ "icon-192.png", "icon-512.png"].includes(file);
39
+ };
40
+
41
+ self.addEventListener("install", (e) => {
42
+ e.waitUntil((async () => {
43
+ const c = await caches.open(CACHE);
44
+ // Individual puts, not addAll: one missing icon must not abort the shell.
45
+ for (const path of SHELL) {
46
+ try { await c.add(new Request(path, { cache: "reload" })); } catch { /* keep going */ }
47
+ }
48
+ self.skipWaiting();
49
+ })());
50
+ });
51
+
52
+ self.addEventListener("activate", (e) => {
53
+ e.waitUntil((async () => {
54
+ // Drop every older shell cache. An old app.js sitting around is old signing
55
+ // code, and signing code is the one thing that must not go stale.
56
+ for (const k of await caches.keys()) if (k !== CACHE) await caches.delete(k);
57
+ self.clients.claim();
58
+ })());
59
+ });
60
+
61
+ self.addEventListener("fetch", (e) => {
62
+ const url = new URL(e.request.url);
63
+ if (e.request.method !== "GET" || url.origin !== self.location.origin || !isShell(url)) return;
64
+ e.respondWith((async () => {
65
+ try {
66
+ const fresh = await fetch(e.request, { cache: "reload" });
67
+ if (fresh.ok) {
68
+ const copy = fresh.clone();
69
+ const c = await caches.open(CACHE);
70
+ await c.put(url.pathname, copy);
71
+ }
72
+ return fresh;
73
+ } catch {
74
+ return (await caches.match(url.pathname))
75
+ // No cached shell either: answer plainly rather than let the browser
76
+ // show its own error page over the app.
77
+ || new Response("offline", { status: 503, headers: { "content-type": "text/plain" } });
78
+ }
79
+ })());
80
+ });
@@ -0,0 +1,48 @@
1
+ // Did the key this screen showed actually sign these bytes?
2
+ //
3
+ // Why this exists: the app asks something else to produce a signature — the
4
+ // browser's WebCrypto, or the iOS app's Keychain-backed signer over a script
5
+ // bridge. Anything that returns bytes without proving them is a black box. A
6
+ // bridge that returned a signature from a different key than the one the review
7
+ // screen named would have it attached to a real transaction and sent to the
8
+ // ledger as if the human had approved it with the key they were shown.
9
+ //
10
+ // So the app checks the signature against the public key it displayed, over the
11
+ // bytes it asked to be signed, before anything is sent. If the check fails,
12
+ // nothing goes out and the human is told.
13
+ //
14
+ // Implementation note: this deliberately uses the platform's Ed25519 verify,
15
+ // not a hand-written one. The app already depends on WebCrypto Ed25519 to sign
16
+ // (see importSecretSeedPair in xdr.js), so verifying adds no new dependency —
17
+ // and a second hand-rolled curve implementation inside a payment approver would
18
+ // be a second place to be wrong, in the one component whose whole job is to be
19
+ // right about who signed.
20
+ "use strict";
21
+
22
+ /**
23
+ * verifySignature(sigBytes, hashHex, pubBytes) -> Promise<boolean>
24
+ *
25
+ * A Stellar approver signature covers the 32-byte transaction hash (that is
26
+ * what signaturePreimage returns, and what the ledger verifies), so the app
27
+ * verifies over the identical bytes it asked to be signed.
28
+ */
29
+ async function verifySignature(sig, hashHex, pub) {
30
+ const X = globalThis.PaydluxXdr;
31
+ if (!(sig instanceof Uint8Array) || sig.length !== 64) return false;
32
+ if (!(pub instanceof Uint8Array) || pub.length !== 32) return false;
33
+ if (!/^[0-9a-f]{64}$/.test(hashHex)) return false;
34
+ try {
35
+ // The public key is imported as a verify-only key: it cannot sign, and it
36
+ // cannot be exported into something that could.
37
+ const key = await crypto.subtle.importKey(
38
+ "raw", pub, { name: "Ed25519" }, false, ["verify"]);
39
+ return await crypto.subtle.verify(
40
+ "Ed25519", key, sig, X.signaturePreimage(hashHex));
41
+ } catch {
42
+ // A platform without WebCrypto Ed25519 cannot verify, and must not pretend
43
+ // it did. Callers treat false as "nothing was sent".
44
+ return false;
45
+ }
46
+ }
47
+
48
+ globalThis.PaydluxVerify = { verifySignature };