@paydlux/agent 0.1.22 → 0.1.23

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 CHANGED
@@ -242,6 +242,31 @@ too, because a budget that silently reads as unspent is worse than no budget.
242
242
  The banner shows what is left: `policy: max 10 per payment, 50 per day (27.50
243
243
  left today)`.
244
244
 
245
+ ### Spend it by category, and edit the rules in a browser
246
+
247
+ One vault, several pockets. Open **spending rules** on the approvals page (or
248
+ `/rules` on the desk) and set, per named category, its own per-payment ceiling and
249
+ daily budget — then say which seller wallet belongs to which category:
250
+
251
+ ```
252
+ data-api max 3 / payment 6 / day ← api.example.com wallet
253
+ coding max 1 / payment 2 / day ← assistant wallet
254
+ ```
255
+
256
+ A category can only be **tighter** than the limits above it; it can never raise
257
+ them, because the base ceilings are checked first. That asymmetry is the security
258
+ property, and `npm run test:policy` proves it, along with the queue counting
259
+ inside a category and the fact that a form posted from another site cannot raise
260
+ your budget.
261
+
262
+ Edits are saved to the vault's config file, so they survive a restart. Once you
263
+ have edited them there, the saved numbers win over `--max`/`--daily` on the next
264
+ start, and the seller prints which it is using — a rule that quietly reverts by
265
+ morning is worse than no rule.
266
+
267
+ `stellar/policy.ts` is the engine; the page is a plain HTML form, so it works with
268
+ JavaScript off.
269
+
245
270
  ### What is enforced where (said honestly)
246
271
 
247
272
  | enforced by Stellar | enforced by the desk (`policy.ts`) |
