@paydlux/agent 0.1.22 → 0.1.24

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
@@ -7,6 +7,9 @@ real money has already been drained this way.
7
7
 
8
8
  Paydlux puts the guardrails **on the ledger** instead of in your code.
9
9
 
10
+ **Homepage: [paydlux.com](https://paydlux.com)** — product, pricing and the
11
+ live demo. Source: this repo. Docs and setup: below.
12
+
10
13
  ## The Stellar path (primary — no smart contract, no faucet, no gas fight)
11
14
 
12
15
  There is no contract to audit. The whole mechanism is three native Stellar
@@ -30,6 +33,7 @@ injection cannot talk its way past a signature check.
30
33
  stellar/vault.ts create a 2-of-2 vault, register mandates, settle, revoke
31
34
  stellar/gateway.ts HTTP 402 resource server; serves only after the ledger agrees
32
35
  stellar/agent.ts the buyer side — one class, one method call
36
+ stellar/mcp.ts the same buyer side as an MCP server, for any agent client
33
37
  stellar/desk.ts approval queue: a human decides, in the clear
34
38
  stellar/portal.ts the page that human looks at
35
39
  stellar/policy.ts daily budget / seller allowlist (the part a ledger can't count)
@@ -75,6 +79,57 @@ What the desk enforces once it is public (`npx tsx stellar/tls-check.ts`, 19 che
75
79
 
76
80
  No wallet, no faucet, no config.
77
81
 
82
+ ### MCP server: your agent calls Paydlux instead of a wallet
83
+
84
+ The buyer side also speaks [Model Context Protocol](https://modelcontextprotocol.io),
85
+ so an agent in Claude, Cursor, LobeHub, Codex or any other MCP client gets Paydlux's
86
+ tools directly — no wallet, no signing library, no payment code in the agent:
87
+
88
+ ```bash
89
+ npx -y @paydlux/agent mcp --file /absolute/path/to/paydlux-vault.json
90
+ ```
91
+
92
+ Add it to the client the same way you add any stdio server:
93
+
94
+ ```json
95
+ {
96
+ "mcpServers": {
97
+ "paydlux": {
98
+ "command": "npx",
99
+ "args": ["-y", "@paydlux/agent", "mcp", "--file", "/absolute/path/to/paydlux-vault.json"]
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ | tool | what the agent gets |
106
+ |---|---|
107
+ | `paydlux_status` | which vault, which token, live balance, who can sign, how many mandates are live |
108
+ | `paydlux_payment_rules` | the ceiling the human set — plus what the ledger says is already spent today |
109
+ | `paydlux_check_payment` | dry run: would *this* amount to *this* seller pass, and which rule breaks if not |
110
+ | `paydlux_buy` | pay a 402-gated seller. If a human has to approve, the answer is a handle and a status URL |
111
+ | `paydlux_payment_status` | has the human decided yet |
112
+ | `paydlux_recent_payments` | the last payments in/out, with transaction hashes an explorer can check |
113
+ | `paydlux_escrow_status` / `paydlux_pending_escrows` | what is locked in escrow, and who can still take it back |
114
+
115
+ Two things this server deliberately cannot do:
116
+
117
+ - **It cannot release money on its own.** It loads the agent key and nothing
118
+ else — one signer of weight 1 against a release threshold of 2. There is no
119
+ flag that accepts the principal or approver key, so it cannot be talked into
120
+ using a key it never holds. A payment the mandate does not cover comes back as
121
+ `needs_approval`, with the handle and the desk link for the human.
122
+ - **It cannot read a carelessly stored key file.** If the vault file is readable
123
+ by other users, the server refuses to start until you `chmod 600` it, and no
124
+ tool result will ever print a secret seed.
125
+
126
+ Run it with `--read-only` and the two tools that touch money disappear from the
127
+ tool listing entirely — useful for an agent that should report on spend but not
128
+ cause any. `npx tsx stellar/mcp-check.ts` drives the server with a real MCP
129
+ client and asserts all of the above (20 checks, testnet).
130
+
131
+ Product page, pricing and the hosted demo: **[https://paydlux.com](https://paydlux.com)**.
132
+
78
133
  ### The human key does not live on the seller's machine
79
134
 
80
135
  The vault needs two signatures. If both keys sit in one process, that is really
@@ -242,6 +297,31 @@ too, because a budget that silently reads as unspent is worse than no budget.
242
297
  The banner shows what is left: `policy: max 10 per payment, 50 per day (27.50
243
298
  left today)`.
244
299
 
300
+ ### Spend it by category, and edit the rules in a browser
301
+
302
+ One vault, several pockets. Open **spending rules** on the approvals page (or
303
+ `/rules` on the desk) and set, per named category, its own per-payment ceiling and
304
+ daily budget — then say which seller wallet belongs to which category:
305
+
306
+ ```
307
+ data-api max 3 / payment 6 / day ← api.example.com wallet
308
+ coding max 1 / payment 2 / day ← assistant wallet
309
+ ```
310
+
311
+ A category can only be **tighter** than the limits above it; it can never raise
312
+ them, because the base ceilings are checked first. That asymmetry is the security
313
+ property, and `npm run test:policy` proves it, along with the queue counting
314
+ inside a category and the fact that a form posted from another site cannot raise
315
+ your budget.
316
+
317
+ Edits are saved to the vault's config file, so they survive a restart. Once you
318
+ have edited them there, the saved numbers win over `--max`/`--daily` on the next
319
+ start, and the seller prints which it is using — a rule that quietly reverts by
320
+ morning is worse than no rule.
321
+
322
+ `stellar/policy.ts` is the engine; the page is a plain HTML form, so it works with
323
+ JavaScript off.
324
+
245
325
  ### What is enforced where (said honestly)
246
326
 
247
327
  | enforced by Stellar | enforced by the desk (`policy.ts`) |
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@
15
15
  // into their bot.
16
16
  import { createInterface } from "node:readline/promises";
17
17
  import { createServer } from "node:http";
18
- import { execSync } from "node:child_process";
18
+ import { execSync, spawn } from "node:child_process";
19
19
  import { existsSync, readFileSync, writeFileSync, chmodSync, realpathSync } from "node:fs";
20
20
  import { pathToFileURL } from "node:url";
21
21
  import { resolve } from "node:path";
@@ -29,6 +29,7 @@ import { DEMO_HOST, DEMO_ROUTE, startDemo } from "./showcase.js";
29
29
  import { DONATE_HOST, DONATE_PORT, startDonate } from "./donate.js";
30
30
  import { claimEscrow, pendingEscrows, prepareEscrowRelease, refundEscrow } from "./escrow.js";
31
31
  import { describeChallenge } from "./phone.js";
32
+ import { start as startMcp } from "./mcp.js";
32
33
  export const VAULT_FILE_DEFAULT = "paydlux-vault.json";
33
34
  const arg = (name) => {
34
35
  const i = process.argv.indexOf(`--${name}`);
@@ -221,7 +222,26 @@ async function sell() {
221
222
  // desk is even built.
222
223
  const maxPer = Number(arg("max") ?? data.policy?.maxPerPayment ?? 2);
223
224
  const daily = Number(arg("daily") ?? data.policy?.dailyBudget ?? 10);
224
- const policy = new PolicyEngine({ maxPerPayment: maxPer, dailyBudget: daily });
225
+ // Categories and the wallet map come from the config file only: they are set
226
+ // at the desk (/rules), not from the command line. A flag for them would be a
227
+ // second, easier way to say the same thing, and the page would drift from it.
228
+ //
229
+ // The same is true of the two ceilings once an owner edits them at the desk:
230
+ // the saved file wins over the flags, because the app scripts pass --max and
231
+ // --daily on every start and would otherwise silently undo whatever was set in
232
+ // the browser. The line printed below is what keeps that from being a
233
+ // surprise — a rule that quietly reverts is worse than no rule at all.
234
+ const saved = data.policy?.savedByOwner === true;
235
+ const maxPerFinal = saved ? Number(data.policy.maxPerPayment ?? maxPer) : maxPer;
236
+ const dailyFinal = saved ? Number(data.policy.dailyBudget ?? daily) : daily;
237
+ if (saved && (maxPerFinal !== maxPer || dailyFinal !== daily)) {
238
+ log(`using the rules you saved at the desk (max ${maxPerFinal}, daily ${dailyFinal}) instead of the command line (${maxPer}/${daily}).\n to change them: open the approvals link and press "spending rules".`);
239
+ }
240
+ const policy = new PolicyEngine({
241
+ maxPerPayment: maxPerFinal, dailyBudget: dailyFinal,
242
+ allowlist: data.policy?.allowlist, denylist: data.policy?.denylist,
243
+ categories: data.policy?.categories, payeeCategory: data.policy?.payeeCategory,
244
+ });
225
245
  // A ceiling below the price blocks every payment, and the failure looks like
226
246
  // a bug in the desk: the buyer waits, the human never sees an approvable
227
247
  // item, and the log says "mandate refused". Say it now, before anything is
@@ -233,16 +253,16 @@ async function sell() {
233
253
  // takes money IN from the buyer, so the ceiling does not apply to it — and
234
254
  // applying it would refuse a perfectly good escrow price for no reason.
235
255
  if (mode === "escrow") {
236
- if (priceNum > maxPer) {
237
- log(`note: --escrow price ${price} is above --max ${maxPer}. That is fine here — escrow money comes FROM the buyer, so your spending mandate never touches it.`);
256
+ if (priceNum > maxPerFinal) {
257
+ log(`note: --escrow price ${price} is above --max ${maxPerFinal}. That is fine here — escrow money comes FROM the buyer, so your spending mandate never touches it.`);
238
258
  }
239
259
  }
240
260
  else {
241
- if (priceNum > maxPer) {
242
- bad(`--price ${price} is above --max ${maxPer}, so every payment would be refused by your own mandate.\n raise --max to at least ${price} (for example --max ${price}) or lower --price.`);
261
+ if (priceNum > maxPerFinal) {
262
+ bad(`--price ${price} is above --max ${maxPerFinal}, so every payment would be refused by your own mandate.\n raise --max to at least ${price} (for example --max ${price}) or lower --price.`);
243
263
  }
244
- if (priceNum > daily) {
245
- bad(`--price ${price} is above --daily ${daily}: one payment would spend the whole day's budget and the second would be refused.\n set --daily to at least ${price}.`);
264
+ if (priceNum > dailyFinal) {
265
+ bad(`--price ${price} is above --daily ${dailyFinal}: one payment would spend the whole day's budget and the second would be refused.\n set --daily to at least ${price}.`);
246
266
  }
247
267
  }
248
268
  const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(keys.principal), approver: keys.approver ? pub(keys.approver) : undefined, agent: pub(keys.agent) }, network);
@@ -267,20 +287,31 @@ async function sell() {
267
287
  + `the daily budget of ${daily} cannot be enforced without it, so nothing was started. `
268
288
  + `Check the network and try again.`);
269
289
  }
