@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/desk.js CHANGED
@@ -17,7 +17,8 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
17
17
  import { Keypair } from "@stellar/stellar-sdk";
18
18
  import { TransactionBuilder } from "@stellar/stellar-sdk";
19
19
  import { passphrase } from "./vault.js";
20
- import { portalPage } from "./portal.js";
20
+ import { DEFAULT_POLICY, normalizeCategory } from "./policy.js";
21
+ import { portalPage, rulesPage } from "./portal.js";
21
22
  import { createApprovalChallenge, submitApproved, challengeQrPayload, } from "./phone.js";
22
23
  import { claimEscrow, prepareEscrowRelease } from "./escrow.js";
23
24
  /**
@@ -181,6 +182,30 @@ function cookieOf(req, name) {
181
182
  }
182
183
  return null;
183
184
  }
185
+ /**
186
+ * Form-field readers for /rules. A form is untrusted input arriving on a route
187
+ * that gates money, so each reader narrows to one type instead of trusting the
188
+ * field name. Empty means "not supplied", which lets a blank input fall back to
189
+ * the engine's default rather than becoming a zero.
190
+ */
191
+ function numField(v) {
192
+ const s = String(v ?? "").trim();
193
+ if (!s)
194
+ return undefined;
195
+ const n = Number(s);
196
+ return Number.isFinite(n) && n > 0 ? n : undefined;
197
+ }
198
+ /** One account id per line. Anything that is not G…56 is dropped, not warned. */
199
+ function linesField(v) {
200
+ return String(v ?? "").split(/[\s,]+/).map((s) => s.trim().toUpperCase())
201
+ .filter((s) => /^G[A-Z2-7]{55}$/.test(s));
202
+ }
203
+ /** A Stellar account id, and nothing else. Same shape the policy engine accepts. */
204
+ const isAccountId = (s) => /^G[A-Z2-7]{55}$/.test(s);
205
+ /** A comma-separated list of ids carried by a hidden field. */
206
+ function listField(v) {
207
+ return String(v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
208
+ }
184
209
  function same(a, b) {
185
210
  const x = Buffer.from(a), y = Buffer.from(b);
186
211
  return x.length === y.length && timingSafeEqual(x, y);
@@ -188,6 +213,17 @@ function same(a, b) {
188
213
  export class ApprovalDesk {
189
214
  records = new Map();
190
215
  seq = 0;
216
+ /** when /rules last saved, so the page can say "saved" without a query flag */
217
+ rulesSavedAt = 0;
218
+ /**
219
+ * When the approver app last polled /queue. The desktop portal reads /list and
220
+ * the phone reads /queue, so a hit here means a phone is pointed at THIS desk
221
+ * with THIS link. It is how a Mac can tell "the seller's phone is asleep" from
222
+ * "the phone is still talking to the desk from the last run" — the second one
223
+ * makes a live demo look dead, because the parked payment goes to a queue
224
+ * nobody is looking at.
225
+ */
226
+ lastPhonePollAt = 0;
191
227
  server;
192
228
  cfg;
193
229
  constructor(cfg) { this.cfg = cfg; }
@@ -325,13 +361,20 @@ export class ApprovalDesk {
325
361
  // `pending` deliberately does NOT count: that is a request nobody has
326
362
  // approved, and counting the whole queue would refuse the first approval
327
363
  // just because more requests exist than the budget can cover.
328
- const committed = this.list().reduce((sum, r) => (r.id !== rec.id && (r.status === "awaiting_phone" || r.status === "approved"))
329
- ? sum + Number(r.price) : sum, 0);
364
+ const waiting = this.list().filter((r) => r.id !== rec.id && (r.status === "awaiting_phone" || r.status === "approved"));
365
+ const committed = waiting.reduce((sum, r) => sum + Number(r.price), 0);
366
+ // The same promise, counted inside the payee's category. A category budget
367
+ // that ignored the queue would let the human approve five payments and have
368
+ // the fifth fail at submission. This desk pays one payee, so every waiting
369
+ // record belongs to that payee's bucket — no per-record lookup needed.
370
+ const category = policy.categoryOf(this.cfg.payee);
371
+ const committedInCategory = category ? committed : undefined;
330
372
  const verdict = policy.check({
331
373
  amount: Number(rec.price),
332
374
  payee: this.cfg.payee,
333
375
  ttl: this.cfg.decideWithinSeconds,
334
376
  committed,
377
+ committedInCategory,
335
378
  });
336
379
  if (verdict.ok)
337
380
  return null;
@@ -350,6 +393,136 @@ export class ApprovalDesk {
350
393
  uri: rec.uri, mandateHash: tx,
351
394
  });
352
395
  }
396
+ /** The rules as they stand, for the /rules page. */
397
+ policyView() {
398
+ return this.cfg.policy?.policy ?? { ...DEFAULT_POLICY };
399
+ }
400
+ /** What today's counters read, per category, for the /rules page. */
401
+ spentView() {
402
+ const p = this.cfg.policy;
403
+ if (!p)
404
+ return { total: 0, byCategory: {} };
405
+ const byCategory = {};
406
+ for (const name of Object.keys(p.policy.categories ?? {}))
407
+ byCategory[name] = p.spentInCategory(name);
408
+ return { total: p.spentToday(), byCategory };
409
+ }
410
+ /**
411
+ * The cross-origin gate used by every route that changes money or the rules
412
+ * that gate it. A cross-origin fetch POST is a CORS *simple request*: no
413
+ * preflight, so the attacker's page never sees the reply but the action still
414
+ * happens. Browsers always send Origin on POST and Sec-Fetch-Site on every
415
+ * request, so checking both closes that path while leaving local automation
416
+ * (curl, tests, the phone app on its own origin) alone.
417
+ *
418
+ * Returns a reason string when the request must be refused, null when it may
419
+ * proceed.
420
+ */
421
+ crossOriginGate(req) {
422
+ const origin = String(req.headers.origin ?? "");
423
+ if (origin) {
424
+ const here = String(req.headers.host ?? "").toLowerCase();
425
+ let oh;
426
+ try {
427
+ oh = new URL(origin).host.toLowerCase();
428
+ }
429
+ catch {
430
+ oh = "";
431
+ }
432
+ if (oh !== here)
433
+ return `this desk only accepts actions from itself, not from ${origin}`;
434
+ }
435
+ const site = String(req.headers["sec-fetch-site"] ?? "").toLowerCase();
436
+ if (site && site !== "same-origin" && site !== "none") {
437
+ return `this desk only accepts same-site actions (got ${site})`;
438
+ }
439
+ return null;
440
+ }
441
+ /**
442
+ * Apply an edit from the /rules form.
443
+ *
444
+ * The form carries the whole rule set, so this rebuilds rather than patches:
445
+ * what you see on the page is what gets saved, and a stale field cannot
446
+ * survive an edit. Names and numbers are validated inside PolicyEngine.update
447
+ * — this function only decides which fields the form is allowed to touch.
448
+ *
449
+ * Deliberately absent: maxMandateTtl and maxLiveMandates. Those are ledger
450
+ * constraints (sequence binding), not owner preferences, and a form field for
451
+ * them would be an invitation to configure a mandate the chain will refuse.
452
+ */
453
+ applyRules(action, form) {
454
+ const engine = this.cfg.policy;
455
+ if (!engine)
456
+ return { ...DEFAULT_POLICY };
457
+ const current = engine.policy;
458
+ const dropped = new Set(listField(form.get("delcat")));
459
+ // A category whose name was edited, old -> new. The page renders the name as
460
+ // a hidden field, so this only fires for a hand-crafted POST; keeping the
461
+ // mapping rather than dropping it means a rename never silently removes a
462
+ // wallet's ceiling.
463
+ const renamed = new Map();
464
+ const next = {};
465
+ const maxPer = numField(form.get("maxPerPayment"));
466
+ const daily = numField(form.get("dailyBudget"));
467
+ if (maxPer !== undefined)
468
+ next.maxPerPayment = maxPer;
469
+ if (daily !== undefined)
470
+ next.dailyBudget = daily;
471
+ next.allowlist = linesField(form.get("allowlist"));
472
+ next.denylist = linesField(form.get("denylist"));
473
+ // Categories: rebuilt from the rows the page shows, minus the ones the
474
+ // human removed, plus the one they just typed.
475
+ const categories = {};
476
+ for (const [key, value] of form) {
477
+ const m = /^cat_(.{1,32})_name$/.exec(key);
478
+ if (!m)
479
+ continue;
480
+ const was = m[1];
481
+ const name = normalizeCategory(value || was);
482
+ if (!name || dropped.has(was) || dropped.has(name))
483
+ continue;
484
+ if (name !== was)
485
+ renamed.set(was, name);
486
+ categories[name] = {
487
+ maxPerPayment: numField(form.get(`cat_${was}_max`)),
488
+ dailyBudget: numField(form.get(`cat_${was}_daily`)),
489
+ };
490
+ }
491
+ // Both "add" boxes are processed whenever they are filled, not only when
492
+ // their own button was pressed. The alternative is a form where typing a
493
+ // wallet and pressing the wrong button silently discards what was typed —
494
+ // and a rule that vanishes without an error is worse than a strict one.
495
+ const addCat = normalizeCategory(form.get("newcat"));
496
+ if (addCat && !dropped.has(addCat)) {
497
+ categories[addCat] = {
498
+ maxPerPayment: numField(form.get("newcat_max")),
499
+ dailyBudget: numField(form.get("newcat_daily")),
500
+ };
501
+ }
502
+ next.categories = categories;
503
+ // Wallet → category map: keep what is there, drop what was removed, add the
504
+ // new one. Rebuilt from the engine rather than the form, because a form
505
+ // field for the whole map would let a POST invent mappings for accounts
506
+ // nobody typed. A renamed bucket carries its wallets with it, so a rename
507
+ // can never quietly drop a ceiling.
508
+ const droppedMap = new Set(listField(form.get("delmap")));
509
+ const payeeCategory = {};
510
+ for (const [payee, name] of Object.entries(current.payeeCategory ?? {})) {
511
+ if (droppedMap.has(payee))
512
+ continue;
513
+ payeeCategory[payee] = renamed.get(name) ?? name;
514
+ }
515
+ const addWallet = String(form.get("newpayee") ?? "").trim().toUpperCase();
516
+ const addCatForWallet = normalizeCategory(form.get("newpayee_cat"));
517
+ if (isAccountId(addWallet) && addCatForWallet && categories[addCatForWallet]) {
518
+ payeeCategory[addWallet] = addCatForWallet;
519
+ }
520
+ next.payeeCategory = payeeCategory;
521
+ const policy = engine.update(next);
522
+ console.log(`[desk] spending rules updated (${action}): max ${policy.maxPerPayment}, daily ${policy.dailyBudget}, `
523
+ + `${Object.keys(policy.categories ?? {}).length} categories, ${Object.keys(policy.payeeCategory ?? {}).length} wallets mapped`);
524
+ return policy;
525
+ }
353
526
  async approve(id) {
354
527
  this.sweepExpired();
355
528
  const rec = this.records.get(id);
@@ -765,10 +938,68 @@ export class ApprovalDesk {
765
938
  }
766
939
  if (url.pathname === "/list")
767
940
  return send(200, this.list());
941
+ // The spending rules. Read-only view of the mandate the desk enforces,
942
+ // plus the form that edits it. Key-gated like everything else: these
943
+ // numbers decide what money leaves the vault, so an unauthenticated
944
+ // /rules would be an open "raise the budget" endpoint.
945
+ if (url.pathname === "/rules" && req.method === "GET") {
946
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
947
+ res.end(rulesPage(this.policyView(), this.spentView(), this.cfg.vault.address, this.cfg.vault.network, qs, Date.now() - this.rulesSavedAt < 15_000));
948
+ return;
949
+ }
950
+ if (url.pathname === "/rules.js") {
951
+ const body = readPortalFile("rules.js");
952
+ if (body === null)
953
+ return notFound(res);
954
+ res.writeHead(200, {
955
+ "content-type": "text/javascript; charset=utf-8",
956
+ "x-content-type-options": "nosniff",
957
+ });
958
+ res.end(body);
959
+ return;
960
+ }
961
+ if (url.pathname === "/rules" && req.method === "POST") {
962
+ // Editing the rules moves money indirectly, so it gets the same
963
+ // cross-origin gate as /approve: a page on another site must not be
964
+ // able to POST a form that raises your daily budget.
965
+ const gate = this.crossOriginGate(req);
966
+ if (gate)
967
+ return send(403, { error: "cross_origin", reason: gate });
968
+ const body = await readBody(req);
969
+ const form = new URLSearchParams(body);
970
+ const action = form.get("action") ?? "save";
971
+ const policy = this.applyRules(action, form);
972
+ this.rulesSavedAt = Date.now();
973
+ this.cfg.onPolicyChange?.(policy);
974
+ res.writeHead(303, { location: "/rules" + (this.cfg.portalKey ? `?k=${encodeURIComponent(this.cfg.portalKey)}` : "") });
975
+ res.end();
976
+ return;
977
+ }
768
978
  // The approver app's list view. Key-gated like everything else, and it
769
- // carries no paid content and no challenge XDR.
770
- if (url.pathname === "/queue")
979
+ // carries no paid content and no challenge XDR. Polling it is also the
980
+ // proof that a phone is attached to this desk, so note the time.
981
+ if (url.pathname === "/queue") {
982
+ this.lastPhonePollAt = Date.now();
771
983
  return send(200, this.queue());
984
+ }
985
+ // "Is the phone actually connected?" for the Mac's demo app. Counts and
986
+ // public ledger identities only — no payment content, no challenge, no
987
+ // key material — and still behind the portal key like everything else.
988
+ if (url.pathname === "/status") {
989
+ const all = this.list();
990
+ return send(200, {
991
+ ok: true,
992
+ network: this.cfg.vault.network,
993
+ vault: this.cfg.vault.address,
994
+ approver: this.cfg.vault.signers.approver,
995
+ pending: all.filter((r) => r.status === "pending").length,
996
+ awaitingPhone: all.filter((r) => r.status === "awaiting_phone").length,
997
+ settled: all.filter((r) => r.status === "settled").length,
998
+ phoneSeenSecondsAgo: this.lastPhonePollAt
999
+ ? Math.round((Date.now() - this.lastPhonePollAt) / 1000)
1000
+ : null,
1001
+ });
1002
+ }
772
1003
  // The app fetches the pending transaction by handle rather than asking
773
1004
  // the human to paste 400 characters of base64 on a phone.
774
1005
  const ch = url.pathname.match(/^\/challenge\/([\w-]+)$/);
@@ -831,31 +1062,10 @@ export class ApprovalDesk {
831
1062
  const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
832
1063
  if (m && req.method === "POST") {
833
1064
  // These routes move money, so they must not be reachable from another
834
- // page. A cross-origin fetch POST is a CORS *simple request*: no
835
- // preflight, so the attacker's page never sees the reply but the
836
- // approval still happens. Verified end to end: a page on another port
837
- // settled a parked payment and paid the seller. Browsers always send
838
- // Origin on POST and Sec-Fetch-Site on every request, so checking both
839
- // closes that path while leaving local automation (curl, tests, the
840
- // phone app on its own origin) alone.
841
- const origin = String(req.headers.origin ?? "");
842
- if (origin) {
843
- const here = String(req.headers.host ?? "").toLowerCase();
844
- let oh;
845
- try {
846
- oh = new URL(origin).host.toLowerCase();
847
- }
848
- catch {
849
- oh = "";
850
- }
851
- if (oh !== here) {
852
- return send(403, { error: "cross_origin", reason: `this desk only accepts actions from itself, not from ${origin}` });
853
- }
854
- }
855
- const site = String(req.headers["sec-fetch-site"] ?? "").toLowerCase();
856
- if (site && site !== "same-origin" && site !== "none") {
857
- return send(403, { error: "cross_origin", reason: `this desk only accepts same-site actions (got ${site})` });
858
- }
1065
+ // page — same gate as /rules, see crossOriginGate.
1066
+ const gate = this.crossOriginGate(req);
1067
+ if (gate)
1068
+ return send(403, { error: "cross_origin", reason: gate });
859
1069
  if (m[1] === "signed") {
860
1070
  const body = await readBody(req);
861
1071
  const { signedXdr } = JSON.parse(body || "{}");
package/dist/gateway.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type IncomingMessage, type Server, type ServerResponse } from "node:htt
2
2
  import { Keypair, Transaction } from "@stellar/stellar-sdk";
3
3
  import { StellarVault, StellarTxError, type Amount, type Mandate, type Network } from "./vault.ts";
4
4
  import { ApprovalDesk, type DeskStore } from "./desk.ts";
5
- import type { PolicyEngine } from "./policy.ts";
5
+ import type { PolicyEngine, Policy } from "./policy.ts";
6
6
  import { claimEscrow } from "./escrow.ts";
7
7
  export declare const PAY_HEADER = "x-payment";
8
8
  export type Route = {
@@ -71,6 +71,11 @@ export type GatewayConfig = {
71
71
  * enforced — see DeskConfig.policy for why the gateway is the wrong place.
72
72
  */
73
73
  policy?: PolicyEngine;
74
+ /**
75
+ * Persist an edited spending policy. Passed straight to the desk; see
76
+ * DeskConfig.onPolicyChange for why it is a callback.
77
+ */
78
+ onPolicyChange?: (policy: Policy) => void;
74
79
  };
75
80
  export type PaymentProof = {
76
81
  /** pre-authorised envelope, base64 XDR, as issued by the vault */
package/dist/gateway.js CHANGED
@@ -49,6 +49,7 @@ export class Gateway {
49
49
  publicHost: cfg.publicHost,
50
50
  decideWithinSeconds: cfg.decideWithinSeconds,
51
51
  policy: cfg.policy,
52
+ onPolicyChange: cfg.onPolicyChange,
52
53
  });
53
54
  }
54
55
  /** Mandates the vault currently holds, so the gateway can hand them out. */
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { type Network } from "./vault.ts";
4
+ import { type Policy } from "./policy.ts";
5
+ type Cfg = {
6
+ path: string;
7
+ network: Network;
8
+ vault: string;
9
+ token: {
10
+ code: string;
11
+ issuer?: string;
12
+ };
13
+ payee?: string;
14
+ policy: Partial<Policy>;
15
+ /** the only secret this process holds. One of two vault signers. */
16
+ agentSeed: string;
17
+ principalPub: string;
18
+ approverPub?: string;
19
+ agentPub: string;
20
+ readOnly: boolean;
21
+ };
22
+ export declare function buildServer(cfg: Cfg): McpServer;
23
+ export declare function start(): Promise<void>;
24
+ export {};