@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/dist/portal.js CHANGED
@@ -19,12 +19,108 @@ export function portalPage(records, vault, network, qs = "") {
19
19
  <title>Paydlux — approvals</title>
20
20
  <link rel="stylesheet" href="portal.css"></head><body>
21
21
  <h1>Paydlux — approvals</h1>
22
- <div class="sub">vault ${esc(vault)} · ${esc(network)} · refreshed <span id="t">now</span></div>
22
+ <div class="sub"><a class="nav" href="/${qs}">approvals</a> · <a class="nav" href="/rules${qs}">spending rules</a><br>
23
+ vault ${esc(vault)} · ${esc(network)} · refreshed <span id="t">now</span></div>
23
24
  <table><thead><tr><th>when</th><th>what</th><th>cost</th><th>status</th><th></th></tr></thead>
24
25
  <tbody>${rows}</tbody></table>
25
26
  <div class="foot">Approving signs a Stellar transaction with your key. The vault needs two signatures, so this device alone cannot release funds.</div>
26
27
  <script src="portal.js"></script></body></html>`;
27
28
  }
29
+ /**
30
+ * The spending rules, as a form the owner can read and edit.
31
+ *
32
+ * This is the surface the "customizable approval policy" request is really
33
+ * about: limits, approved wallets, and categories in one place, instead of
34
+ * command-line flags nobody outside a terminal can see.
35
+ *
36
+ * What it does NOT do is approve anything. Every payment still needs a human
37
+ * signature; these rules only decide what a human gets asked to sign. A rule
38
+ * set here can narrow the agent's reach, never widen what the ledger will
39
+ * accept, and the page says so in the same words the code enforces.
40
+ */
41
+ export function rulesPage(policy, spent, vault, network, qs = "", saved = false) {
42
+ const cats = Object.entries(policy.categories ?? {});
43
+ const catRows = cats.length
44
+ ? cats.map(([name, rule]) => categoryRow(name, rule, policy, spent.byCategory[name] ?? 0)).join("\n")
45
+ : `<tr><td colspan="5" class="empty">no categories — every seller shares the daily budget</td></tr>`;
46
+ const mapped = Object.entries(policy.payeeCategory ?? {});
47
+ return `<!doctype html><html><head>
48
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
49
+ <title>Paydlux — spending rules</title>
50
+ <link rel="stylesheet" href="portal.css"></head><body>
51
+ <h1>Paydlux — spending rules</h1>
52
+ <div class="sub"><a class="nav" href="/${qs}">approvals</a> · <a class="nav" href="/rules${qs}">spending rules</a><br>
53
+ vault ${esc(vault)} · ${esc(network)}${saved ? ` · <span class="saved">saved</span>` : ""}</div>
54
+
55
+ <form id="rules" method="post" action="/rules${qs}">
56
+ <h2>Every payment</h2>
57
+ <div class="grid">
58
+ <label>Most for one payment
59
+ <input name="maxPerPayment" type="number" min="0.0000001" step="0.0000001" value="${esc(policy.maxPerPayment)}"></label>
60
+ <label>Daily budget
61
+ <input name="dailyBudget" type="number" min="0.0000001" step="0.0000001" value="${esc(policy.dailyBudget)}"></label>
62
+ </div>
63
+ <p class="note">Spent today: <b>${esc(spent.total.toFixed(7))}</b> of ${esc(policy.dailyBudget)}. The day resets at 00:00 UTC.</p>
64
+
65
+ <h2>Wallets the agent may pay</h2>
66
+ <div class="grid">
67
+ <label>Allowed (one account per line, blank = any)
68
+ <textarea name="allowlist" rows="3" spellcheck="false" autocomplete="off">${esc((policy.allowlist ?? []).join("\n"))}</textarea></label>
69
+ <label>Never allowed, even if listed above
70
+ <textarea name="denylist" rows="3" spellcheck="false" autocomplete="off">${esc((policy.denylist ?? []).join("\n"))}</textarea></label>
71
+ </div>
72
+
73
+ <h2>Categories</h2>
74
+ <p class="note">Split the daily budget by what the money is for. A category can only be
75
+ tighter than the limits above — it can never raise them.</p>
76
+ <table class="cats"><thead><tr><th>name</th><th>max / payment</th><th>daily</th><th>spent today</th><th></th></tr></thead>
77
+ <tbody id="catbody">${catRows}</tbody></table>
78
+ <div class="addcat">
79
+ <input name="newcat" placeholder="name (a-z, 0-9)" maxlength="32" spellcheck="false" autocomplete="off">
80
+ <input name="newcat_max" type="number" min="0.0000001" step="0.0000001" placeholder="max / payment">
81
+ <input name="newcat_daily" type="number" min="0.0000001" step="0.0000001" placeholder="daily">
82
+ <button class="yes" type="submit" name="action" value="add">Add category</button>
83
+ </div>
84
+
85
+ <h2>Which wallet is in which category</h2>
86
+ ${mapped.length
87
+ ? `<table class="map"><thead><tr><th>wallet</th><th>category</th></tr></thead><tbody>
88
+ ${mapped.map(([payee, name]) => `<tr><td class="tx">${esc(payee)}</td><td>${esc(name)}`
89
+ + ` <button class="no mini" data-del="${esc(payee)}" data-map="1">Remove</button></td></tr>`).join("\n")}
90
+ </tbody></table>`
91
+ : `<p class="note">Nothing categorised yet. Add a wallet below to cap it on its own.</p>`}
92
+ <div class="addcat">
93
+ <input name="newpayee" placeholder="G… wallet address" maxlength="56" spellcheck="false" autocomplete="off">
94
+ <input name="newpayee_cat" placeholder="category" maxlength="32" spellcheck="false" autocomplete="off">
95
+ <button class="yes" type="submit" name="action" value="map">Add</button>
96
+ </div>
97
+
98
+ <div class="save">
99
+ <button class="yes" type="submit" name="action" value="save">Save rules</button>
100
+ </div>
101
+ </form>
102
+
103
+ <div class="foot">These rules decide what you are <i>asked</i> to approve. They never approve
104
+ anything themselves: every payment still needs your signature, and the vault needs two.</div>
105
+ <script src="rules.js"></script></body></html>`;
106
+ }
107
+ function categoryRow(name, rule, policy, spent) {
108
+ // Names go in both a hidden value and the delete button's data-*, so they are
109
+ // escaped like any other attacker-chosen string — even though update() already
110
+ // reduced them to [a-z0-9_-].
111
+ //
112
+ // The name is deliberately NOT an editable input. Renaming a bucket would
113
+ // orphan every wallet mapped to it, and an orphaned wallet stops having a
114
+ // category ceiling — a rename would silently loosen the rules while looking
115
+ // like a cosmetic edit. Renaming is therefore remove + add, which forces the
116
+ // human to re-map the wallets and see what they are doing.
117
+ return `<tr>
118
+ <td><span class="catname">${esc(name)}</span><input type="hidden" name="cat_${esc(name)}_name" value="${esc(name)}"></td>
119
+ <td><input name="cat_${esc(name)}_max" type="number" min="0.0000001" step="0.0000001" value="${esc(rule.maxPerPayment ?? "")}" placeholder="${esc(policy.maxPerPayment)}"></td>
120
+ <td><input name="cat_${esc(name)}_daily" type="number" min="0.0000001" step="0.0000001" value="${esc(rule.dailyBudget ?? "")}" placeholder="${esc(policy.dailyBudget)}"></td>
121
+ <td class="amt">${esc(spent.toFixed(7))}</td>
122
+ <td><button class="no" data-del="${esc(name)}">Remove</button></td></tr>`;
123
+ }
28
124
  function render(r, qs = "") {
29
125
  // qs is the portal key query string ("?k=..."). The approver link has to carry
30
126
  // it, because the desk answers 403 for anything without it.
@@ -42,6 +42,20 @@ export interface DemoConfig {
42
42
  donateHost?: string;
43
43
  /** Called whenever a key is enrolled, so the gateway's accepted set follows. */
44
44
  onKeys?: (pubs: string[]) => void;
45
+ /**
46
+ * The route the showcase sells (e.g. "/market/data"). /demo/last only reports
47
+ * settled payments for THIS route, so a seller's private sales never appear
48
+ * on a public page — a settled demo payment is the proof being shown, not the
49
+ * seller's order book.
50
+ */
51
+ route?: string;
52
+ /**
53
+ * The address the seller gets paid at. /demo/last uses it to read the last
54
+ * real settlement straight off the ledger, so the proof survives a restart of
55
+ * the demo process — an investor visiting at 3am, with the seller asleep,
56
+ * still gets a hash that exists on a public explorer.
57
+ */
58
+ payee?: string;
45
59
  }
46
60
  /**
47
61
  * Serve the showcase in front of a running gateway.
package/dist/showcase.js CHANGED
@@ -103,6 +103,43 @@ function demoPayload(cfg) {
103
103
  proof: cfg.explorer,
104
104
  }, null, 2);
105
105
  }
106
+ /**
107
+ * The last payment this vault really made to the seller, read off the public
108
+ * ledger. The in-memory desk forgets on restart; the ledger does not, so an
109
+ * investor who visits while the seller is asleep still reaches a hash they can
110
+ * open on an explorer instead of a spinner. Cached — one page load should not
111
+ * hammer horizon, and the answer only moves when a payment settles.
112
+ */
113
+ let ledgerProof = null;
114
+ let ledgerProofAt = 0;
115
+ async function lastSettledOnLedger(cfg) {
116
+ if (!cfg.payee)
117
+ return null;
118
+ if (ledgerProof && Date.now() - ledgerProofAt < 5 * 60 * 1000)
119
+ return ledgerProof;
120
+ const host = cfg.network === "public" ? "horizon.stellar.org" : "horizon-testnet.stellar.org";
121
+ try {
122
+ const r = await fetch(`https://${host}/accounts/${cfg.vault}/payments?order=desc&limit=25`, { signal: AbortSignal.timeout(4000) });
123
+ if (!r.ok)
124
+ return null;
125
+ const records = ((await r.json())?._embedded?.records) ?? [];
126
+ for (const p of records) {
127
+ // horizon calls the hash `transaction_hash`; a record without one is not a
128
+ // proof, so it is skipped rather than printed as "undefined".
129
+ if (p?.to !== cfg.payee || !(Number(p.amount) > 0) || !p.transaction_hash)
130
+ continue;
131
+ ledgerProof = {
132
+ at: Date.parse(p.created_at) || 0,
133
+ price: String(p.amount),
134
+ tx: String(p.transaction_hash),
135
+ };
136
+ ledgerProofAt = Date.now();
137
+ return ledgerProof;
138
+ }
139
+ }
140
+ catch { /* no ledger reachable: the page still works, just without the extra proof */ }
141
+ return null;
142
+ }
106
143
  /**
107
144
  * Serve the showcase in front of a running gateway.
108
145
  *
@@ -193,10 +230,32 @@ export function startDemo(cfg) {
193
230
  res.end(JSON.stringify({ ok: true, until: ok.until, vault: cfg.vault }));
194
231
  return;
195
232
  }
233
+ // A visitor watching alone cannot finish step 4 — the seller's phone is the
234
+ // only thing that can sign it, and the seller may be asleep. The loop is
235
+ // worthless as a demo if it ends at "waiting", so hand them the most recent
236
+ // payment this route actually settled: a hash they can open on the explorer
237
+ // without trusting this page. Nothing else about the payment is returned —
238
+ // a tx hash is already public on the ledger, the buyer and the content are
239
+ // not.
240
+ if (p === "/demo/last") {
241
+ const done = cfg.gateway.desk.list()
242
+ .filter((r) => r.status === "settled" && r.tx && (!cfg.route || r.uri === cfg.route))
243
+ .sort((a, b) => b.at - a.at)[0];
244
+ const proof = done
245
+ ? { at: done.at, price: done.price, tx: done.tx }
246
+ : await lastSettledOnLedger(cfg);
247
+ res.writeHead(200, {
248
+ "content-type": "application/json",
249
+ "cache-control": "no-store",
250
+ "x-content-type-options": "nosniff",
251
+ });
252
+ res.end(JSON.stringify(proof ?? { none: true }));
253
+ return;
254
+ }
196
255
  // Everything else belongs to the gateway: the paid route, /pay/status.
197
256
  // Only demo paths are exposed here, so pointing the tunnel at this port
198
257
  // cannot reach a seller's private routes by guessing a URI.
199
- if (!(p === DEMO_ROUTE || p === "/pay/status" || p === "/demo/terms")) {
258
+ if (!(p === DEMO_ROUTE || p === "/pay/status" || p === "/demo/terms" || p === "/demo/last")) {
200
259
  res.writeHead(404, { "content-type": "application/json" });
201
260
  res.end(JSON.stringify({ error: "not found", routes: [DEMO_ROUTE] }));
202
261
  return;
@@ -244,6 +303,7 @@ h1{font-size:30px;margin:0 0 6px;letter-spacing:-.01em}
244
303
  pre{margin:10px 0 0;padding:10px 12px;background:#0f1213;border:1px solid var(--line);border-radius:8px;font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;word-break:break-word;color:#cfd6d0}
245
304
  button{font:inherit;padding:11px 18px;border-radius:9px;border:1px solid var(--gold);background:var(--gold);color:#1a1408;font-weight:600;cursor:pointer;margin:22px 0 0}
246
305
  button[disabled]{opacity:.45;cursor:default}
306
+ .note{margin:12px 0 0;color:var(--dim);font-size:14px}
247
307
  .door{margin:30px 0 0;border:1px solid var(--line);border-left:3px solid var(--gold);background:#1a1f21;border-radius:10px;padding:18px 20px}
248
308
  .door .q{margin:0 0 6px;font-weight:600}
249
309
  .door .a{margin:0;color:var(--dim);font-size:15px}
@@ -275,8 +335,8 @@ on a phone, and the ledger settles it. You are about to drive that loop.</p>
275
335
  <div class="step" id="s3"><h3><span class="n">3</span>Request the payment</h3>
276
336
  <p>The browser signs the request — not the payment — and sends it. The seller parks it for a human.</p>
277
337
  <pre id="o3">not run</pre></div>
278
- <div class="step" id="s4"><h3><span class="n">4</span>A human decides</h3>
279
- <p>The seller sees the exact payment on their phone and holds for 1.2 seconds to approve. Nothing here can skip that.</p>
338
+ <div class="step" id="s4"><h3><span class="n">4</span>A human decides — and it isn't you</h3>
339
+ <p>The buyer half ends at step 3. From here the <b>seller</b> decides: the parked payment appears on their phone with the amount, the payee and the deadline, and a person holds the button for 1.2 seconds. The key that signs it never leaves that device, so no code — including this page — can do it for them. The app only checks the queue while it is open on the screen, so a phone in a pocket signs nothing: if nobody is holding it, this step waits. That is the product, not a bug.</p>
280
340
  <pre id="o4">not run</pre></div>
281
341
  <div class="step" id="s5"><h3><span class="n">5</span>The ledger proves it</h3>
282
342
  <p>Same request, now served. The transaction hash is public and verifiable without trusting this page.</p>
@@ -284,6 +344,9 @@ on a phone, and the ledger settles it. You are about to drive that loop.</p>
284
344
  </div>
285
345
 
286
346
  <button id="go">Run the loop</button>
347
+ <p class="note">You are the <b>buyer</b> here. Steps 1–3 happen in your browser. Step 4 happens on the
348
+ seller's phone — if nobody is holding it, this page will show you the last payment this exact loop
349
+ settled, with a hash you can open on the explorer.</p>
287
350
 
288
351
  <div class="door">
289
352
  <p class="q">That was practice. Nothing you just did cost anything.</p>
@@ -312,6 +375,8 @@ function demoScript() {
312
375
  const el = (id) => document.getElementById(id);
313
376
  const say = (n, txt) => { el("o" + n).textContent = txt; el("s" + n).classList.add("done"); };
314
377
  const PAY = "x-payment";
378
+ let NET = "testnet"; // which explorer the proof links point at
379
+ let TOKEN = ""; // the token the demo is priced in, for readable amounts
315
380
 
316
381
  async function main() {
317
382
  const btn = el("go"); btn.disabled = true;
@@ -321,6 +386,8 @@ function demoScript() {
321
386
  try {
322
387
  const r = await fetch(ROUTE, { cache: "no-store" });
323
388
  terms = JSON.parse(r.headers.get(PAY) || "null");
389
+ NET = String((terms && terms.network) || "testnet").replace("stellar:", "");
390
+ TOKEN = String((terms && terms.token && terms.token.code) || "");
324
391
  say(1, "HTTP " + r.status + "\\n" + JSON.stringify(terms, null, 2));
325
392
  if (r.status !== 402) { say(1, "HTTP " + r.status + " — this showcase is not asking for payment right now"); return; }
326
393
  } catch (e) { say(1, "request failed: " + e); return; }
@@ -352,8 +419,18 @@ function demoScript() {
352
419
  say(3, "HTTP 202 — parked as " + (parked.handle || "?") + "\\n" + (parked.reason || ""));
353
420
 
354
421
  // 4 — wait for a person. The page polls the seller's own status route.
355
- say(4, "waiting for the seller's phone…");
422
+ // Say plainly what is happening: the seller's phone is the only thing that
423
+ // can sign this, and a visitor on their own laptop cannot make that happen.
424
+ // Bare silence here reads as a broken demo.
425
+ say(4, "parked. Now the seller's phone decides — that signature cannot come from this browser.\\nwaiting…");
356
426
  const seen = await poll(parked.statusUrl);
427
+ if (seen.status === "timed out waiting for a human") {
428
+ el("o4").textContent = "nobody signed, so nothing was paid — exactly as designed.\\n"
429
+ + "Run the loop again when the seller is at their phone.";
430
+ await showLast();
431
+ btn.disabled = false;
432
+ return;
433
+ }
357
434
  say(4, "status: " + seen.status + (seen.reason ? "\\n" + seen.reason : ""));
358
435
  if (seen.status !== "settled" && seen.status !== "paid") { btn.disabled = false; return; }
359
436
 
@@ -375,11 +452,29 @@ function demoScript() {
375
452
  for (let i = 0; i < 150; i++) {
376
453
  const j = await (await fetch(u, { cache: "no-store" })).json();
377
454
  if (j.status && j.status !== "pending" && j.status !== "awaiting_phone") return j;
455
+ // ~16 s with nobody at the phone: show that this loop does settle, instead
456
+ // of letting a visitor sit on a spinner and conclude the product is fake.
457
+ if (i === 8) await showLast();
378
458
  await new Promise((r) => setTimeout(r, 2000));
379
459
  }
380
460
  return { status: "timed out waiting for a human" };
381
461
  }
382
462
 
463
+ /**
464
+ * The most recent payment THIS route settled, as a public hash. It is not a
465
+ * substitute for the live approval — it is labelled as a different payment —
466
+ * it is the answer to "okay, but has this ever actually happened?".
467
+ */
468
+ async function showLast() {
469
+ try {
470
+ const last = await (await fetch("/demo/last", { cache: "no-store" })).json();
471
+ if (!last || !last.tx) return;
472
+ el("o4").textContent += "\\n\\nthe same loop, settled for real on " + new Date(last.at).toLocaleString()
473
+ + " (" + Number(last.price) + (TOKEN ? " " + TOKEN : "") + ") — verify it without this page:\\nhttps://stellar.expert/explorer/"
474
+ + NET + "/transaction/" + last.tx;
475
+ } catch { /* the demo still works without the extra proof */ }
476
+ }
477
+
383
478
  el("go").addEventListener("click", main);