270
- policy.seedFromLedger(alreadyPaid);
290
+ policy.seedFromLedger(alreadyPaid, policy.categoryOf(payee));
271
291
  if (alreadyPaid > 0) {
272
- log(`ledger says ${alreadyPaid.toFixed(2)} of the ${daily} daily budget is already spent today`);
273
- if (alreadyPaid >= daily) {
274
- bad(`today's budget is already gone: ${alreadyPaid.toFixed(2)} paid, ${daily} allowed.\n `
292
+ log(`ledger says ${alreadyPaid.toFixed(2)} of the ${dailyFinal} daily budget is already spent today`);
293
+ if (alreadyPaid >= dailyFinal) {
294
+ bad(`today's budget is already gone: ${alreadyPaid.toFixed(2)} paid, ${dailyFinal} allowed.\n `
275
295
  + `the mandate would refuse every payment. Raise --daily or wait for the UTC day to roll over.`);
276
296
  }
277
297
  }
298
+ // Say out loud what the rules are, including the categories, so the numbers
299
+ // an owner set in a browser are visible to whoever starts the seller. A rule
300
+ // nobody can see is a rule nobody trusts.
301
+ const catNames = Object.keys(policy.policy.categories ?? {});
302
+ if (catNames.length) {
303
+ const mapped = Object.entries(policy.policy.payeeCategory ?? {});
304
+ log(`spending categories: ${catNames.map((c) => {
305
+ const r = policy.policy.categories[c];
306
+ return `${c} (${r.maxPerPayment ?? policy.policy.maxPerPayment}/payment, ${r.dailyBudget ?? dailyFinal}/day)`;
307
+ }).join(", ")}${mapped.length ? ` · ${mapped.length} wallet(s) mapped` : ""}`);
308
+ }
278
309
  if (network === "public") {
279
310
  console.log(`
280
311
  MAINNET — real money.
281
312
  token ${data.token.code}
282
- max per payment ${maxPer}
283
- daily budget ${daily}
313
+ max per payment ${maxPerFinal}
314
+ daily budget ${dailyFinal}
284
315
  vault ${vault.address}
285
316
  balance ${await vault.balance()} ${data.token.code}
286
317
  `);
@@ -337,6 +368,25 @@ async function sell() {
337
368
  ],
338
369
  // The banner prints these numbers. This line is what makes them true.
339
370
  policy,
371
+ // Rules edited at the desk must survive the next restart, or the mandate in
372
+ // the banner is a promise that lasts until ctrl-c. Written back to the same
373
+ // config file, with the same 0600 as everywhere else in this file, because
374
+ // it also holds key material.
375
+ onPolicyChange: (p) => {
376
+ data.policy = {
377
+ ...(data.policy ?? {}),
378
+ // The marker that says "a human set these in a browser". Without it the
379
+ // app scripts' --max/--daily flags win on every restart and the edit is
380
+ // lost by morning.
381
+ savedByOwner: true,
382
+ maxPerPayment: p.maxPerPayment, dailyBudget: p.dailyBudget,
383
+ allowlist: p.allowlist ?? [], denylist: p.denylist ?? [],
384
+ categories: p.categories ?? {}, payeeCategory: p.payeeCategory ?? {},
385
+ };
386
+ writeFileSync(path, JSON.stringify(data, null, 2));
387
+ chmodSync(path, 0o600);
388
+ log(`rules saved to ${path}`);
389
+ },
340
390
  });
341
391
  let tunnel = null;
342
392
  if (tls) {
@@ -372,7 +422,7 @@ async function sell() {
372
422
  bad(`port ${demoPort} (showcase) is already in use by ${who}.`);
373
423
  await startDemo({
374
424
  port: demoPort, price, network, vault: vault.address, token: data.token.code,
375
- explorer: data.explorer ?? "", gateway: gw,
425
+ explorer: data.explorer ?? "", gateway: gw, route: uri, payee,
376
426
  onKeys: (pubs) => { gw.agentKeys = [pub(keys.agent), ...pubs]; },
377
427
  });
378
428
  log(`showcase is listening on 127.0.0.1:${demoPort} for https://${DEMO_HOST}`);
@@ -455,6 +505,257 @@ ${flag("donate") ? ` donations https://${DONATE_HOST} (they pay YOU from THE
455
505
  process.on("SIGINT", bye);
456
506
  process.on("SIGTERM", bye);
457
507
  }
508
+ /**
509
+ * paydlux demo — the funder meeting, as one command.
510
+ *
511
+ * `sell` is the right command for a seller: it takes flags, and it refuses to
512
+ * start when anything is off (port taken, budget already spent). That is correct
513
+ * for a seller and fatal in front of an investor, who is watching a terminal go
514
+ * red thirty seconds before the money shot.
515
+ *
516
+ * This is a preset, not a second implementation. It fixes the two things that
517
+ * actually break a live demo, then runs the SAME sell() — same mandate, same
518
+ * desk, same ledger, same phone signature:
519
+ *
520
+ * 1. A port already held by Paydlux is not an error. It means the demo is
521
+ * already running, so print the links and open the tabs instead of dying.
522
+ * 2. A daily budget already spent today IS fatal (the mandate would refuse
523
+ * every payment), so raise it, save it to the vault file, and say so out
524
+ * loud. Raising the *daily* counter is safe: the per-payment ceiling is
525
+ * still pinned into the signed transaction hash.
526
+ *
527
+ * It then opens the two tabs and prints the runbook, so nobody has to remember
528
+ * a URL or a portal key while someone is watching.
529
+ */
530
+ async function demo() {
531
+ const path = resolve(process.cwd(), arg("file") ?? VAULT_FILE_DEFAULT);
532
+ if (!existsSync(path))
533
+ bad(`no vault here. Run: paydlux setup`);
534
+ const data = JSON.parse(readFileSync(path, "utf8"));
535
+ const network = (data.network ?? "testnet");
536
+ const price = arg("price") ?? data.policy?.defaultPrice ?? "1";
537
+ const ports = [
538
+ Number(arg("port") ?? 4021), Number(arg("desk") ?? 4022),
539
+ Number(arg("demo-port") ?? 4023),
540
+ ];
541
+ // The donation door (4024) is deliberately NOT in this list and NOT started
542
+ // by the demo. It runs against the MAINNET vault, where real people send real
543
+ // USDC. A demo preset that restarted it would point a public money address at
544
+ // a testnet vault, and a donation to a testnet account is gone forever.
545
+ // 1. Something already on those ports? Two different things happened in real
546
+ // meetings and neither may end in a red error:
547
+ // a) the demo IS up and answering — reuse it, just open the tabs;
548
+ // b) a leftover Paydlux (a donation door from this morning, a seller that
549
+ // lost its tunnel) is squatting on one port — the public URLs then 530
550
+ // and `sell` refuses to start. Stop the leftover, say exactly what was
551
+ // stopped, and start a clean demo.
552
+ const live = await surfacesLive();
553
+ if (live.desk && live.demo && !flag("restart") && await surfacesServe(path)) {
554
+ const key = data.desk?.portalKey;
555
+ log("the demo is already running and answering — nothing to start");
556
+ openUrl(`https://${DEMO_HOST}`);
557
+ if (key)
558
+ openUrl(`https://${PUBLIC_HOST}/phone/?k=${key}`);
559
+ demoCard(`https://${PUBLIC_HOST}`, key, data);
560
+ return;
561
+ }
562
+ if (live.desk && live.demo) {
563
+ // Answering, but on a different vault. desk.paydlux.com and demo.paydlux.com
564
+ // are ONE address: a testnet demo and a mainnet demo cannot both be live on
565
+ // it. Reusing whichever happens to be up is how a founder ends up showing
566
+ // play money while believing the real vault is on the screen — or the other
567
+ // way round, with real money moving during a "safe" demo. Stop it, say so,
568
+ // and start the one that was asked for.
569
+ log(`something else is answering ${DEMO_HOST} — stopping it so the ${network} demo can take the address`);
570
+ }
571
+ await clearDemoPorts(ports);
572
+ // 2. Budget headroom. `sell` refuses to start when the ledger says today's
573
+ // budget is gone, and the demo would then die at the worst possible moment.
574
+ // Seed the counter from the ledger here and lift the ceiling if needed.
575
+ // NB: `payee` in the vault file is an object ({address, seed}) when `setup`
576
+ // generated one, and a bare address string when the seller passed --payee.
577
+ // Reading only the string form silently skips this check — which is exactly
578
+ // how the demo died once, on "1.00 of the 50 daily budget is already spent".
579
+ const payeeAddr = typeof data.payee === "string" ? data.payee : data.payee?.address;
580
+ const daily = Number(data.policy?.dailyBudget ?? 50);
581
+ if (typeof payeeAddr === "string" && /^G[A-Z2-7]{55}$/.test(payeeAddr)) {
582
+ try {
583
+ const vault = await vaultHandle(data, network);
584
+ const spent = Number(await vault.paidToday(payeeAddr));
585
+ // Enough headroom for ten more demo payments, whatever the price is.
586
+ const want = spent + Math.max(10, Math.ceil(Number(price) * 10));
587
+ if (daily < want) {
588
+ data.policy = { ...(data.policy ?? {}), savedByOwner: true, dailyBudget: Math.ceil(want) };
589
+ writeFileSync(path, JSON.stringify(data, null, 2));
590
+ chmodSync(path, 0o600);
591
+ log(`today's spend is ${spent.toFixed(2)} of ${daily}; raised the daily budget to ${data.policy.dailyBudget} so the demo has room`);
592
+ log(` per-payment ceiling unchanged (${data.policy.maxPerPayment ?? "?"}) — that one is pinned into the signed transaction hash`);
593
+ }
594
+ }
595
+ catch (e) {
596
+ // Do not block the demo on this check. If the ledger really is
597
+ // unreachable, sell() will say so plainly before it starts anything.
598
+ log(`could not pre-check today's spend (${e?.message ?? e}) — starting anyway`);
599
+ }
600
+ }
601
+ // 3. Run the real thing. sell() reads its options from process.argv, so the
602
+ // preset is expressed there rather than by duplicating the seller's startup.
603
+ const extra = ["sell", "--split", "--tls", "--showcase",
604
+ "--price", price, "--route", "/market/data", "--file", path];
605
+ for (const f of ["max", "daily", "port", "desk", "demo-port", "donate-port"]) {
606
+ const v = arg(f);
607
+ if (v !== undefined)
608
+ extra.push(`--${f}`, v);
609
+ }
610
+ process.argv = [process.argv[0], process.argv[1], ...extra];
611
+ // Open the tabs as soon as the desk actually answers — not before, because a
612
+ // tab that loads an error page is what a funder remembers. Scheduled rather
613
+ // than awaited: sell() never returns, it stays up serving payments.
614
+ const key = data.desk?.portalKey;
615
+ demoCard(`https://${PUBLIC_HOST}`, key, data);
616
+ if (!flag("no-open")) {
617
+ setTimeout(async () => {
618
+ if (await tunnelLive()) {
619
+ openUrl(`https://${DEMO_HOST}`);
620
+ if (key)
621
+ openUrl(`https://${PUBLIC_HOST}/phone/?k=${key}`);
622
+ }
623
+ }, 1000);
624
+ }
625
+ await sell();
626
+ }
627
+ /** Are the public URLs actually answering? A port can be held by a process
628
+ * whose tunnel died, which is worse than no process at all: the browser gets
629
+ * a Cloudflare 530 and the founder has no idea why. */
630
+ async function surfacesLive() {
631
+ const get = async (url) => {
632
+ try {
633
+ const r = await fetch(url, { signal: AbortSignal.timeout(4000) });
634
+ return r.status < 500;
635
+ }
636
+ catch {
637
+ return false;
638
+ }
639
+ };
640
+ const [desk, demo] = await Promise.all([
641
+ get(`https://${PUBLIC_HOST}/healthz`), get(`https://${DEMO_HOST}/`),
642
+ ]);
643
+ return { desk, demo };
644
+ }
645
+ /**
646
+ * Is the Paydlux answering those ports the SAME vault that was asked for?
647
+ *
648
+ * The public addresses are singletons: desk.paydlux.com and demo.paydlux.com can
649
+ * only carry one vault at a time, and the testnet demo and the mainnet demo use
650
+ * the same two. "It is already running" is therefore not enough — the thing that
651
+ * is running may be the other network. Answering a funder with play money while
652
+ * saying it is the real vault (or moving real money during what everyone believes
653
+ * is a safe demo) is the worst failure this command can have, so the reuse path
654
+ * checks the vault file of the process actually holding the ports.
655
+ */
656
+ async function surfacesServe(path) {
657
+ const want = resolve(process.cwd(), path);
658
+ for (const port of [Number(arg("desk") ?? 4022), Number(arg("port") ?? 4021)]) {
659
+ const who = await portHolder(port);
660
+ const pid = Number((who?.match(/pid (\d+)/) ?? [])[1]);
661
+ if (!pid)
662
+ return false;
663
+ // -ww: a truncated command line would hide the --file argument and make this
664
+ // say "different vault" about the very demo we are asking to reuse.
665
+ let cmd = "";
666
+ try {
667
+ cmd = execSync(`ps -ww -o command= -p ${pid} 2>/dev/null`, { encoding: "utf8" }).trim();
668
+ }
669
+ catch {
670
+ return false;
671
+ }
672
+ const served = (cmd.match(/--file[= ](\S+)/) ?? [])[1] ?? VAULT_FILE_DEFAULT;
673
+ if (resolve(process.cwd(), served) !== want)
674
+ return false;
675
+ }
676
+ return true;
677
+ }
678
+ /**
679
+ * Free the demo's ports when a stale Paydlux is squatting on one.
680
+ *
681
+ * Only Paydlux processes are touched, and only after saying which one and why.
682
+ * Anything else on the port is left alone and reported, because killing a
683
+ * stranger's server to make a demo fit is not our call to make.
684
+ */
685
+ async function clearDemoPorts(ports) {
686
+ for (const port of ports) {
687
+ const who = await portHolder(port);
688
+ if (!who)
689
+ continue;
690
+ if (!/paydlux|cli|tsx|node/i.test(who)) {
691
+ bad(`port ${port} is held by ${who}, which is not Paydlux.\n free it, or move the demo: paydlux demo --desk ${port + 100}`);
692
+ }
693
+ const pid = Number((who.match(/pid (\d+)/) ?? [])[1]);
694
+ if (!pid) {
695
+ log(`port ${port} is held by ${who}; stopping it`);
696
+ continue;
697
+ }
698
+ try {
699
+ const cmd = execSync(`ps -o command= -p ${pid} 2>/dev/null`, { encoding: "utf8" }).trim();
700
+ log(`stopping the stale Paydlux on port ${port} (pid ${pid}): ${cmd.slice(0, 90)}`);
701
+ process.kill(pid, "SIGTERM");
702
+ }
703
+ catch { /* already gone */ }
704
+ // Wait for the port to actually free. SIGTERM to a seller runs its bye()
705
+ // handler, which takes a moment to close listeners and the tunnel.
706
+ for (let i = 0; i < 20; i++) {
707
+ if (!(await portHolder(port)))
708
+ break;
709
+ await new Promise((r) => setTimeout(r, 250));
710
+ }
711
+ const still = await portHolder(port);
712
+ if (still) {
713
+ try {
714
+ process.kill(pid, "SIGKILL");
715
+ }
716
+ catch { /* gone */ }
717
+ log(`port ${port} needed a hard stop`);
718
+ }
719
+ }
720
+ }
721
+ /** The runbook, printed where the founder is already looking. */
722
+ function demoCard(host, key, data) {
723
+ const network = data.network ?? "testnet";
724
+ // The footer used to promise "nothing here costs real money" unconditionally.
725
+ // On the public network that is a lie, and it is the kind of lie that gets a
726
+ // founder spending the vault in front of an investor.
727
+ const real = network !== "testnet";
728
+ console.log(`
729
+ ┌── Paydlux demo · the 5-minute script ─────────────────────────────
730
+ │ 1 https://paydlux.com "an agent buys from a website"
731
+ │ 2 https://${DEMO_HOST} press RUN THE LOOP → 402 → paid
732
+ │ 3 your iPhone hold 1.2s. the key never left it
733
+ │ 4 back on tab 2 step 5 prints the ledger tx hash
734
+ │ 5 ${host}/rules${key ? `?k=${key}` : ""} spending limits, categories
735
+ └───────────────────────────────────────────────────────────────────
736
+ token ${data.token?.code ?? "?"} · ${network} · ${real
737
+ ? "REAL MONEY — each approved loop pays out of this vault"
738
+ : "nothing here costs real money"}
739
+ `);
740
+ }
741
+ /** Open a URL in the default browser. Best-effort: a demo survives it failing. */
742
+ function openUrl(url) {
743
+ try {
744
+ const tool = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
745
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
746
+ const p = spawn(tool, args, { stdio: "ignore", detached: true });
747
+ p.on("error", () => log(`open this yourself: ${url}`));
748
+ p.unref();
749
+ }
750
+ catch {
751
+ log(`open this yourself: ${url}`);
752
+ }
753
+ }
754
+ /** The vault handle `sell` uses, shared so the demo checks the same numbers. */
755
+ async function vaultHandle(data, network) {
756
+ const keys = data.keys ?? {};
757
+ return StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(keys.principal), approver: keys.approver ? pub(keys.approver) : undefined, agent: pub(keys.agent) }, network);
758
+ }
458
759
  /** XLM the vault must hold to exist, versus what it actually has. */
