@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/mcp.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Paydlux as an MCP server: the tools an agent calls when it wants to buy
|
|
3
|
+
// something and a human has to approve the money.
|
|
4
|
+
//
|
|
5
|
+
// npx -y @paydlux/agent mcp --file paydlux-mainnet.json
|
|
6
|
+
//
|
|
7
|
+
// This is the BUYER side of the protocol, and it is deliberately narrow. The
|
|
8
|
+
// only signing key this process ever loads is the agent key: one of two signers
|
|
9
|
+
// on the vault, weight 1 against a release threshold of 2. It can ask, and it
|
|
10
|
+
// can execute a mandate a human already signed. It cannot release money on its
|
|
11
|
+
// own, and there is no flag here that accepts a principal or approver key at
|
|
12
|
+
// all. The guardrail is the ledger, so this server does not have to be trusted.
|
|
13
|
+
//
|
|
14
|
+
// Why the tool set looks like this: an agent's failure mode is not malice, it
|
|
15
|
+
// is confidence. It will retry a refused payment forever, and it will spend a
|
|
16
|
+
// budget it believes is free. So the read-only tools come first — what the
|
|
17
|
+
// human allowed, what is already spent according to the ledger, whether a
|
|
18
|
+
// specific payment would pass — and the one tool that moves anything returns
|
|
19
|
+
// the handle and the human's link rather than pretending the answer is in.
|
|
20
|
+
import { existsSync, readFileSync, statSync, realpathSync } from "node:fs";
|
|
21
|
+
import { resolve } from "node:path";
|
|
22
|
+
import { pathToFileURL } from "node:url";
|
|
23
|
+
import { Keypair } from "@stellar/stellar-sdk";
|
|
24
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
25
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
import { StellarVault, assetMatches } from "./vault.js";
|
|
28
|
+
import { PaydluxAgent } from "./agent.js";
|
|
29
|
+
import { PolicyEngine } from "./policy.js";
|
|
30
|
+
import { readEscrow, escrowOutcome, pendingEscrows } from "./escrow.js";
|
|
31
|
+
const HOME = "https://paydlux.com";
|
|
32
|
+
function arg(name) {
|
|
33
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
34
|
+
return i > -1 ? process.argv[i + 1] : undefined;
|
|
35
|
+
}
|
|
36
|
+
const flag = (name) => process.argv.includes(`--${name}`);
|
|
37
|
+
function pub(seed) { return Keypair.fromSecret(seed).publicKey(); }
|
|
38
|
+
/**
|
|
39
|
+
* Load the vault file and keep only what the buyer role needs.
|
|
40
|
+
*
|
|
41
|
+
* The file also holds the principal and approver seeds — the human's half of
|
|
42
|
+
* the vault. They are read once, converted to public keys for the ledger's
|
|
43
|
+
* signer check, and then the parsed object goes out of scope. A process that
|
|
44
|
+
* never holds the human keys cannot be talked into using them, which is the
|
|
45
|
+
* whole point of splitting them in the first place.
|
|
46
|
+
*/
|
|
47
|
+
function load(path) {
|
|
48
|
+
if (!existsSync(path)) {
|
|
49
|
+
throw new Error(`no vault file at ${path}. Point --file at a paydlux-*.json created by \`paydlux setup\`.`);
|
|
50
|
+
}
|
|
51
|
+
// Same stance as the CLI: a key file other users can read is not a key file.
|
|
52
|
+
const mode = statSync(path).mode & 0o777;
|
|
53
|
+
if (mode & 0o077) {
|
|
54
|
+
throw new Error(`${path} is readable by others (${mode.toString(8)}). Run: chmod 600 ${path}`);
|
|
55
|
+
}
|
|
56
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
57
|
+
const keys = raw.keys ?? {};
|
|
58
|
+
if (!keys.agent)
|
|
59
|
+
throw new Error(`${path} has no keys.agent — this server buys on the agent key's behalf`);
|
|
60
|
+
if (!keys.principal)
|
|
61
|
+
throw new Error(`${path} has no keys.principal — it is not a Paydlux vault file`);
|
|
62
|
+
if (!raw.vault)
|
|
63
|
+
throw new Error(`${path} has no vault address`);
|
|
64
|
+
return {
|
|
65
|
+
path,
|
|
66
|
+
network: (raw.network === "public" ? "public" : "testnet"),
|
|
67
|
+
vault: String(raw.vault),
|
|
68
|
+
token: { code: String(raw.token?.code ?? "XLM"), issuer: raw.token?.issuer },
|
|
69
|
+
payee: raw.payee?.address,
|
|
70
|
+
policy: raw.policy ?? {},
|
|
71
|
+
agentSeed: String(keys.agent),
|
|
72
|
+
principalPub: pub(String(keys.principal)),
|
|
73
|
+
approverPub: keys.approver ? pub(String(keys.approver)) : undefined,
|
|
74
|
+
agentPub: pub(String(keys.agent)),
|
|
75
|
+
readOnly: flag("read-only"),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// ----------------------------------------------------------------- helpers
|
|
79
|
+
let vaultHandle;
|
|
80
|
+
async function vault(cfg) {
|
|
81
|
+
if (vaultHandle)
|
|
82
|
+
return vaultHandle;
|
|
83
|
+
vaultHandle = await StellarVault.load(cfg.vault, cfg.token.code, cfg.token.issuer, { principal: cfg.principalPub, approver: cfg.approverPub, agent: cfg.agentPub }, cfg.network);
|
|
84
|
+
if (cfg.token.code === "USDC")
|
|
85
|
+
vaultHandle.useUsdc();
|
|
86
|
+
return vaultHandle;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* What left this vault today, straight from the ledger, for every payee.
|
|
90
|
+
*
|
|
91
|
+
* StellarVault.paidToday() answers for one payee. An agent asking "how much of
|
|
92
|
+
* my budget is left" means the total, and the in-RAM PolicyEngine counter resets
|
|
93
|
+
* on restart — a budget that reads as unspent after a restart is worse than no
|
|
94
|
+
* budget, so the honest number comes from Horizon. assetMatches is the same
|
|
95
|
+
* single comparison the rest of the codebase uses, so a native-XLM vault is not
|
|
96
|
+
* blind to its own money here.
|
|
97
|
+
*/
|
|
98
|
+
async function spentTodayAll(v) {
|
|
99
|
+
const start = new Date();
|
|
100
|
+
start.setUTCHours(0, 0, 0, 0);
|
|
101
|
+
const byPayee = {};
|
|
102
|
+
let total = 0;
|
|
103
|
+
let cursor;
|
|
104
|
+
const base = v.network === "public" ? "https://horizon.stellar.org" : "https://horizon-testnet.stellar.org";
|
|
105
|
+
for (let page = 0; page < 10; page++) {
|
|
106
|
+
const url = `${base}/accounts/${v.address}/payments?order=desc&limit=200${cursor ? `&cursor=${cursor}` : ""}`;
|
|
107
|
+
const res = await fetch(url);
|
|
108
|
+
if (!res.ok)
|
|
109
|
+
throw new Error(`ledger payments query failed: ${res.status}`);
|
|
110
|
+
const recs = (await res.json())?._embedded?.records ?? [];
|
|
111
|
+
if (!recs.length)
|
|
112
|
+
break;
|
|
113
|
+
for (const e of recs) {
|
|
114
|
+
if (new Date(e.created_at).getTime() < start.getTime())
|
|
115
|
+
return { total, byPayee };
|
|
116
|
+
if (e.type !== "payment" && e.type !== "claimable_balance_claim")
|
|
117
|
+
continue;
|
|
118
|
+
if (e.from !== v.address)
|
|
119
|
+
continue; // money leaving the vault only
|
|
120
|
+
if (!assetMatches(v.token, e))
|
|
121
|
+
continue; // the vault's own token, incl. XLM
|
|
122
|
+
total += Number(e.amount);
|
|
123
|
+
byPayee[e.to] = (byPayee[e.to] ?? 0) + Number(e.amount);
|
|
124
|
+
}
|
|
125
|
+
cursor = recs[recs.length - 1].paging_token;
|
|
126
|
+
}
|
|
127
|
+
return { total, byPayee };
|
|
128
|
+
}
|
|
129
|
+
async function recentPayments(v, limit) {
|
|
130
|
+
const base = v.network === "public" ? "https://horizon.stellar.org" : "https://horizon-testnet.stellar.org";
|
|
131
|
+
const res = await fetch(`${base}/accounts/${v.address}/payments?order=desc&limit=${Math.min(limit, 50)}`);
|
|
132
|
+
if (!res.ok)
|
|
133
|
+
throw new Error(`ledger payments query failed: ${res.status}`);
|
|
134
|
+
return ((await res.json())?._embedded?.records ?? []).map((e) => ({
|
|
135
|
+
at: e.created_at, type: e.type, from: e.from, to: e.to,
|
|
136
|
+
amount: e.amount, asset: e.asset_type === "native" ? "XLM" : `${e.asset_code}:${String(e.asset_issuer).slice(0, 6)}…`,
|
|
137
|
+
transaction: e.transaction_hash,
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Last line of defence on output. A Stellar secret seed is an S-prefixed
|
|
142
|
+
* 56-character base32 string; nothing this server returns has any business
|
|
143
|
+
* containing one, so if one ever reaches a tool result it is replaced before it
|
|
144
|
+
* reaches a model's context — and a model's context is a place secrets leak
|
|
145
|
+
* from, usually by being summarised into a public answer.
|
|
146
|
+
*/
|
|
147
|
+
function redact(s) {
|
|
148
|
+
return s.replace(/S[A-Z2-7]{55}/g, "[redacted: a secret seed reached an MCP tool result — report this]");
|
|
149
|
+
}
|
|
150
|
+
function text(obj) {
|
|
151
|
+
return { content: [{ type: "text", text: redact(JSON.stringify(obj, null, 2)) }] };
|
|
152
|
+
}
|
|
153
|
+
function fail(reason, extra = {}) {
|
|
154
|
+
return { content: [{ type: "text", text: redact(JSON.stringify({ ok: false, reason, ...extra }, null, 2)) }] };
|
|
155
|
+
}
|
|
156
|
+
/** Sellers answer with the payload an agent asked for; keep tool results small. */
|
|
157
|
+
function clip(data, max = 4000) {
|
|
158
|
+
if (typeof data !== "string")
|
|
159
|
+
return data;
|
|
160
|
+
return data.length > max ? `${data.slice(0, max)}… [+${data.length - max} chars]` : data;
|
|
161
|
+
}
|
|
162
|
+
// ------------------------------------------------------------------ server
|
|
163
|
+
function version() {
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return "0.0.0";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export function buildServer(cfg) {
|
|
172
|
+
const policy = new PolicyEngine(cfg.policy);
|
|
173
|
+
const server = new McpServer({ name: "paydlux", version: version() }, {
|
|
174
|
+
instructions: [
|
|
175
|
+
"Paydlux is spend-limited payments for AI agents. The money sits in a 2-of-2",
|
|
176
|
+
"Stellar vault; the agent key is one signer, a human is the other, so an agent",
|
|
177
|
+
"cannot release funds alone. Read paydlux_payment_rules before buying, check",
|
|
178
|
+
"paydlux_check_payment for the specific amount, then paydlux_buy. If a buy",
|
|
179
|
+
"returns needs_approval, tell the human and poll paydlux_payment_status — do not",
|
|
180
|
+
`retry the purchase, and do not open the desk link yourself. ${HOME}`,
|
|
181
|
+
].join("\n"),
|
|
182
|
+
});
|
|
183
|
+
// ---------------------------------------------------------------- reading
|
|
184
|
+
server.registerTool("paydlux_status", {
|
|
185
|
+
title: "Paydlux vault status",
|
|
186
|
+
description: "Which vault this agent spends from: network, token, live balance, the XLM rent that keeps the account alive, who can sign, and how many spending mandates are live. Read-only.",
|
|
187
|
+
}, async () => {
|
|
188
|
+
const v = await vault(cfg);
|
|
189
|
+
const signers = await v.signerSummary();
|
|
190
|
+
const acc = await v.horizon.loadAccount(v.address);
|
|
191
|
+
const xlm = acc.balances.find((b) => b.asset_type === "native")?.balance ?? "0";
|
|
192
|
+
const min = ((2 + (acc.subentry_count ?? 0)) * 0.5).toFixed(1);
|
|
193
|
+
return text({
|
|
194
|
+
ok: true,
|
|
195
|
+
network: cfg.network === "public" ? "mainnet (real money)" : "testnet",
|
|
196
|
+
vault: v.address,
|
|
197
|
+
token: cfg.token.code,
|
|
198
|
+
balance: await v.balance(),
|
|
199
|
+
xlm_rent: { have: xlm, need: min, short: Number(xlm) < Number(min) },
|
|
200
|
+
thresholds: signers.thresholds,
|
|
201
|
+
signers: signers.signers.map((s) => ({ weight: s.weight, kind: s.type })),
|
|
202
|
+
live_mandates: signers.signers.filter((s) => s.type === "preauth-mandate").length,
|
|
203
|
+
agent_key: cfg.agentPub,
|
|
204
|
+
explorer: `https://stellar.expert/explorer/${cfg.network === "public" ? "account" : "testnet/account"}/${v.address}`,
|
|
205
|
+
about: HOME,
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
server.registerTool("paydlux_payment_rules", {
|
|
209
|
+
title: "What the human allowed",
|
|
210
|
+
description: "The spending mandate as the human set it: per-payment ceiling, daily budget, seller allow/deny lists, category ceilings — plus what has actually been spent today according to the ledger, not this process's memory. Call this before buying.",
|
|
211
|
+
}, async () => {
|
|
212
|
+
const v = await vault(cfg);
|
|
213
|
+
const spent = await spentTodayAll(v);
|
|
214
|
+
const p = policy.policy;
|
|
215
|
+
return text({
|
|
216
|
+
ok: true,
|
|
217
|
+
token: cfg.token.code,
|
|
218
|
+
max_per_payment: p.maxPerPayment,
|
|
219
|
+
daily_budget: p.dailyBudget,
|
|
220
|
+
spent_today_ledger: Number(spent.total.toFixed(4)),
|
|
221
|
+
budget_left_today: Number((Number(p.dailyBudget ?? 0) - spent.total).toFixed(4)),
|
|
222
|
+
paid_to: spent.byPayee,
|
|
223
|
+
allowlist: p.allowlist?.length ? p.allowlist : "(any seller)",
|
|
224
|
+
denylist: p.denylist ?? [],
|
|
225
|
+
categories: p.categories ?? {},
|
|
226
|
+
payee_categories: p.payeeCategory ?? {},
|
|
227
|
+
max_live_mandates: p.maxLiveMandates,
|
|
228
|
+
note: "The ceiling is enforced on the ledger, not here. Refusing you early only saves you a failed transaction.",
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
server.registerTool("paydlux_check_payment", {
|
|
232
|
+
title: "Would this payment be allowed?",
|
|
233
|
+
description: "Dry-run one payment against the mandate: amount, seller, and anything already approved and waiting. Answers ok plus the headroom left, or which rule it breaks (max_per_payment, daily_budget, allowlist, denylist, category ceiling). No money moves.",
|
|
234
|
+
inputSchema: {
|
|
235
|
+
amount: z.number().positive().describe(`How much to pay, in the vault's token (${cfg.token.code})`),
|
|
236
|
+
payee: z.string().optional().describe("Seller account id (G…, 56 chars). Defaults to the vault's configured payee."),
|
|
237
|
+
committed: z.number().min(0).optional().describe("Amount already approved and still waiting on a signature, so the ceiling counts promises as well as payments"),
|
|
238
|
+
},
|
|
239
|
+
}, async ({ amount, payee, committed }) => {
|
|
240
|
+
const to = payee ?? cfg.payee;
|
|
241
|
+
if (!to)
|
|
242
|
+
return fail("no payee: pass payee, or set payee.address in the vault file");
|
|
243
|
+
const v = await vault(cfg);
|
|
244
|
+
const spent = await spentTodayAll(v);
|
|
245
|
+
// Seed the engine's day counter from the ledger before asking it anything.
|
|
246
|
+
// Its own counter starts empty in every fresh process, so an unseeded check
|
|
247
|
+
// would cheerfully approve a payment that blows a budget already spent —
|
|
248
|
+
// and a restart would reset the ceiling the mandate advertises.
|
|
249
|
+
policy.seedFromLedger(spent.total);
|
|
250
|
+
const verdict = policy.check({ amount, payee: to, committed });
|
|
251
|
+
return text({
|
|
252
|
+
ok: verdict.ok,
|
|
253
|
+
amount, payee: to, token: cfg.token.code,
|
|
254
|
+
rule: verdict.ok ? undefined : verdict.rule,
|
|
255
|
+
why: verdict.ok ? undefined : verdict.detail,
|
|
256
|
+
spent_today_ledger: Number(spent.total.toFixed(4)),
|
|
257
|
+
daily_budget: policy.policy?.dailyBudget,
|
|
258
|
+
max_per_payment: policy.policy?.maxPerPayment,
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
server.registerTool("paydlux_recent_payments", {
|
|
262
|
+
title: "Recent payments from the vault",
|
|
263
|
+
description: "The last payments in or out of the vault, straight from the Stellar ledger, with transaction hashes an explorer can verify. Read-only proof of what actually happened.",
|
|
264
|
+
inputSchema: { limit: z.number().int().min(1).max(50).optional().describe("How many records, default 10") },
|
|
265
|
+
}, async ({ limit }) => text({ ok: true, payments: await recentPayments(await vault(cfg), limit ?? 10) }));
|
|
266
|
+
server.registerTool("paydlux_escrow_status", {
|
|
267
|
+
title: "Escrow state",
|
|
268
|
+
description: "Look up a locked escrow by claimable-balance id: amount, when the seller can claim it, and until when the buyer can still take it back without the seller. Also reports who claimed it if it is already settled.",
|
|
269
|
+
inputSchema: { balance_id: z.string().describe("The escrow's claimable balance id (starts with B…)") },
|
|
270
|
+
}, async ({ balance_id }) => {
|
|
271
|
+
const e = await readEscrow(cfg.network, balance_id);
|
|
272
|
+
const outcome = await escrowOutcome(cfg.network, balance_id);
|
|
273
|
+
if (!e)
|
|
274
|
+
return text({ ok: true, escrow: null, outcome, note: "no live escrow with that id — it was claimed, refunded, or never existed" });
|
|
275
|
+
const now = Math.floor(Date.now() / 1000);
|
|
276
|
+
return text({
|
|
277
|
+
ok: true,
|
|
278
|
+
// One timer, two meanings: before releaseAt the buyer can take the money
|
|
279
|
+
// back alone, after it the seller can claim it alone. There is no separate
|
|
280
|
+
// refund deadline on the ledger, so do not invent one here.
|
|
281
|
+
escrow: {
|
|
282
|
+
balance_id: e.balanceId, amount: e.amount, asset: e.asset,
|
|
283
|
+
buyer: e.buyer, vault: e.vault, release_at: e.releaseAt,
|
|
284
|
+
lock_tx: e.txHash, memo: e.memo, claimed: e.claimed,
|
|
285
|
+
},
|
|
286
|
+
buyer_can_refund: !e.claimed && now < e.releaseAt,
|
|
287
|
+
seconds_until_seller_can_claim: Math.max(0, e.releaseAt - now),
|
|
288
|
+
outcome,
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
server.registerTool("paydlux_pending_escrows", {
|
|
292
|
+
title: "Escrows this vault is part of",
|
|
293
|
+
description: "Every live escrow the vault is a claimant on, oldest release first, with each one's refund window. Read-only.",
|
|
294
|
+
}, async () => {
|
|
295
|
+
const list = await pendingEscrows(cfg.network, cfg.vault);
|
|
296
|
+
const now = Math.floor(Date.now() / 1000);
|
|
297
|
+
return text({
|
|
298
|
+
ok: true, count: list.length,
|
|
299
|
+
escrows: list.map((e) => ({
|
|
300
|
+
balance_id: e.balanceId, amount: e.amount, asset: e.asset, buyer: e.buyer,
|
|
301
|
+
release_at: e.releaseAt, buyer_can_refund: !e.claimed && now < e.releaseAt,
|
|
302
|
+
})),
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
// ---------------------------------------------------------------- buying
|
|
306
|
+
if (!cfg.readOnly) {
|
|
307
|
+
server.registerTool("paydlux_buy", {
|
|
308
|
+
title: "Buy from a 402-gated seller",
|
|
309
|
+
description: "GET (or POST to) a URL that prices itself with HTTP 402. If the seller runs in approval mode the request is parked for a human: the result carries `handle`, `status_url` and the human's `desk` link — report those to the human and poll paydlux_payment_status. Do NOT retry the buy: a second request creates a second approval. A seller demanding a pre-authorised mandate is refused here, because creating that mandate needs the human's keys, which this server does not hold.",
|
|
310
|
+
inputSchema: {
|
|
311
|
+
url: z.string().url().describe("The paid resource, e.g. https://seller.example.com/api/data"),
|
|
312
|
+
body: z.string().optional().describe("Request body; sends POST instead of GET"),
|
|
313
|
+
},
|
|
314
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
315
|
+
}, async ({ url, body }) => {
|
|
316
|
+
const agent = new PaydluxAgent({
|
|
317
|
+
agentKey: cfg.agentSeed,
|
|
318
|
+
network: cfg.network,
|
|
319
|
+
// The agent must not sit inside one MCP call waiting for a person.
|
|
320
|
+
approvalTimeoutMs: 5_000, pollMs: 2_000,
|
|
321
|
+
});
|
|
322
|
+
// Escrow routes are funded by the buyer's own account, never by the agent
|
|
323
|
+
// key on a vault. The key is supplied out of band by whoever runs this
|
|
324
|
+
// server; without it the seller's refusal is reported as-is.
|
|
325
|
+
const buyerKey = process.env.PAYDLUX_BUYER_SECRET;
|
|
326
|
+
const res = await agent.get(url, { body, buyerKey });
|
|
327
|
+
const needsApproval = !res.ok && (res.handle || res.statusUrl);
|
|
328
|
+
return text({
|
|
329
|
+
ok: res.ok,
|
|
330
|
+
status: res.status,
|
|
331
|
+
data: clip(res.data),
|
|
332
|
+
tx: res.tx,
|
|
333
|
+
order: res.order,
|
|
334
|
+
escrow: res.escrow,
|
|
335
|
+
needs_approval: needsApproval || undefined,
|
|
336
|
+
handle: res.handle,
|
|
337
|
+
status_url: res.statusUrl,
|
|
338
|
+
desk: res.desk ? `${res.desk} (open this yourself — it needs the human's portal key)` : undefined,
|
|
339
|
+
reason: res.reason,
|
|
340
|
+
error: res.error,
|
|
341
|
+
requirements: res.requirements,
|
|
342
|
+
next: needsApproval
|
|
343
|
+
? "tell the human to approve on their desk, then call paydlux_payment_status with status_url and handle"
|
|
344
|
+
: undefined,
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
server.registerTool("paydlux_payment_status", {
|
|
348
|
+
title: "Did the human approve?",
|
|
349
|
+
description: "One status check on a parked payment, using the status_url and handle paydlux_buy returned. Polls the seller's gateway, which needs no key. Returns the settled record (with the tx hash) once decided, or the current pending state.",
|
|
350
|
+
inputSchema: {
|
|
351
|
+
status_url: z.string().url().describe("The status_url from paydlux_buy — never the desk link"),
|
|
352
|
+
handle: z.string().describe("The payment handle from paydlux_buy"),
|
|
353
|
+
wait_seconds: z.number().int().min(0).max(120).optional().describe("Keep polling up to this long inside the call. Default 0 = one check"),
|
|
354
|
+
},
|
|
355
|
+
}, async ({ status_url, handle, wait_seconds }) => {
|
|
356
|
+
const agent = new PaydluxAgent({
|
|
357
|
+
agentKey: cfg.agentSeed, network: cfg.network,
|
|
358
|
+
approvalTimeoutMs: (wait_seconds ?? 0) * 1000 + 2_000, pollMs: 2_000,
|
|
359
|
+
});
|
|
360
|
+
const rec = wait_seconds ? await agent.waitForApproval({ statusUrl: status_url }, handle)
|
|
361
|
+
: await fetch(status_url).then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
|
362
|
+
if (!rec)
|
|
363
|
+
return text({ ok: true, status: "pending", handle, note: "no decision yet — check again in a few seconds" });
|
|
364
|
+
return text({ ok: true, decision: rec });
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
return server;
|
|
368
|
+
}
|
|
369
|
+
// ------------------------------------------------------------------- main
|
|
370
|
+
export async function start() {
|
|
371
|
+
const file = resolve(process.cwd(), arg("file") ?? process.env.PAYDLUX_VAULT_FILE ?? "paydlux-vault.json");
|
|
372
|
+
const cfg = load(file);
|
|
373
|
+
const server = buildServer(cfg);
|
|
374
|
+
await server.connect(new StdioServerTransport());
|
|
375
|
+
// stdout is the JSON-RPC channel. Any human-facing line goes to stderr.
|
|
376
|
+
console.error(`paydlux mcp: ${cfg.network} vault ${cfg.vault}, token ${cfg.token.code}, tools ${cfg.readOnly ? "read-only" : "read + buy"}. ${HOME}`);
|
|
377
|
+
}
|
|
378
|
+
// Run only when this file is the entry point, so importing it in a check is
|
|
379
|
+
// safe. Compare realpaths, not the invocation string: npm's bin launcher runs
|
|
380
|
+
// this as .../node_modules/.bin/paydlux-mcp, which does not end in .js, so a
|
|
381
|
+
// name-based test silently turns the published command into a no-op.
|
|
382
|
+
const invoked = process.argv[1]
|
|
383
|
+
? pathToFileURL(existsSync(process.argv[1]) ? realpathSync(process.argv[1]) : process.argv[1]).href
|
|
384
|
+
: undefined;
|
|
385
|
+
if (invoked === import.meta.url) {
|
|
386
|
+
start().catch((e) => { console.error("paydlux mcp failed:", e.message); process.exit(1); });
|
|
387
|
+
}
|
package/dist/policy.d.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A named bucket of spending with its own ceilings.
|
|
3
|
+
*
|
|
4
|
+
* This is the "transaction categories" idea: the same mandate, sliced by what
|
|
5
|
+
* the money is for. A vault can say "data APIs cost me $5 a day, my coding
|
|
6
|
+
* assistant costs $50 a month" without two vaults or two mandates.
|
|
7
|
+
*
|
|
8
|
+
* A category can only TIGHTEN the base mandate. It has no floor of its own:
|
|
9
|
+
* a category ceiling above the base ceiling is a number that can never bind,
|
|
10
|
+
* because the base ceiling already refused the payment. That asymmetry is the
|
|
11
|
+
* security property — adding a category can never widen what the agent may
|
|
12
|
+
* spend, only narrow it.
|
|
13
|
+
*/
|
|
14
|
+
export type CategoryRule = {
|
|
15
|
+
/** ceiling on any single payment in this category */
|
|
16
|
+
maxPerPayment?: number;
|
|
17
|
+
/** total across a calendar day for this category */
|
|
18
|
+
dailyBudget?: number;
|
|
19
|
+
};
|
|
1
20
|
export type Policy = {
|
|
2
21
|
/** hard ceiling on any single payment */
|
|
3
22
|
maxPerPayment: number;
|
|
@@ -15,14 +34,23 @@ export type Policy = {
|
|
|
15
34
|
* matches what the ledger will actually honour.
|
|
16
35
|
*/
|
|
17
36
|
maxLiveMandates: number;
|
|
37
|
+
/** named spend buckets, keyed by category name */
|
|
38
|
+
categories?: Record<string, CategoryRule>;
|
|
39
|
+
/** which category a seller's account falls into */
|
|
40
|
+
payeeCategory?: Record<string, string>;
|
|
18
41
|
};
|
|
19
42
|
export declare const DEFAULT_POLICY: Policy;
|
|
43
|
+
/** A category name is rendered into HTML and stored in a config file, so it is
|
|
44
|
+
* reduced to a safe alphabet here rather than escaped at every use site. */
|
|
45
|
+
export declare function normalizeCategory(name: unknown): string;
|
|
20
46
|
export type SpendEvent = {
|
|
21
47
|
at: number;
|
|
22
48
|
amount: number;
|
|
23
49
|
payee: string;
|
|
24
50
|
uri?: string;
|
|
25
51
|
mandateHash?: string;
|
|
52
|
+
/** the bucket this spend counts against, when the payee is categorised */
|
|
53
|
+
category?: string;
|
|
26
54
|
};
|
|
27
55
|
export type Verdict = {
|
|
28
56
|
ok: true;
|
|
@@ -36,6 +64,20 @@ export declare class PolicyEngine {
|
|
|
36
64
|
policy: Policy;
|
|
37
65
|
constructor(policy?: Partial<Policy>);
|
|
38
66
|
record(e: SpendEvent): void;
|
|
67
|
+
/** Which bucket a seller's account belongs to, or undefined if uncategorised. */
|
|
68
|
+
categoryOf(payee: string): string | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Replace the policy from an untrusted source (the rules editor, a config
|
|
71
|
+
* file). Names are normalised and numbers validated here, so a bad value
|
|
72
|
+
* cannot smuggle markup or a negative ceiling into the engine.
|
|
73
|
+
*
|
|
74
|
+
* The owner may widen the counters this file keeps — the daily total and the
|
|
75
|
+
* allowlist are ours to count. What this cannot touch is the per-payment
|
|
76
|
+
* ceiling baked into the mandate's transaction hash: raise it here and the
|
|
77
|
+
* ledger still refuses above the mandate amount. `effectiveMaxPerPayment()`
|
|
78
|
+
* reports the number that actually binds so the UI can say so honestly.
|
|
79
|
+
*/
|
|
80
|
+
update(next: Partial<Policy>): Policy;
|
|
39
81
|
/**
|
|
40
82
|
* Seed the day's total from the ledger instead of from this process's memory.
|
|
41
83
|
*
|
|
@@ -44,9 +86,11 @@ export declare class PolicyEngine {
|
|
|
44
86
|
* holds until the next restart. Seeding from the ledger makes the budget a
|
|
45
87
|
* property of the vault, not of the uptime of whichever machine ran `sell`.
|
|
46
88
|
*/
|
|
47
|
-
seedFromLedger(amount: number): void;
|
|
89
|
+
seedFromLedger(amount: number, category?: string): void;
|
|
48
90
|
/** Everything spent in the current UTC day. */
|
|
49
91
|
spentToday(): number;
|
|
92
|
+
/** Everything spent in one category in the current UTC day. */
|
|
93
|
+
spentInCategory(category: string): number;
|
|
50
94
|
today(): SpendEvent[];
|
|
51
95
|
/**
|
|
52
96
|
* Check a request BEFORE any transaction is built. Returns the reason a
|
|
@@ -66,5 +110,11 @@ export declare class PolicyEngine {
|
|
|
66
110
|
* instead, which is the point of having a human in the loop.
|
|
67
111
|
*/
|
|
68
112
|
committed?: number;
|
|
113
|
+
/**
|
|
114
|
+
* Same, per category. A category budget that ignores the queue has the same
|
|
115
|
+
* hole the daily budget had: the human approves five payments, the fifth
|
|
116
|
+
* fails, and the approval gesture was wasted.
|
|
117
|
+
*/
|
|
118
|
+
committedInCategory?: number;
|
|
69
119
|
}): Verdict;
|
|
70
120
|
}
|
package/dist/policy.js
CHANGED
|
@@ -5,14 +5,81 @@ export const DEFAULT_POLICY = {
|
|
|
5
5
|
denylist: [],
|
|
6
6
|
maxMandateTtl: 3600,
|
|
7
7
|
maxLiveMandates: 1,
|
|
8
|
+
categories: {},
|
|
9
|
+
payeeCategory: {},
|
|
8
10
|
};
|
|
11
|
+
/** A category name is rendered into HTML and stored in a config file, so it is
|
|
12
|
+
* reduced to a safe alphabet here rather than escaped at every use site. */
|
|
13
|
+
export function normalizeCategory(name) {
|
|
14
|
+
return String(name ?? "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "").slice(0, 32);
|
|
15
|
+
}
|
|
9
16
|
export class PolicyEngine {
|
|
10
17
|
events = [];
|
|
11
18
|
policy;
|
|
12
19
|
constructor(policy = {}) {
|
|
13
20
|
this.policy = { ...DEFAULT_POLICY, ...policy };
|
|
14
21
|
}
|
|
15
|
-
record(e) {
|
|
22
|
+
record(e) {
|
|
23
|
+
if (!e.category) {
|
|
24
|
+
const named = this.categoryOf(e.payee);
|
|
25
|
+
if (named)
|
|
26
|
+
e = { ...e, category: named };
|
|
27
|
+
}
|
|
28
|
+
this.events.push(e);
|
|
29
|
+
}
|
|
30
|
+
/** Which bucket a seller's account belongs to, or undefined if uncategorised. */
|
|
31
|
+
categoryOf(payee) {
|
|
32
|
+
const named = this.policy.payeeCategory?.[payee];
|
|
33
|
+
return named ? normalizeCategory(named) : undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Replace the policy from an untrusted source (the rules editor, a config
|
|
37
|
+
* file). Names are normalised and numbers validated here, so a bad value
|
|
38
|
+
* cannot smuggle markup or a negative ceiling into the engine.
|
|
39
|
+
*
|
|
40
|
+
* The owner may widen the counters this file keeps — the daily total and the
|
|
41
|
+
* allowlist are ours to count. What this cannot touch is the per-payment
|
|
42
|
+
* ceiling baked into the mandate's transaction hash: raise it here and the
|
|
43
|
+
* ledger still refuses above the mandate amount. `effectiveMaxPerPayment()`
|
|
44
|
+
* reports the number that actually binds so the UI can say so honestly.
|
|
45
|
+
*/
|
|
46
|
+
update(next) {
|
|
47
|
+
const merged = { ...this.policy, ...next };
|
|
48
|
+
// A garbage or blank number falls back to the rule that was in force a
|
|
49
|
+
// moment ago, NOT to DEFAULT_POLICY. Falling back to the default would make
|
|
50
|
+
// a malformed edit silently LOOSEN the mandate — an empty maxPerPayment on
|
|
51
|
+
// a form would turn a 2 into the default 50. Keeping the previous value
|
|
52
|
+
// means a bad field is a no-op, which is the only safe reading of a form
|
|
53
|
+
// that gates money.
|
|
54
|
+
merged.maxPerPayment = positive(merged.maxPerPayment, this.policy.maxPerPayment);
|
|
55
|
+
merged.dailyBudget = positive(merged.dailyBudget, this.policy.dailyBudget);
|
|
56
|
+
merged.maxMandateTtl = positive(merged.maxMandateTtl, this.policy.maxMandateTtl);
|
|
57
|
+
merged.maxLiveMandates = 1; // Stellar sequence binding: see Policy.maxLiveMandates
|
|
58
|
+
merged.allowlist = (merged.allowlist ?? []).filter(isAccount);
|
|
59
|
+
merged.denylist = (merged.denylist ?? []).filter(isAccount);
|
|
60
|
+
const cats = {};
|
|
61
|
+
for (const [name, rule] of Object.entries(merged.categories ?? {})) {
|
|
62
|
+
const key = normalizeCategory(name);
|
|
63
|
+
if (!key)
|
|
64
|
+
continue;
|
|
65
|
+
cats[key] = {
|
|
66
|
+
maxPerPayment: rule?.maxPerPayment === undefined ? undefined : positive(rule.maxPerPayment, merged.maxPerPayment),
|
|
67
|
+
dailyBudget: rule?.dailyBudget === undefined ? undefined : positive(rule.dailyBudget, merged.dailyBudget),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
merged.categories = cats;
|
|
71
|
+
const map = {};
|
|
72
|
+
for (const [payee, name] of Object.entries(merged.payeeCategory ?? {})) {
|
|
73
|
+
if (!isAccount(payee))
|
|
74
|
+
continue;
|
|
75
|
+
const key = normalizeCategory(name);
|
|
76
|
+
if (key && cats[key])
|
|
77
|
+
map[payee] = key;
|
|
78
|
+
}
|
|
79
|
+
merged.payeeCategory = map;
|
|
80
|
+
this.policy = merged;
|
|
81
|
+
return this.policy;
|
|
82
|
+
}
|
|
16
83
|
/**
|
|
17
84
|
* Seed the day's total from the ledger instead of from this process's memory.
|
|
18
85
|
*
|
|
@@ -21,14 +88,14 @@ export class PolicyEngine {
|
|
|
21
88
|
* holds until the next restart. Seeding from the ledger makes the budget a
|
|
22
89
|
* property of the vault, not of the uptime of whichever machine ran `sell`.
|
|
23
90
|
*/
|
|
24
|
-
seedFromLedger(amount) {
|
|
91
|
+
seedFromLedger(amount, category) {
|
|
25
92
|
if (!(amount > 0))
|
|
26
93
|
return;
|
|
27
94
|
// One synthetic event at the start of the day: it counts toward the total
|
|
28
95
|
// without pretending to be a payment this process can re-enact.
|
|
29
96
|
const start = new Date();
|
|
30
97
|
start.setUTCHours(0, 0, 0, 0);
|
|
31
|
-
this.record({ at: start.getTime(), amount, payee: "ledger" });
|
|
98
|
+
this.record({ at: start.getTime(), amount, payee: "ledger", category });
|
|
32
99
|
}
|
|
33
100
|
/** Everything spent in the current UTC day. */
|
|
34
101
|
spentToday() {
|
|
@@ -37,6 +104,15 @@ export class PolicyEngine {
|
|
|
37
104
|
const t = start.getTime();
|
|
38
105
|
return this.events.filter((e) => e.at >= t).reduce((a, b) => a + b.amount, 0);
|
|
39
106
|
}
|
|
107
|
+
/** Everything spent in one category in the current UTC day. */
|
|
108
|
+
spentInCategory(category) {
|
|
109
|
+
const start = new Date();
|
|
110
|
+
start.setUTCHours(0, 0, 0, 0);
|
|
111
|
+
const t = start.getTime();
|
|
112
|
+
return this.events
|
|
113
|
+
.filter((e) => e.at >= t && e.category === category)
|
|
114
|
+
.reduce((a, b) => a + b.amount, 0);
|
|
115
|
+
}
|
|
40
116
|
today() {
|
|
41
117
|
const start = new Date();
|
|
42
118
|
start.setUTCHours(0, 0, 0, 0);
|
|
@@ -62,6 +138,22 @@ export class PolicyEngine {
|
|
|
62
138
|
if (p.allowlist?.length && !p.allowlist.includes(req.payee)) {
|
|
63
139
|
return no("allowlist", `${short(req.payee)} is not on the approved seller list`);
|
|
64
140
|
}
|
|
141
|
+
// Category ceilings, checked last so a refusal names the narrowest rule it
|
|
142
|
+
// actually broke. A category can only tighten the base mandate: the base
|
|
143
|
+
// ceilings above already ran, so a category limit above them is inert.
|
|
144
|
+
const category = this.categoryOf(req.payee);
|
|
145
|
+
if (category) {
|
|
146
|
+
const rule = p.categories?.[category];
|
|
147
|
+
if (rule?.maxPerPayment !== undefined && req.amount > rule.maxPerPayment) {
|
|
148
|
+
return no("category_max_per_payment", `${req.amount} exceeds the ${category} per-payment ceiling of ${rule.maxPerPayment}`);
|
|
149
|
+
}
|
|
150
|
+
if (rule?.dailyBudget !== undefined) {
|
|
151
|
+
const inCat = this.spentInCategory(category) + (req.committedInCategory ?? 0);
|
|
152
|
+
if (inCat + req.amount > rule.dailyBudget) {
|
|
153
|
+
return no("category_daily_budget", `${req.amount} would take the ${category} budget to ${(inCat + req.amount).toFixed(2)}, over its ${rule.dailyBudget} limit (${this.spentInCategory(category).toFixed(2)} paid${req.committedInCategory ? ` + ${req.committedInCategory.toFixed(2)} already approved and waiting` : ""})`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
65
157
|
if (req.ttl !== undefined && req.ttl > p.maxMandateTtl) {
|
|
66
158
|
return no("max_ttl", `mandate would live ${req.ttl}s, longer than the ${p.maxMandateTtl}s limit`);
|
|
67
159
|
}
|
|
@@ -73,3 +165,10 @@ export class PolicyEngine {
|
|
|
73
165
|
}
|
|
74
166
|
const no = (rule, detail) => ({ ok: false, rule, detail });
|
|
75
167
|
const short = (k) => k.slice(0, 6) + "…" + k.slice(-4);
|
|
168
|
+
/** A Stellar account id, and nothing else. */
|
|
169
|
+
const isAccount = (s) => /^G[A-Z2-7]{55}$/.test(String(s ?? ""));
|
|
170
|
+
/** Coerce to a usable positive number, falling back to the default. */
|
|
171
|
+
function positive(v, fallback) {
|
|
172
|
+
const n = Number(v);
|
|
173
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
174
|
+
}
|
package/dist/portal.d.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import type { DeskRecord } from "./desk.ts";
|
|
3
|
+
import type { Policy } from "./policy.ts";
|
|
3
4
|
/**
|
|
4
5
|
* HTML escaping. Not optional: the request path and body are attacker-chosen
|
|
5
6
|
* text that lands inside the one page that can authorise a payment.
|
|
6
7
|
*/
|
|
7
8
|
export declare function esc(v: unknown): string;
|
|
8
9
|
export declare function portalPage(records: DeskRecord[], vault: string, network: string, qs?: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* The spending rules, as a form the owner can read and edit.
|
|
12
|
+
*
|
|
13
|
+
* This is the surface the "customizable approval policy" request is really
|
|
14
|
+
* about: limits, approved wallets, and categories in one place, instead of
|
|
15
|
+
* command-line flags nobody outside a terminal can see.
|
|
16
|
+
*
|
|
17
|
+
* What it does NOT do is approve anything. Every payment still needs a human
|
|
18
|
+
* signature; these rules only decide what a human gets asked to sign. A rule
|
|
19
|
+
* set here can narrow the agent's reach, never widen what the ledger will
|
|
20
|
+
* accept, and the page says so in the same words the code enforces.
|
|
21
|
+
*/
|
|
22
|
+
export declare function rulesPage(policy: Policy, spent: {
|
|
23
|
+
total: number;
|
|
24
|
+
byCategory: Record<string, number>;
|
|
25
|
+
}, vault: string, network: string, qs?: string, saved?: boolean): string;
|
|
9
26
|
export declare function servePortal(req: IncomingMessage, res: ServerResponse, html: string): void;
|