@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/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) { this.events.push(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;