459
760
  async function vaultRent(vault) {
460
761
  const acc = await vault.horizon.loadAccount(vault.address);
@@ -770,7 +1071,19 @@ async function withdraw() {
770
1071
  bad("then run this again — approvals from the phone land automatically.");
771
1072
  }
772
1073
  console.log(`sending ${want} ${data.token.code} from the vault to ${to} …`);
773
- const res = await vault.release(to, String(want), principal, approver, "paydlux withdraw");
1074
+ // A wallet that has never been funded cannot receive a payment — the ledger
1075
+ // answers op_no_destination. For XLM the fix is the createAccount operation,
1076
+ // which costs the same two signatures. For a token there is no fix at all:
1077
+ // that account would need a trustline too, so say so instead of failing cryptically.
1078
+ const exists = await vault.horizon.loadAccount(to).then(() => true).catch(() => false);
1079
+ if (!exists && !vault.isNative) {
1080
+ bad(`${to} has no account on the ledger, so it cannot hold ${data.token.code}.\n send it XLM from any exchange first, then withdraw again.`);
1081
+ }
1082
+ if (!exists)
1083
+ console.log(`${to} does not exist on the ledger yet — this payment creates it.`);
1084
+ const res = exists
1085
+ ? await vault.release(to, String(want), principal, approver, "paydlux withdraw")
1086
+ : await vault.fund(to, String(want), principal, approver, "paydlux withdraw");
774
1087
  console.log(`done. tx ${res?.hash}`);
775
1088
  console.log(`vault now holds ${await vault.balance()} ${data.token.code}`);
776
1089
  }
