@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/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
@@ -14,7 +14,8 @@
14
14
  // A seller should type one command and get a price, a link, and a key to paste
15
15
  // into their bot.
16
16
  import { createInterface } from "node:readline/promises";
17
- import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
17
+ import { existsSync, readFileSync, writeFileSync, chmodSync, realpathSync } from "node:fs";
18
+ import { pathToFileURL } from "node:url";
18
19
  import { resolve } from "node:path";
19
20
  import { Keypair } from "@stellar/stellar-sdk";
20
21
  import { StellarVault, USDC_ISSUER } from "./vault.js";
@@ -406,6 +407,49 @@ removed from ${path}. this machine can no longer approve anything by itself.
406
407
  sell with: paydlux sell --split --tls
407
408
  `);
408
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
+ }
409
453
  // ------------------------------------------------------------------------- main
410
454
  const HELP = `paydlux — get paid by AI agents, with a human in the loop.
411
455
 
@@ -418,6 +462,9 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
418
462
  paydlux approver [--forget]
419
463
  print the human half of the vault, then optionally delete it from this
420
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.
421
468
 
422
469
  one file holds it all: ./paydlux-vault.json (0600, never commit it)
423
470
  `;
@@ -435,9 +482,17 @@ export async function main() {
435
482
  return status();
436
483
  if (cmd === "approver")
437
484
  return approver();
485
+ if (cmd === "withdraw")
486
+ return withdraw();
438
487
  bad(`unknown command "${cmd}".\n\n${HELP}`);
439
488
  }
440
- // Only run when this file is the entry point, so importing it in a test is safe.
441
- if (process.argv[1] && /cli\.(ts|js)$/.test(process.argv[1])) {
489
+ // Run only when this file is the entry point, so importing it in a test is safe.
490
+ // Compare realpaths, not the invocation string: npm's bin launcher runs this as
491
+ // .../node_modules/.bin/paydlux, which does not end in .js, so a name-based test
492
+ // silently turns the published `paydlux` command into a no-op.
493
+ const invoked = process.argv[1]
494
+ ? pathToFileURL(existsSync(process.argv[1]) ? realpathSync(process.argv[1]) : process.argv[1]).href
495
+ : undefined;
496
+ if (invoked === import.meta.url) {
442
497
  main().catch((e) => { console.error("failed:", e.message); process.exit(1); });
443
498
  }
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.8",
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",