@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 +80 -0
- package/dist/cli.js +344 -16
- package/dist/desk.d.ts +51 -1
- package/dist/desk.js +240 -30
- package/dist/gateway.d.ts +6 -1
- package/dist/gateway.js +1 -0
- package/dist/mcp.d.ts +24 -0
- package/dist/mcp.js +387 -0
- package/dist/policy.d.ts +51 -1
- package/dist/policy.js +102 -3
- package/dist/portal.d.ts +17 -0
- package/dist/portal.js +97 -1
- package/dist/showcase.d.ts +14 -0
- package/dist/showcase.js +99 -4
- package/dist/vault.d.ts +13 -0
- package/dist/vault.js +22 -0
- package/package.json +19 -4
- package/portal-app/portal.css +22 -0
- package/portal-app/rules.js +62 -0
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 {
|
|
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
|
|
329
|
-
|
|
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
|
-
|
|
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
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
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 {};
|