@@ -1027,6 +1340,12 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
1027
1340
  in a claimable balance you cannot touch until --release seconds have
1028
1341
  passed, and the buyer can take it back any time before then. You get paid
1029
1342
  by proving delivery before the timer, not by waiting for it.
1343
+ paydlux demo [--price 1] [--file ./paydlux-vault.json]
1344
+ the funder meeting as one command. Same seller, same desk, same phone
1345
+ signature — but it refuses to fail in front of an audience: if the demo is
1346
+ already running it opens the tabs instead of dying on a taken port, and if
1347
+ today's daily budget is already spent it raises that budget (the
1348
+ per-payment ceiling is untouched) rather than refusing to start.
1030
1349
  paydlux escrow [--release ORD | --refund ESCROW_ID]
1031
1350
  list what is locked for your vault, release one order, or take a buyer's
1032
1351
  money back. Release needs both human keys; on a --split vault it prints
@@ -1046,6 +1365,11 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
1046
1365
  paydlux withdraw --to G… [--all | --amount N]
1047
1366
  take your money back out of the vault. Needs both human keys; the agent
1048
1367
  key can never do this.
1368
+ paydlux mcp [--file ./paydlux-vault.json] [--read-only]
1369
+ serve Paydlux as an MCP server, so an agent in Claude, Cursor, LobeHub or
1370
+ any other MCP client buys through the mandate instead of holding a wallet.
1371
+ This process loads only the agent key — one of two vault signers — so it
1372
+ cannot release money alone. --read-only drops the tools that touch money.
1049
1373
 