package/dist/cli.js CHANGED
@@ -221,7 +221,26 @@ async function sell() {
221
221
  // desk is even built.
222
222
  const maxPer = Number(arg("max") ?? data.policy?.maxPerPayment ?? 2);
223
223
  const daily = Number(arg("daily") ?? data.policy?.dailyBudget ?? 10);
224
- const policy = new PolicyEngine({ maxPerPayment: maxPer, dailyBudget: daily });
224
+ // Categories and the wallet map come from the config file only: they are set
225
+ // at the desk (/rules), not from the command line. A flag for them would be a
226
+ // second, easier way to say the same thing, and the page would drift from it.
227
+ //
228
+ // The same is true of the two ceilings once an owner edits them at the desk:
229
+ // the saved file wins over the flags, because the app scripts pass --max and
230
+ // --daily on every start and would otherwise silently undo whatever was set in
231
+ // the browser. The line printed below is what keeps that from being a
232
+ // surprise — a rule that quietly reverts is worse than no rule at all.
233
+ const saved = data.policy?.savedByOwner === true;
234
+ const maxPerFinal = saved ? Number(data.policy.maxPerPayment ?? maxPer) : maxPer;
235
+ const dailyFinal = saved ? Number(data.policy.dailyBudget ?? daily) : daily;
236
+ if (saved && (maxPerFinal !== maxPer || dailyFinal !== daily)) {
237
+ log(`using the rules you saved at the desk (max ${maxPerFinal}, daily ${dailyFinal}) instead of the command line (${maxPer}/${daily}).\n to change them: open the approvals link and press "spending rules".`);
238
+ }
239
+ const policy = new PolicyEngine({
240
+ maxPerPayment: maxPerFinal, dailyBudget: dailyFinal,
241
+ allowlist: data.policy?.allowlist, denylist: data.policy?.denylist,
242
+ categories: data.policy?.categories, payeeCategory: data.policy?.payeeCategory,
243
+ });
225
244
  // A ceiling below the price blocks every payment, and the failure looks like
226
245
  // a bug in the desk: the buyer waits, the human never sees an approvable
227
246
  // item, and the log says "mandate refused". Say it now, before anything is
@@ -233,16 +252,16 @@ async function sell() {
233
252
  // takes money IN from the buyer, so the ceiling does not apply to it — and
234
253
  // applying it would refuse a perfectly good escrow price for no reason.
235
254
  if (mode === "escrow") {
236
- if (priceNum > maxPer) {
237
- log(`note: --escrow price ${price} is above --max ${maxPer}. That is fine here — escrow money comes FROM the buyer, so your spending mandate never touches it.`);
255
+ if (priceNum > maxPerFinal) {
256
+ log(`note: --escrow price ${price} is above --max ${maxPerFinal}. That is fine here — escrow money comes FROM the buyer, so your spending mandate never touches it.`);
238
257
  }
239
258
  }
240
259
  else {
241
- if (priceNum > maxPer) {
242
- bad(`--price ${price} is above --max ${maxPer}, so every payment would be refused by your own mandate.\n raise --max to at least ${price} (for example --max ${price}) or lower --price.`);
260
+ if (priceNum > maxPerFinal) {
261
+ bad(`--price ${price} is above --max ${maxPerFinal}, so every payment would be refused by your own mandate.\n raise --max to at least ${price} (for example --max ${price}) or lower --price.`);
243
262
  }
244
- if (priceNum > daily) {
245
- bad(`--price ${price} is above --daily ${daily}: one payment would spend the whole day's budget and the second would be refused.\n set --daily to at least ${price}.`);
263
+ if (priceNum > dailyFinal) {
264
+ bad(`--price ${price} is above --daily ${dailyFinal}: one payment would spend the whole day's budget and the second would be refused.\n set --daily to at least ${price}.`);
246
265
  }
247
266
  }
248
267
  const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(keys.principal), approver: keys.approver ? pub(keys.approver) : undefined, agent: pub(keys.agent) }, network);
@@ -267,20 +286,31 @@ async function sell() {
267
286
  + `the daily budget of ${daily} cannot be enforced without it, so nothing was started. `
268
287
  + `Check the network and try again.`);
269
288
  }
270
- policy.seedFromLedger(alreadyPaid);
289
+ policy.seedFromLedger(alreadyPaid, policy.categoryOf(payee));
271
290
  if (alreadyPaid > 0) {
272
- log(`ledger says ${alreadyPaid.toFixed(2)} of the ${daily} daily budget is already spent today`);
273
- if (alreadyPaid >= daily) {
274
- bad(`today's budget is already gone: ${alreadyPaid.toFixed(2)} paid, ${daily} allowed.\n `
291
+ log(`ledger says ${alreadyPaid.toFixed(2)} of the ${dailyFinal} daily budget is already spent today`);
292
+ if (alreadyPaid >= dailyFinal) {
293
+ bad(`today's budget is already gone: ${alreadyPaid.toFixed(2)} paid, ${dailyFinal} allowed.\n `
275
294
  + `the mandate would refuse every payment. Raise --daily or wait for the UTC day to roll over.`);
276
295
  }
277
296
  }
297
+ // Say out loud what the rules are, including the categories, so the numbers
298
+ // an owner set in a browser are visible to whoever starts the seller. A rule
299
+ // nobody can see is a rule nobody trusts.
300
+ const catNames = Object.keys(policy.policy.categories ?? {});
301
+ if (catNames.length) {
302
+ const mapped = Object.entries(policy.policy.payeeCategory ?? {});
303
+ log(`spending categories: ${catNames.map((c) => {
304
+ const r = policy.policy.categories[c];
305
+ return `${c} (${r.maxPerPayment ?? policy.policy.maxPerPayment}/payment, ${r.dailyBudget ?? dailyFinal}/day)`;
306
+ }).join(", ")}${mapped.length ? ` · ${mapped.length} wallet(s) mapped` : ""}`);
307
+ }
278
308
  if (network === "public") {
279
309
  console.log(`
280
310
  MAINNET — real money.
281
311
  token ${data.token.code}
282
- max per payment ${maxPer}
283
- daily budget ${daily}
312
+ max per payment ${maxPerFinal}
313
+ daily budget ${dailyFinal}
284
314
  vault ${vault.address}
285
315
  balance ${await vault.balance()} ${data.token.code}
286
316
  `);
@@ -337,6 +367,25 @@ async function sell() {
337
367
  ],
338
368
  // The banner prints these numbers. This line is what makes them true.
339
369
  policy,
370
+ // Rules edited at the desk must survive the next restart, or the mandate in
371
+ // the banner is a promise that lasts until ctrl-c. Written back to the same
372
+ // config file, with the same 0600 as everywhere else in this file, because
373
+ // it also holds key material.
374
+ onPolicyChange: (p) => {
375
+ data.policy = {
376
+ ...(data.policy ?? {}),
377
+ // The marker that says "a human set these in a browser". Without it the
378
+ // app scripts' --max/--daily flags win on every restart and the edit is
379
+ // lost by morning.
380
+ savedByOwner: true,
381
+ maxPerPayment: p.maxPerPayment, dailyBudget: p.dailyBudget,
382
+ allowlist: p.allowlist ?? [], denylist: p.denylist ?? [],
383
+ categories: p.categories ?? {}, payeeCategory: p.payeeCategory ?? {},
384
+ };
385
+ writeFileSync(path, JSON.stringify(data, null, 2));
386
+ chmodSync(path, 0o600);
387
+ log(`rules saved to ${path}`);
388
+ },
340
389
  });
341
390
  let tunnel = null;
342
391
  if (tls) {
package/dist/desk.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import type { StellarVault } from "./vault.ts";
3
- import type { PolicyEngine } from "./policy.ts";
3
+ import type { PolicyEngine, Policy } from "./policy.ts";
4
4
  import { type ApprovalChallenge } from "./phone.ts";
5
5
  /** A portal link is a capability: whoever holds it can approve. So it is long,
6
6
  * random, and compared without leaking timing. */
@@ -96,10 +96,22 @@ export type DeskConfig = {
96
96
  * ceiling can actually hold.
97
97
  */
98
98
  policy?: PolicyEngine;
99
+ /**
100
+ * Called when the human edits the spending rules from the portal. The CLI
101
+ * wires this to the config file, so a rule set at the desk survives the next
102
+ * restart instead of resetting to the command-line defaults.
103
+ *
104
+ * A callback rather than a path: the desk must not know where secrets live.
105
+ * The config file also holds key material, so the writer stays with the code
106
+ * that already handles it (0600, same serialisation).
107
+ */
108
+ onPolicyChange?: (policy: Policy) => void;
99
109
  };
100
110
  export declare class ApprovalDesk {
101
111
  private records;
102
112
  private seq;
113
+ /** when /rules last saved, so the page can say "saved" without a query flag */
114
+ private rulesSavedAt;
103
115
  private server?;
104
116
  cfg: DeskConfig;
105
117
  constructor(cfg: DeskConfig);
@@ -168,6 +180,35 @@ export declare class ApprovalDesk {
168
180
  private mandateBlock;
169
181
  /** The mandate has been satisfied: count the money as spent. */
170
182
  private recordSpend;
183
+ /** The rules as they stand, for the /rules page. */
184
+ policyView(): Policy;
185
+ /** What today's counters read, per category, for the /rules page. */
186
+ private spentView;
187
+ /**
188
+ * The cross-origin gate used by every route that changes money or the rules
189
+ * that gate it. A cross-origin fetch POST is a CORS *simple request*: no
190
+ * preflight, so the attacker's page never sees the reply but the action still
191
+ * happens. Browsers always send Origin on POST and Sec-Fetch-Site on every
192
+ * request, so checking both closes that path while leaving local automation
193
+ * (curl, tests, the phone app on its own origin) alone.
194
+ *
195
+ * Returns a reason string when the request must be refused, null when it may
196
+ * proceed.
197
+ */
198
+ private crossOriginGate;
199
+ /**
200
+ * Apply an edit from the /rules form.
201
+ *
202
+ * The form carries the whole rule set, so this rebuilds rather than patches:
203
+ * what you see on the page is what gets saved, and a stale field cannot
204
+ * survive an edit. Names and numbers are validated inside PolicyEngine.update
205
+ * — this function only decides which fields the form is allowed to touch.
206
+ *
207
+ * Deliberately absent: maxMandateTtl and maxLiveMandates. Those are ledger
208
+ * constraints (sequence binding), not owner preferences, and a form field for
209
+ * them would be an invitation to configure a mandate the chain will refuse.
210
+ */
211
+ private applyRules;
171
212
  approve(id: string): Promise<DeskRecord>;
172
213
  /** Split mode step 1: ask the phone to sign. Nothing is submitted yet. */
173
214
  private requestPhoneSignature;
package/dist/desk.js CHANGED
@@ -17,7 +17,8 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
17
17
  import { Keypair } from "@stellar/stellar-sdk";
18
18
  import { TransactionBuilder } from "@stellar/stellar-sdk";
19
19
  import { passphrase } from "./vault.js";
20
- import { portalPage } from "./portal.js";
20
+ import { DEFAULT_POLICY, normalizeCategory } from "./policy.js";
21
+ import { portalPage, rulesPage } from "./portal.js";
21
22
  import { createApprovalChallenge, submitApproved, challengeQrPayload, } from "./phone.js";
22
23
  import { claimEscrow, prepareEscrowRelease } from "./escrow.js";
23
24
  /**
@@ -181,6 +182,30 @@ function cookieOf(req, name) {
181
182
  }
182
183
  return null;
183
184
  }
185
+ /**
186
+ * Form-field readers for /rules. A form is untrusted input arriving on a route
187
+ * that gates money, so each reader narrows to one type instead of trusting the
188
+ * field name. Empty means "not supplied", which lets a blank input fall back to
189
+ * the engine's default rather than becoming a zero.
190
+ */
191
+ function numField(v) {
192
+ const s = String(v ?? "").trim();
193
+ if (!s)
194
+ return undefined;
195
+ const n = Number(s);
196
+ return Number.isFinite(n) && n > 0 ? n : undefined;
197
+ }
198
+ /** One account id per line. Anything that is not G…56 is dropped, not warned. */
199
+ function linesField(v) {
200
+ return String(v ?? "").split(/[\s,]+/).map((s) => s.trim().toUpperCase())
201
+ .filter((s) => /^G[A-Z2-7]{55}$/.test(s));
202
+ }
203
+ /** A Stellar account id, and nothing else. Same shape the policy engine accepts. */
204
+ const isAccountId = (s) => /^G[A-Z2-7]{55}$/.test(s);
205
+ /** A comma-separated list of ids carried by a hidden field. */
206
+ function listField(v) {
207
+ return String(v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
208
+ }
184
209
  function same(a, b) {
185
210
  const x = Buffer.from(a), y = Buffer.from(b);
186
211
  return x.length === y.length && timingSafeEqual(x, y);
@@ -188,6 +213,8 @@ 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;
191
218
  server;
192
219
  cfg;
193
220
  constructor(cfg) { this.cfg = cfg; }
@@ -325,13 +352,20 @@ export class ApprovalDesk {
325
352
  // `pending` deliberately does NOT count: that is a request nobody has
326
353
  // approved, and counting the whole queue would refuse the first approval
327
354
  // just because more requests exist than the budget can cover.
328
- const committed = this.list().reduce((sum, r) => (r.id !== rec.id && (r.status === "awaiting_phone" || r.status === "approved"))
329
- ? sum + Number(r.price) : sum, 0);
355
+ const waiting = this.list().filter((r) => r.id !== rec.id && (r.status === "awaiting_phone" || r.status === "approved"));
356
+ const committed = waiting.reduce((sum, r) => sum + Number(r.price), 0);
357
+ // The same promise, counted inside the payee's category. A category budget
358
+ // that ignored the queue would let the human approve five payments and have
359
+ // the fifth fail at submission. This desk pays one payee, so every waiting
360
+ // record belongs to that payee's bucket — no per-record lookup needed.
361
+ const category = policy.categoryOf(this.cfg.payee);
362
+ const committedInCategory = category ? committed : undefined;
330
363
  const verdict = policy.check({
331
364
  amount: Number(rec.price),
332
365
  payee: this.cfg.payee,
333
366
  ttl: this.cfg.decideWithinSeconds,
334
367
  committed,
368
+ committedInCategory,
335
369
  });
336
370
  if (verdict.ok)
337
371
  return null;
@@ -350,6 +384,136 @@ export class ApprovalDesk {
350
384
  uri: rec.uri, mandateHash: tx,
351
385
  });
352
386
  }
387
+ /** The rules as they stand, for the /rules page. */
388
+ policyView() {
389
+ return this.cfg.policy?.policy ?? { ...DEFAULT_POLICY };
390
+ }
391
+ /** What today's counters read, per category, for the /rules page. */
392
+ spentView() {
393
+ const p = this.cfg.policy;
394
+ if (!p)
395
+ return { total: 0, byCategory: {} };
396
+ const byCategory = {};
397
+ for (const name of Object.keys(p.policy.categories ?? {}))
398
+ byCategory[name] = p.spentInCategory(name);
399
+ return { total: p.spentToday(), byCategory };
400
+ }
401
+ /**
402
+ * The cross-origin gate used by every route that changes money or the rules
403
+ * that gate it. A cross-origin fetch POST is a CORS *simple request*: no
404
+ * preflight, so the attacker's page never sees the reply but the action still
405
+ * happens. Browsers always send Origin on POST and Sec-Fetch-Site on every
406
+ * request, so checking both closes that path while leaving local automation
407
+ * (curl, tests, the phone app on its own origin) alone.
408
+ *
409
+ * Returns a reason string when the request must be refused, null when it may
410
+ * proceed.
411
+ */
412
+ crossOriginGate(req) {
413
+ const origin = String(req.headers.origin ?? "");
414
+ if (origin) {
415
+ const here = String(req.headers.host ?? "").toLowerCase();
416
+ let oh;
417
+ try {
418
+ oh = new URL(origin).host.toLowerCase();
419
+ }
420
+ catch {
421
+ oh = "";
422
+ }
423
+ if (oh !== here)
424
+ return `this desk only accepts actions from itself, not from ${origin}`;
425
+ }
426
+ const site = String(req.headers["sec-fetch-site"] ?? "").toLowerCase();
427
+ if (site && site !== "same-origin" && site !== "none") {
428
+ return `this desk only accepts same-site actions (got ${site})`;
429
+ }
430
+ return null;
431
+ }
432
+ /**
433
+ * Apply an edit from the /rules form.
434
+ *
435
+ * The form carries the whole rule set, so this rebuilds rather than patches:
436
+ * what you see on the page is what gets saved, and a stale field cannot
437
+ * survive an edit. Names and numbers are validated inside PolicyEngine.update
438
+ * — this function only decides which fields the form is allowed to touch.
439
+ *
440
+ * Deliberately absent: maxMandateTtl and maxLiveMandates. Those are ledger
441
+ * constraints (sequence binding), not owner preferences, and a form field for
442
+ * them would be an invitation to configure a mandate the chain will refuse.
443
+ */
444
+ applyRules(action, form) {
445
+ const engine = this.cfg.policy;
446
+ if (!engine)
447
+ return { ...DEFAULT_POLICY };
448
+ const current = engine.policy;
449
+ const dropped = new Set(listField(form.get("delcat")));
450
+ // A category whose name was edited, old -> new. The page renders the name as
451
+ // a hidden field, so this only fires for a hand-crafted POST; keeping the
452
+ // mapping rather than dropping it means a rename never silently removes a
453
+ // wallet's ceiling.
454
+ const renamed = new Map();
455
+ const next = {};
456
+ const maxPer = numField(form.get("maxPerPayment"));
457
+ const daily = numField(form.get("dailyBudget"));
458
+ if (maxPer !== undefined)
459
+ next.maxPerPayment = maxPer;
460
+ if (daily !== undefined)
461
+ next.dailyBudget = daily;
462
+ next.allowlist = linesField(form.get("allowlist"));
463
+ next.denylist = linesField(form.get("denylist"));
464
+ // Categories: rebuilt from the rows the page shows, minus the ones the
465
+ // human removed, plus the one they just typed.
466
+ const categories = {};
467
+ for (const [key, value] of form) {
468
+ const m = /^cat_(.{1,32})_name$/.exec(key);
469
+ if (!m)
470
+ continue;
471
+ const was = m[1];
472
+ const name = normalizeCategory(value || was);
473
+ if (!name || dropped.has(was) || dropped.has(name))
474
+ continue;
475
+ if (name !== was)
476
+ renamed.set(was, name);
477
+ categories[name] = {
478
+ maxPerPayment: numField(form.get(`cat_${was}_max`)),
479
+ dailyBudget: numField(form.get(`cat_${was}_daily`)),
480
+ };
481
+ }
482
+ // Both "add" boxes are processed whenever they are filled, not only when
483
+ // their own button was pressed. The alternative is a form where typing a
484
+ // wallet and pressing the wrong button silently discards what was typed —
485
+ // and a rule that vanishes without an error is worse than a strict one.
486
+ const addCat = normalizeCategory(form.get("newcat"));
487
+ if (addCat && !dropped.has(addCat)) {
488
+ categories[addCat] = {
489
+ maxPerPayment: numField(form.get("newcat_max")),
490
+ dailyBudget: numField(form.get("newcat_daily")),
491
+ };
492
+ }
493
+ next.categories = categories;
494
+ // Wallet → category map: keep what is there, drop what was removed, add the
495
+ // new one. Rebuilt from the engine rather than the form, because a form
496
+ // field for the whole map would let a POST invent mappings for accounts
497
+ // nobody typed. A renamed bucket carries its wallets with it, so a rename
498
+ // can never quietly drop a ceiling.
499
+ const droppedMap = new Set(listField(form.get("delmap")));
500
+ const payeeCategory = {};
501
+ for (const [payee, name] of Object.entries(current.payeeCategory ?? {})) {
502
+ if (droppedMap.has(payee))
503
+ continue;
504
+ payeeCategory[payee] = renamed.get(name) ?? name;
505
+ }
506
+ const addWallet = String(form.get("newpayee") ?? "").trim().toUpperCase();
507
+ const addCatForWallet = normalizeCategory(form.get("newpayee_cat"));
508
+ if (isAccountId(addWallet) && addCatForWallet && categories[addCatForWallet]) {
509
+ payeeCategory[addWallet] = addCatForWallet;
510
+ }
511
+ next.payeeCategory = payeeCategory;
512
+ const policy = engine.update(next);
513
+ console.log(`[desk] spending rules updated (${action}): max ${policy.maxPerPayment}, daily ${policy.dailyBudget}, `
514
+ + `${Object.keys(policy.categories ?? {}).length} categories, ${Object.keys(policy.payeeCategory ?? {}).length} wallets mapped`);
515
+ return policy;
516
+ }
353
517
  async approve(id) {
354
518
  this.sweepExpired();
355
519
  const rec = this.records.get(id);
@@ -765,6 +929,43 @@ export class ApprovalDesk {
765
929
  }
766
930
  if (url.pathname === "/list")
767
931
  return send(200, this.list());
932
+ // The spending rules. Read-only view of the mandate the desk enforces,
933
+ // plus the form that edits it. Key-gated like everything else: these
934
+ // numbers decide what money leaves the vault, so an unauthenticated
935
+ // /rules would be an open "raise the budget" endpoint.
936
+ if (url.pathname === "/rules" && req.method === "GET") {
937
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
938
+ res.end(rulesPage(this.policyView(), this.spentView(), this.cfg.vault.address, this.cfg.vault.network, qs, Date.now() - this.rulesSavedAt < 15_000));
939
+ return;
940
+ }
941
+ if (url.pathname === "/rules.js") {
942
+ const body = readPortalFile("rules.js");
943
+ if (body === null)
944
+ return notFound(res);
945
+ res.writeHead(200, {
946
+ "content-type": "text/javascript; charset=utf-8",
947
+ "x-content-type-options": "nosniff",
948
+ });
949
+ res.end(body);
950
+ return;
951
+ }
952
+ if (url.pathname === "/rules" && req.method === "POST") {
953
+ // Editing the rules moves money indirectly, so it gets the same
954
+ // cross-origin gate as /approve: a page on another site must not be
955
+ // able to POST a form that raises your daily budget.
956
+ const gate = this.crossOriginGate(req);
957
+ if (gate)
958
+ return send(403, { error: "cross_origin", reason: gate });
959
+ const body = await readBody(req);
960
+ const form = new URLSearchParams(body);
961
+ const action = form.get("action") ?? "save";
962
+ const policy = this.applyRules(action, form);
963
+ this.rulesSavedAt = Date.now();
964
+ this.cfg.onPolicyChange?.(policy);
965
+ res.writeHead(303, { location: "/rules" + (this.cfg.portalKey ? `?k=${encodeURIComponent(this.cfg.portalKey)}` : "") });
966
+ res.end();
967
+ return;
968
+ }
768
969
  // The approver app's list view. Key-gated like everything else, and it
769
970
  // carries no paid content and no challenge XDR.
770
971
  if (url.pathname === "/queue")
@@ -831,31 +1032,10 @@ export class ApprovalDesk {
831
1032
  const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
832
1033
  if (m && req.method === "POST") {
833
1034
  // These routes move money, so they must not be reachable from another
834
- // page. A cross-origin fetch POST is a CORS *simple request*: no
835
- // preflight, so the attacker's page never sees the reply but the
836
- // approval still happens. Verified end to end: a page on another port
837
- // settled a parked payment and paid the seller. Browsers always send
838
- // Origin on POST and Sec-Fetch-Site on every request, so checking both
839
- // closes that path while leaving local automation (curl, tests, the
840
- // phone app on its own origin) alone.
841
- const origin = String(req.headers.origin ?? "");
842
- if (origin) {
843
- const here = String(req.headers.host ?? "").toLowerCase();
844
- let oh;
845
- try {
846
- oh = new URL(origin).host.toLowerCase();
847
- }
848
- catch {
849
- oh = "";
850
- }
851
- if (oh !== here) {
852
- return send(403, { error: "cross_origin", reason: `this desk only accepts actions from itself, not from ${origin}` });
853
- }
854
- }
855
- const site = String(req.headers["sec-fetch-site"] ?? "").toLowerCase();
856
- if (site && site !== "same-origin" && site !== "none") {
857
- return send(403, { error: "cross_origin", reason: `this desk only accepts same-site actions (got ${site})` });
858
- }
1035
+ // page — same gate as /rules, see crossOriginGate.
1036
+ const gate = this.crossOriginGate(req);
1037
+ if (gate)
1038
+ return send(403, { error: "cross_origin", reason: gate });
859
1039
  if (m[1] === "signed") {
860
1040
  const body = await readBody(req);
861
1041
  const { signedXdr } = JSON.parse(body || "{}");
package/dist/gateway.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type IncomingMessage, type Server, type ServerResponse } from "node:htt
2
2
  import { Keypair, Transaction } from "@stellar/stellar-sdk";
3
3
  import { StellarVault, StellarTxError, type Amount, type Mandate, type Network } from "./vault.ts";
4
4
  import { ApprovalDesk, type DeskStore } from "./desk.ts";
5
- import type { PolicyEngine } from "./policy.ts";
5
+ import type { PolicyEngine, Policy } from "./policy.ts";
6
6
  import { claimEscrow } from "./escrow.ts";
7
7
  export declare const PAY_HEADER = "x-payment";
8
8
  export type Route = {
@@ -71,6 +71,11 @@ export type GatewayConfig = {
71
71
  * enforced — see DeskConfig.policy for why the gateway is the wrong place.
72
72
  */
73
73
  policy?: PolicyEngine;
74
+ /**
75
+ * Persist an edited spending policy. Passed straight to the desk; see
76
+ * DeskConfig.onPolicyChange for why it is a callback.
77
+ */
78
+ onPolicyChange?: (policy: Policy) => void;
74
79
  };
75
80
  export type PaymentProof = {
76
81
  /** pre-authorised envelope, base64 XDR, as issued by the vault */
package/dist/gateway.js CHANGED
@@ -49,6 +49,7 @@ export class Gateway {
49
49
  publicHost: cfg.publicHost,
50
50
  decideWithinSeconds: cfg.decideWithinSeconds,
51
51
  policy: cfg.policy,
52
+ onPolicyChange: cfg.onPolicyChange,
52
53
  });
53
54
  }
54
55
  /** Mandates the vault currently holds, so the gateway can hand them out. */
package/dist/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;
package/dist/portal.js CHANGED
@@ -19,12 +19,108 @@ export function portalPage(records, vault, network, qs = "") {
19
19
  <title>Paydlux — approvals</title>
20
20
  <link rel="stylesheet" href="portal.css"></head><body>
21
21
  <h1>Paydlux — approvals</h1>
22
- <div class="sub">vault ${esc(vault)} · ${esc(network)} · refreshed <span id="t">now</span></div>
22
+ <div class="sub"><a class="nav" href="/${qs}">approvals</a> · <a class="nav" href="/rules${qs}">spending rules</a><br>
23
+ vault ${esc(vault)} · ${esc(network)} · refreshed <span id="t">now</span></div>
23
24
  <table><thead><tr><th>when</th><th>what</th><th>cost</th><th>status</th><th></th></tr></thead>
24
25
  <tbody>${rows}</tbody></table>
25
26
  <div class="foot">Approving signs a Stellar transaction with your key. The vault needs two signatures, so this device alone cannot release funds.</div>
26
27
  <script src="portal.js"></script></body></html>`;
27
28
  }
29
+ /**
30
+ * The spending rules, as a form the owner can read and edit.
31
+ *
32
+ * This is the surface the "customizable approval policy" request is really
33
+ * about: limits, approved wallets, and categories in one place, instead of
34
+ * command-line flags nobody outside a terminal can see.
35
+ *
36
+ * What it does NOT do is approve anything. Every payment still needs a human
37
+ * signature; these rules only decide what a human gets asked to sign. A rule
38
+ * set here can narrow the agent's reach, never widen what the ledger will
39
+ * accept, and the page says so in the same words the code enforces.
40
+ */
41
+ export function rulesPage(policy, spent, vault, network, qs = "", saved = false) {
42
+ const cats = Object.entries(policy.categories ?? {});
43
+ const catRows = cats.length
44
+ ? cats.map(([name, rule]) => categoryRow(name, rule, policy, spent.byCategory[name] ?? 0)).join("\n")
45
+ : `<tr><td colspan="5" class="empty">no categories — every seller shares the daily budget</td></tr>`;
46
+ const mapped = Object.entries(policy.payeeCategory ?? {});
47
+ return `<!doctype html><html><head>
48
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
49
+ <title>Paydlux — spending rules</title>
50
+ <link rel="stylesheet" href="portal.css"></head><body>
51
+ <h1>Paydlux — spending rules</h1>
52
+ <div class="sub"><a class="nav" href="/${qs}">approvals</a> · <a class="nav" href="/rules${qs}">spending rules</a><br>
53
+ vault ${esc(vault)} · ${esc(network)}${saved ? ` · <span class="saved">saved</span>` : ""}</div>
54
+
55
+ <form id="rules" method="post" action="/rules${qs}">
56
+ <h2>Every payment</h2>
57
+ <div class="grid">
58
+ <label>Most for one payment
59
+ <input name="maxPerPayment" type="number" min="0.0000001" step="0.0000001" value="${esc(policy.maxPerPayment)}"></label>
60
+ <label>Daily budget
61
+ <input name="dailyBudget" type="number" min="0.0000001" step="0.0000001" value="${esc(policy.dailyBudget)}"></label>
62
+ </div>
63
+ <p class="note">Spent today: <b>${esc(spent.total.toFixed(7))}</b> of ${esc(policy.dailyBudget)}. The day resets at 00:00 UTC.</p>
64
+
65
+ <h2>Wallets the agent may pay</h2>
66
+ <div class="grid">
67
+ <label>Allowed (one account per line, blank = any)
68
+ <textarea name="allowlist" rows="3" spellcheck="false" autocomplete="off">${esc((policy.allowlist ?? []).join("\n"))}</textarea></label>
69
+ <label>Never allowed, even if listed above
70
+ <textarea name="denylist" rows="3" spellcheck="false" autocomplete="off">${esc((policy.denylist ?? []).join("\n"))}</textarea></label>
71
+ </div>
72
+
73
+ <h2>Categories</h2>
74
+ <p class="note">Split the daily budget by what the money is for. A category can only be
75
+ tighter than the limits above — it can never raise them.</p>
76
+ <table class="cats"><thead><tr><th>name</th><th>max / payment</th><th>daily</th><th>spent today</th><th></th></tr></thead>
77
+ <tbody id="catbody">${catRows}</tbody></table>
78
+ <div class="addcat">
79
+ <input name="newcat" placeholder="name (a-z, 0-9)" maxlength="32" spellcheck="false" autocomplete="off">
80
+ <input name="newcat_max" type="number" min="0.0000001" step="0.0000001" placeholder="max / payment">
81
+ <input name="newcat_daily" type="number" min="0.0000001" step="0.0000001" placeholder="daily">
82
+ <button class="yes" type="submit" name="action" value="add">Add category</button>
83
+ </div>
84
+
85
+ <h2>Which wallet is in which category</h2>
86
+ ${mapped.length
87
+ ? `<table class="map"><thead><tr><th>wallet</th><th>category</th></tr></thead><tbody>
88
+ ${mapped.map(([payee, name]) => `<tr><td class="tx">${esc(payee)}</td><td>${esc(name)}`
89
+ + ` <button class="no mini" data-del="${esc(payee)}" data-map="1">Remove</button></td></tr>`).join("\n")}
90
+ </tbody></table>`
91
+ : `<p class="note">Nothing categorised yet. Add a wallet below to cap it on its own.</p>`}
92
+ <div class="addcat">
93
+ <input name="newpayee" placeholder="G… wallet address" maxlength="56" spellcheck="false" autocomplete="off">
94
+ <input name="newpayee_cat" placeholder="category" maxlength="32" spellcheck="false" autocomplete="off">
95
+ <button class="yes" type="submit" name="action" value="map">Add</button>
96
+ </div>
97
+
98
+ <div class="save">
99
+ <button class="yes" type="submit" name="action" value="save">Save rules</button>
100
+ </div>
101
+ </form>
102
+
103
+ <div class="foot">These rules decide what you are <i>asked</i> to approve. They never approve
104
+ anything themselves: every payment still needs your signature, and the vault needs two.</div>
105
+ <script src="rules.js"></script></body></html>`;
106
+ }
107
+ function categoryRow(name, rule, policy, spent) {
108
+ // Names go in both a hidden value and the delete button's data-*, so they are
109
+ // escaped like any other attacker-chosen string — even though update() already
110
+ // reduced them to [a-z0-9_-].
111
+ //
112
+ // The name is deliberately NOT an editable input. Renaming a bucket would
113
+ // orphan every wallet mapped to it, and an orphaned wallet stops having a
114
+ // category ceiling — a rename would silently loosen the rules while looking
115
+ // like a cosmetic edit. Renaming is therefore remove + add, which forces the
116
+ // human to re-map the wallets and see what they are doing.
117
+ return `<tr>
118
+ <td><span class="catname">${esc(name)}</span><input type="hidden" name="cat_${esc(name)}_name" value="${esc(name)}"></td>
119
+ <td><input name="cat_${esc(name)}_max" type="number" min="0.0000001" step="0.0000001" value="${esc(rule.maxPerPayment ?? "")}" placeholder="${esc(policy.maxPerPayment)}"></td>
120
+ <td><input name="cat_${esc(name)}_daily" type="number" min="0.0000001" step="0.0000001" value="${esc(rule.dailyBudget ?? "")}" placeholder="${esc(policy.dailyBudget)}"></td>
121
+ <td class="amt">${esc(spent.toFixed(7))}</td>
122
+ <td><button class="no" data-del="${esc(name)}">Remove</button></td></tr>`;
123
+ }
28
124
  function render(r, qs = "") {
29
125
  // qs is the portal key query string ("?k=..."). The approver link has to carry
30
126
  // it, because the desk answers 403 for anything without it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Payments for AI agents that a human approves. Spending mandates enforced on the Stellar ledger: 2-of-2 vault, pre-authorised transaction envelopes, HTTP 402, USDC.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -24,6 +24,7 @@
24
24
  "phone-app/icon-512.png",
25
25
  "portal-app/portal.js",
26
26
  "portal-app/portal.css",
27
+ "portal-app/rules.js",
27
28
  "README.md",
28
29
  "LICENSE"
29
30
  ],
@@ -44,6 +45,7 @@
44
45
  "test:stellar": "tsx stellar/demo.ts",
45
46
  "test:portal": "tsx stellar/xss-check.ts",
46
47
  "test:mandate": "tsx stellar/mandate-check.ts",
48
+ "test:policy": "tsx stellar/policy-check.ts",
47
49
  "test:phone": "tsx stellar/phone-check.ts && tsx stellar/desk-split-check.ts && tsx stellar/mandate-check.ts && tsx stellar/e2e-phone.ts && tsx phone-app/xdr-check.ts && tsx phone-app/app-check.ts && tsx phone-app/desk-app-check.ts && tsx ios/bridge-check.ts && tsx phone-app/ui-check.ts",
48
50
  "test:ios": "tsx ios/bridge-check.ts",
49
51
  "test:link": "swiftc ios/PaydluxApprover/LinkResolver.swift ios/Checks/linkcheck/main.swift -o \"$TMPDIR/linkcheck\" && \"$TMPDIR/linkcheck\"",
@@ -19,4 +19,26 @@ button{border:0;border-radius:10px;padding:12px 18px;font-size:15px;font-weight:
19
19
  details summary{cursor:pointer}
20
20
  .phlink{color:var(--gold);text-decoration:underline;font-size:12px}
21
21
  .foot{margin-top:28px;color:var(--muted);font-size:12px}
22
+
23
+ /* --- spending rules page --- */
24
+ h2{font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:28px 0 10px}
25
+ .grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
26
+ label{display:block;color:var(--muted);font-size:12px}
27
+ input,textarea{display:block;width:100%;margin-top:6px;background:#141715;color:var(--text);
28
+ border:1px solid var(--line);border-radius:10px;padding:11px 12px;font:15px/1.4 ui-monospace,monospace}
29
+ textarea{resize:vertical}
30
+ input:focus,textarea:focus{outline:none;border-color:var(--gold)}
31
+ .note{color:var(--muted);font-size:13px;margin:10px 0}
32
+ .note b{color:var(--gold)}
33
+ table.cats,table.map{margin-top:6px}
34
+ table.cats td{padding:8px 6px}
35
+ .addcat{display:flex;gap:10px;align-items:end;margin-top:10px;flex-wrap:wrap}
36
+ .addcat input{flex:1;min-width:140px}
37
+ .addcat button{width:auto;flex:0 0 auto}
38
+ .save{margin-top:26px}
39
+ .save button{width:auto;min-width:180px}
40
+ button.mini{display:inline;width:auto;padding:4px 10px;font-size:12px;margin:0}
41
+ .nav{color:var(--gold);text-decoration:underline}
42
+ .saved{color:var(--green);font-weight:700}
43
+ @media(max-width:640px){.grid{grid-template-columns:1fr}}
22
44
  @media(max-width:640px){.body{max-width:180px}}
@@ -0,0 +1,62 @@
1
+ // The spending-rules page's client half. Served by the desk at /rules.js.
2
+ //
3
+ // The page is a plain HTML form, so it works with JavaScript off. This file
4
+ // only adds the two things a form cannot do by itself: a Remove button that
5
+ // queues the row for deletion and submits, and a confirm before a rule that
6
+ // gates money is loosened.
7
+ //
8
+ // No inline handlers, so the page's CSP keeps script-src 'self'. Category names
9
+ // arrive from the server already reduced to [a-z0-9_-]; they are still read
10
+ // through dataset, never interpolated into markup, so nothing here can be
11
+ // turned into HTML by a name.
12
+ const form = document.getElementById("rules");
13
+ if (!form) throw new Error("rules page has no #rules form");
14
+
15
+ // Hidden fields the server reads as "delete these". Accumulating rather than
16
+ // replacing means a human can remove three rows before pressing Save.
17
+ const hidden = (name) => {
18
+ let el = form.querySelector(`input[name="${name}"]`);
19
+ if (!el) {
20
+ el = document.createElement("input");
21
+ el.type = "hidden";
22
+ el.name = name;
23
+ form.appendChild(el);
24
+ }
25
+ return el;
26
+ };
27
+
28
+ document.addEventListener("click", (e) => {
29
+ const b = e.target.closest("button[data-del]");
30
+ if (!b) return;
31
+ e.preventDefault();
32
+ const field = b.dataset.map ? "delmap" : "delcat";
33
+ const el = hidden(field);
34
+ const val = b.dataset.del;
35
+ const have = el.value ? el.value.split(",") : [];
36
+ if (!have.includes(val)) have.push(val);
37
+ el.value = have.join(",");
38
+ // The row goes immediately: the human sees the rule stop protecting them
39
+ // before they press Save, which is the point of the confirm.
40
+ b.closest("tr")?.remove();
41
+ form.requestSubmit();
42
+ });
43
+
44
+ // A wider ceiling is the one edit that can only hurt. The ledger still caps a
45
+ // single payment at the mandate amount, but the daily budget and the seller
46
+ // list are ours to count, so raising them is a real loosening. Ask once.
47
+ const limits = ["maxPerPayment", "dailyBudget"];
48
+ const start = Object.fromEntries(
49
+ limits.map((n) => [n, form.querySelector(`[name="${n}"]`)?.value ?? ""]));
50
+
51
+ form.addEventListener("submit", (e) => {
52
+ const raised = limits.filter((n) => {
53
+ const now = Number(form.querySelector(`[name="${n}"]`)?.value);
54
+ const was = Number(start[n]);
55
+ return Number.isFinite(now) && Number.isFinite(was) && now > was;
56
+ });
57
+ if (!raised.length) return;
58
+ const ok = confirm(
59
+ `This raises ${raised.join(" and ")}. Higher limits mean the agent may ask you for ` +
60
+ `more money, more often. Continue?`);
61
+ if (!ok) e.preventDefault();
62
+ });