@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 +25 -0
- package/dist/cli.js +62 -13
- package/dist/desk.d.ts +42 -1
- package/dist/desk.js +208 -28
- package/dist/gateway.d.ts +6 -1
- package/dist/gateway.js +1 -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/package.json +3 -1
- package/portal-app/portal.css +22 -0
- package/portal-app/rules.js +62 -0
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
|
-
|
|
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 >
|
|
237
|
-
log(`note: --escrow price ${price} is above --max ${
|
|
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 >
|
|
242
|
-
bad(`--price ${price} is above --max ${
|
|
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 >
|
|
245
|
-
bad(`--price ${price} is above --daily ${
|
|
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 ${
|
|
273
|
-
if (alreadyPaid >=
|
|
274
|
-
bad(`today's budget is already gone: ${alreadyPaid.toFixed(2)} paid, ${
|
|
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 ${
|
|
283
|
-
daily budget ${
|
|
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 {
|
|
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
|
|
329
|
-
|
|
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
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
// Origin on POST and Sec-Fetch-Site on every request, so checking both
|
|
839
|
-
// closes that path while leaving local automation (curl, tests, the
|
|
840
|
-
// phone app on its own origin) alone.
|
|
841
|
-
const origin = String(req.headers.origin ?? "");
|
|
842
|
-
if (origin) {
|
|
843
|
-
const here = String(req.headers.host ?? "").toLowerCase();
|
|
844
|
-
let oh;
|
|
845
|
-
try {
|
|
846
|
-
oh = new URL(origin).host.toLowerCase();
|
|
847
|
-
}
|
|
848
|
-
catch {
|
|
849
|
-
oh = "";
|
|
850
|
-
}
|
|
851
|
-
if (oh !== here) {
|
|
852
|
-
return send(403, { error: "cross_origin", reason: `this desk only accepts actions from itself, not from ${origin}` });
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
const site = String(req.headers["sec-fetch-site"] ?? "").toLowerCase();
|
|
856
|
-
if (site && site !== "same-origin" && site !== "none") {
|
|
857
|
-
return send(403, { error: "cross_origin", reason: `this desk only accepts same-site actions (got ${site})` });
|
|
858
|
-
}
|
|
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
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;
|
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"
|
|
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.
|
|
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\"",
|
package/portal-app/portal.css
CHANGED
|
@@ -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
|
+
});
|