1050
1374
  one file holds it all: ./paydlux-vault.json (0600, never commit it)
1051
1375
  `;
@@ -1191,6 +1515,8 @@ export async function main() {
1191
1515
  return setup();
1192
1516
  if (cmd === "sell")
1193
1517
  return sell();
1518
+ if (cmd === "demo")
1519
+ return demo();
1194
1520
  if (cmd === "donate")
1195
1521
  return donate();
1196
1522
  if (cmd === "status")
@@ -1203,6 +1529,8 @@ export async function main() {
1203
1529
  return usdc();
1204
1530
  if (cmd === "escrow")
1205
1531
  return escrow();
1532
+ if (cmd === "mcp")
1533
+ return startMcp();
1206
1534
  bad(`unknown command "${cmd}".\n\n${HELP}`);
1207
1535
  }
1208
1536
  // Run only when this file is the entry point, so importing it in a test is safe.
package/dist/desk.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import type { StellarVault } from "./vault.ts";
3
- import type { PolicyEngine } from "./policy.ts";
3
+ import type { PolicyEngine, Policy } from "./policy.ts";
4
4
  import { type ApprovalChallenge } from "./phone.ts";
5
5
  /** A portal link is a capability: whoever holds it can approve. So it is long,
6
6
  * random, and compared without leaking timing. */
@@ -96,10 +96,31 @@ export type DeskConfig = {
96
96
  * ceiling can actually hold.
97
97
  */
98
98
  policy?: PolicyEngine;
99
+ /**
100
+ * Called when the human edits the spending rules from the portal. The CLI
101
+ * wires this to the config file, so a rule set at the desk survives the next
102
+ * restart instead of resetting to the command-line defaults.
103
+ *
104
+ * A callback rather than a path: the desk must not know where secrets live.
105
+ * The config file also holds key material, so the writer stays with the code
106
+ * that already handles it (0600, same serialisation).
107
+ */
108
+ onPolicyChange?: (policy: Policy) => void;
99
109
  };
100
110
  export declare class ApprovalDesk {
101
111
  private records;
102
112
  private seq;
113
+ /** when /rules last saved, so the page can say "saved" without a query flag */
114
+ private rulesSavedAt;
115
+ /**
116
+ * When the approver app last polled /queue. The desktop portal reads /list and
117
+ * the phone reads /queue, so a hit here means a phone is pointed at THIS desk
118
+ * with THIS link. It is how a Mac can tell "the seller's phone is asleep" from
119
+ * "the phone is still talking to the desk from the last run" — the second one
120
+ * makes a live demo look dead, because the parked payment goes to a queue
121
+ * nobody is looking at.
122
+ */
123
+ private lastPhonePollAt;
103
124
  private server?;
104
125
  cfg: DeskConfig;
105
126
  constructor(cfg: DeskConfig);
@@ -168,6 +189,35 @@ export declare class ApprovalDesk {
168
189
  private mandateBlock;
169
190
  /** The mandate has been satisfied: count the money as spent. */
170
191
  private recordSpend;
192
+ /** The rules as they stand, for the /rules page. */
193
+ policyView(): Policy;
194
+ /** What today's counters read, per category, for the /rules page. */
195
+ private spentView;
196
+ /**
197
+ * The cross-origin gate used by every route that changes money or the rules
198
+ * that gate it. A cross-origin fetch POST is a CORS *simple request*: no
199
+ * preflight, so the attacker's page never sees the reply but the action still
200
+ * happens. Browsers always send Origin on POST and Sec-Fetch-Site on every
201
+ * request, so checking both closes that path while leaving local automation
202
+ * (curl, tests, the phone app on its own origin) alone.
203
+ *
204
+ * Returns a reason string when the request must be refused, null when it may
205
+ * proceed.
206
+ */
207
+ private crossOriginGate;
208
+ /**
209
+ * Apply an edit from the /rules form.
210
+ *
211
+ * The form carries the whole rule set, so this rebuilds rather than patches:
212
+ * what you see on the page is what gets saved, and a stale field cannot
213
+ * survive an edit. Names and numbers are validated inside PolicyEngine.update
214
+ * — this function only decides which fields the form is allowed to touch.
215
+ *
216
+ * Deliberately absent: maxMandateTtl and maxLiveMandates. Those are ledger
217
+ * constraints (sequence binding), not owner preferences, and a form field for
218
+ * them would be an invitation to configure a mandate the chain will refuse.
219
+ */
220
+ private applyRules;
171
221
  approve(id: string): Promise<DeskRecord>;
172
222
  /** Split mode step 1: ask the phone to sign. Nothing is submitted yet. */
173
223
  private requestPhoneSignature;