384
479
  })();`;
385
480
  }
package/dist/vault.d.ts CHANGED
@@ -171,6 +171,19 @@ export declare class StellarVault {
171
171
  * so a stolen agent key cannot use it and a stolen phone cannot use it alone.
172
172
  */
173
173
  release(payee: string, amount: Amount, principal: Keypair, approver: Keypair, memo?: string): Promise<Horizon.HorizonApi.SubmitTransactionResponse>;
174
+ /**
175
+ * Create an account that does not exist yet, with a starting balance. Same two
176
+ * signatures as release(), because it is the vault's money moving.
177
+ *
178
+ * This exists because a plain payment to an address that has never been
179
+ * funded is refused by the ledger (`op_no_destination`) — proven on the mainnet
180
+ * vault when topping up the demo's agent account. So "send XLM to a brand new
181
+ * wallet" (the demo agent, a seller's fresh payee, an owner's new cold
182
+ * account) needs createAccount, not payment. Only XLM can do this: a token
183
+ * payment to an unfunded account can never work, because that account would
184
+ * also need a trustline.
185
+ */
186
+ fund(destination: string, amount: Amount, principal: Keypair, approver: Keypair, memo?: string): Promise<Horizon.HorizonApi.SubmitTransactionResponse>;
174
187
  /**
175
188
  * Kill every outstanding mandate at once. Any accepted transaction bumps the
176
189
  * vault's sequence, which invalidates all pre-authorized hashes pointing at a
package/dist/vault.js CHANGED
@@ -395,6 +395,28 @@ export class StellarVault {
395
395
  b.addMemo(Memo.text(memo));
396
396
  return sub(this.horizon, PASSPHRASE[this.network], b.build(), principal, approver);
397
397
  }
398
+ /**
399
+ * Create an account that does not exist yet, with a starting balance. Same two
400
+ * signatures as release(), because it is the vault's money moving.
401
+ *
402
+ * This exists because a plain payment to an address that has never been
403
+ * funded is refused by the ledger (`op_no_destination`) — proven on the mainnet
404
+ * vault when topping up the demo's agent account. So "send XLM to a brand new
405
+ * wallet" (the demo agent, a seller's fresh payee, an owner's new cold
406
+ * account) needs createAccount, not payment. Only XLM can do this: a token
407
+ * payment to an unfunded account can never work, because that account would
408
+ * also need a trustline.
409
+ */
410
+ async fund(destination, amount, principal, approver, memo) {
411
+ if (!this.isNative)
412
+ throw new Error("only XLM can create an account; a token needs a trustline first");
413
+ const b = (await this.tb(this.address))
414
+ .addOperation(Operation.createAccount({ destination, startingBalance: amount }))
415
+ .setTimeout(300);
416
+ if (memo)
417
+ b.addMemo(Memo.text(memo));
418
+ return sub(this.horizon, PASSPHRASE[this.network], b.build(), principal, approver);
419
+ }
398
420
  /**
399
421
  * Kill every outstanding mandate at once. Any accepted transaction bumps the
400
422
  * vault's sequence, which invalidates all pre-authorized hashes pointing at a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
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",
@@ -24,6 +24,7 @@
24
24
  "phone-app/icon-512.png",
25
25
  "portal-app/portal.js",
26
26
  "portal-app/portal.css",
27
+ "portal-app/rules.js",
27
28
  "README.md",
28
29
  "LICENSE"
29
30
  ],
@@ -32,7 +33,7 @@
32
33
  "node": ">=20"
33
34
  },
34
35
  "scripts": {
35
- "build": "tsc -p tsconfig.build.json",
36
+ "build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js dist/mcp.js",
36
37
  "test:dist": "npm run build && tsx stellar/dist-check.ts",
37
38
  "demo": "node sdk/demo.ts",
38
39
  "demo:stellar": "tsx stellar/demo.ts",
@@ -44,12 +45,15 @@
44
45
  "test:stellar": "tsx stellar/demo.ts",
45
46
  "test:portal": "tsx stellar/xss-check.ts",
46
47
  "test:mandate": "tsx stellar/mandate-check.ts",
48
+ "test:policy": "tsx stellar/policy-check.ts",
47
49
  "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",
48
50
  "test:ios": "tsx ios/bridge-check.ts",
49
51
  "test:link": "swiftc ios/PaydluxApprover/LinkResolver.swift ios/Checks/linkcheck/main.swift -o \"$TMPDIR/linkcheck\" && \"$TMPDIR/linkcheck\"",
50
52
  "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\"",
51
53
  "typecheck": "tsc --noEmit",
52
54
  "app": "tsx stellar/cli.ts",
55
+ "mcp": "tsx stellar/mcp.ts",
56
+ "test:mcp": "tsx stellar/mcp-check.ts",
53
57
  "app:mainnet": "tsx stellar/cli.ts setup --mainnet --file paydlux-mainnet.json --token XLM --max 2 --daily 20",
54
58
  "fund:check": "tsx stellar/check-address.ts GARPB3CO3JMMED3ETFRSSZXCH2X7UTXW4FNUP3Z7HDMBKAC2H355W4SA",
55
59
  "test:showcase": "tsx stellar/showcase-check.ts",
@@ -59,7 +63,13 @@
59
63
  "test:donate": "tsx stellar/donate-check.ts",
60
64
  "donate:live": "tsx stellar/donate-live.ts",
61
65
  "app:mainnet:sell": "tsx stellar/cli.ts sell --file paydlux-mainnet.json --split --tls --price 1 --route /market/data --max 2 --daily 20",
66
+ "app:mainnet:fund": "bash tools/fund-demo-wallet.sh",
67
+ "loop:check": "tsx stellar/mainnet-loop-check.ts",
62
68
  "app:testnet:sell": "tsx stellar/cli.ts sell --split --tls --showcase --price 1 --route /market/data --max 10 --daily 50",
69
+ "app:demo": "tsx stellar/cli.ts demo",
70
+ "app:demo:build": "bash tools/make-demo-app.sh",
71
+ "app:mainnet:build": "bash tools/make-demo-app.sh --mainnet",
72
+ "tunnel:service": "bash tools/install-tunnel-service.sh",
63
73
  "app:donate": "tsx stellar/cli.ts donate --file paydlux-mainnet.json --tls --price 1",
64
74
  "site:qr": "tsx stellar/make-site-qr.ts paydlux-mainnet.json site/qr.svg",
65
75
  "address:check": "tsx stellar/check-address.ts",
@@ -91,18 +101,23 @@
91
101
  "access": "public"
92
102
  },
93
103
  "dependencies": {
104
+ "@modelcontextprotocol/sdk": "^1.32.2",
94
105
  "@stellar/stellar-sdk": "^17.4.0",
95
- "qrcode-generator": "^2.0.4"
106
+ "qrcode-generator": "^2.0.4",
107
+ "zod": "^4.6.7"
96
108
  },
97
109
  "devDependencies": {
98
110
  "@types/node": "^26.6.6",
99
111
  "jsqr": "^1.4.0",
112
+ "qrcode": "^1.5.4",
100
113
  "tsx": "^4.23.15",
101
114
  "typescript": "^7.0.2",
102
115
  "viem": "^2.57.13",
116
+ "wrangler": "^4.158.0",
103
117
  "xrpl": "^5.3.0"
104
118
  },
105
119
  "bin": {
106
- "paydlux": "dist/cli.js"
120
+ "paydlux": "dist/cli.js",
121
+ "paydlux-mcp": "dist/mcp.js"
107
122
  }
108
123
  }
@@ -19,4 +19,26 @@ button{border:0;border-radius:10px;padding:12px 18px;font-size:15px;font-weight:
19
19
  details summary{cursor:pointer}
20
20
  .phlink{color:var(--gold);text-decoration:underline;font-size:12px}
21
21
  .foot{margin-top:28px;color:var(--muted);font-size:12px}
22
+
23
+ /* --- spending rules page --- */
24
+ h2{font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:28px 0 10px}
25
+ .grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
26
+ label{display:block;color:var(--muted);font-size:12px}
27
+ input,textarea{display:block;width:100%;margin-top:6px;background:#141715;color:var(--text);
28
+ border:1px solid var(--line);border-radius:10px;padding:11px 12px;font:15px/1.4 ui-monospace,monospace}
29
+ textarea{resize:vertical}
30
+ input:focus,textarea:focus{outline:none;border-color:var(--gold)}
31
+ .note{color:var(--muted);font-size:13px;margin:10px 0}
32
+ .note b{color:var(--gold)}
33
+ table.cats,table.map{margin-top:6px}
34
+ table.cats td{padding:8px 6px}
35
+ .addcat{display:flex;gap:10px;align-items:end;margin-top:10px;flex-wrap:wrap}
36
+ .addcat input{flex:1;min-width:140px}
37
+ .addcat button{width:auto;flex:0 0 auto}
38
+ .save{margin-top:26px}
39
+ .save button{width:auto;min-width:180px}
40
+ button.mini{display:inline;width:auto;padding:4px 10px;font-size:12px;margin:0}
41
+ .nav{color:var(--gold);text-decoration:underline}
42
+ .saved{color:var(--green);font-weight:700}
43
+ @media(max-width:640px){.grid{grid-template-columns:1fr}}
22
44
  @media(max-width:640px){.body{max-width:180px}}
@@ -0,0 +1,62 @@
1
+ // The spending-rules page's client half. Served by the desk at /rules.js.
2
+ //
3
+ // The page is a plain HTML form, so it works with JavaScript off. This file
4
+ // only adds the two things a form cannot do by itself: a Remove button that
5
+ // queues the row for deletion and submits, and a confirm before a rule that
6
+ // gates money is loosened.
7
+ //
8
+ // No inline handlers, so the page's CSP keeps script-src 'self'. Category names
9
+ // arrive from the server already reduced to [a-z0-9_-]; they are still read
10
+ // through dataset, never interpolated into markup, so nothing here can be
11
+ // turned into HTML by a name.
12
+ const form = document.getElementById("rules");
13
+ if (!form) throw new Error("rules page has no #rules form");
14
+
15
+ // Hidden fields the server reads as "delete these". Accumulating rather than
16
+ // replacing means a human can remove three rows before pressing Save.
17
+ const hidden = (name) => {
18
+ let el = form.querySelector(`input[name="${name}"]`);
19
+ if (!el) {
20
+ el = document.createElement("input");
21
+ el.type = "hidden";
22
+ el.name = name;
23
+ form.appendChild(el);
24
+ }
25
+ return el;
26
+ };
27
+
28
+ document.addEventListener("click", (e) => {
29
+ const b = e.target.closest("button[data-del]");
30
+ if (!b) return;
31
+ e.preventDefault();
32
+ const field = b.dataset.map ? "delmap" : "delcat";
33
+ const el = hidden(field);
34
+ const val = b.dataset.del;
35
+ const have = el.value ? el.value.split(",") : [];
36
+ if (!have.includes(val)) have.push(val);
37
+ el.value = have.join(",");
38
+ // The row goes immediately: the human sees the rule stop protecting them
39
+ // before they press Save, which is the point of the confirm.
40
+ b.closest("tr")?.remove();
41
+ form.requestSubmit();
42
+ });
43
+
44
+ // A wider ceiling is the one edit that can only hurt. The ledger still caps a
45
+ // single payment at the mandate amount, but the daily budget and the seller
46
+ // list are ours to count, so raising them is a real loosening. Ask once.
47
+ const limits = ["maxPerPayment", "dailyBudget"];
48
+ const start = Object.fromEntries(
49
+ limits.map((n) => [n, form.querySelector(`[name="${n}"]`)?.value ?? ""]));
50
+
51
+ form.addEventListener("submit", (e) => {
52
+ const raised = limits.filter((n) => {
53
+ const now = Number(form.querySelector(`[name="${n}"]`)?.value);
54
+ const was = Number(start[n]);
55
+ return Number.isFinite(now) && Number.isFinite(was) && now > was;
56
+ });
57
+ if (!raised.length) return;
58
+ const ok = confirm(
59
+ `This raises ${raised.join(" and ")}. Higher limits mean the agent may ask you for ` +
60
+ `more money, more often. Continue?`);
61
+ if (!ok) e.preventDefault();
62
+ });