@paydlux/agent 0.1.21 → 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 +102 -23
- package/dist/desk.d.ts +101 -1
- package/dist/desk.js +400 -29
- package/dist/gateway.d.ts +12 -1
- package/dist/gateway.js +14 -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 +4 -2
- package/phone-app/app.js +26 -6
- package/phone-app/index.html +1 -1
- package/phone-app/xdr.js +23 -4
- package/portal-app/portal.css +22 -0
- package/portal-app/rules.js +62 -0
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\"",
|
|
@@ -65,7 +67,7 @@
|
|
|
65
67
|
"address:check": "tsx stellar/check-address.ts",
|
|
66
68
|
"test:address": "tsx stellar/address-check.ts",
|
|
67
69
|
"site:deploy": "tsx stellar/make-site-qr.ts paydlux-mainnet.json site/qr.svg && wrangler deploy",
|
|
68
|
-
"test:escrow": "tsx stellar/escrow-check.ts && tsx stellar/escrow-gateway-check.ts"
|
|
70
|
+
"test:escrow": "tsx stellar/escrow-check.ts && tsx stellar/escrow-gateway-check.ts && tsx stellar/escrow-phone-check.ts"
|
|
69
71
|
},
|
|
70
72
|
"keywords": [
|
|
71
73
|
"x402",
|
package/phone-app/app.js
CHANGED
|
@@ -371,8 +371,16 @@ async function openReview(id) {
|
|
|
371
371
|
const rec = await api("/challenge/" + encodeURIComponent(id));
|
|
372
372
|
const env = X.parseEnvelope(rec.xdr);
|
|
373
373
|
const op = env.tx.ops[0];
|
|
374
|
-
|
|
375
|
-
|
|
374
|
+
// Two shapes are signable: a single payment, and a single escrow claim
|
|
375
|
+
// (the vault taking money a buyer locked in a claimable balance). Anything
|
|
376
|
+
// else is refused, and the escrow branch verifies the claim targets the
|
|
377
|
+
// balance id the desk says this approval is for.
|
|
378
|
+
const isClaim = op.type === X.OP_CLAIM_CLAIMABLE_BALANCE;
|
|
379
|
+
if (env.tx.ops.length !== 1 || (op.type !== X.OP_PAYMENT && !isClaim)) {
|
|
380
|
+
throw new Error("this transaction is not a single payment or escrow claim \u2014 refusing to sign it");
|
|
381
|
+
}
|
|
382
|
+
if (isClaim && rec.escrowId && op.balanceId !== rec.escrowId) {
|
|
383
|
+
throw new Error("the claim targets a different balance than this approval is for \u2014 refusing");
|
|
376
384
|
}
|
|
377
385
|
const { hash } = await X.transactionHash(rec.xdr, X.NETWORK_ID[rec.network] || rec.network);
|
|
378
386
|
if (rec.hash && hash !== rec.hash) {
|
|
@@ -391,10 +399,14 @@ async function openReview(id) {
|
|
|
391
399
|
}
|
|
392
400
|
current = {
|
|
393
401
|
id, xdr: rec.xdr, hash,
|
|
394
|
-
amount:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
402
|
+
// A claim op carries no amount: the money is whatever the ledger says the
|
|
403
|
+
// balance holds. The desk read that number when it queued the release,
|
|
404
|
+
// so the human sees it from the desk, and the balance id next to it is
|
|
405
|
+
// what ties the number to the order.
|
|
406
|
+
amount: isClaim ? String(rec.amount ?? "") : X.formatAmount(op.amount),
|
|
407
|
+
token: isClaim ? String(rec.asset ?? "") : (op.asset.code || "XLM"),
|
|
408
|
+
issuer: isClaim ? "" : (op.asset.issuer || ""),
|
|
409
|
+
payee: isClaim ? op.balanceId : X.encodeAccountEd25519(op.destination.pub),
|
|
398
410
|
vault: X.encodeAccountEd25519(env.tx.source.pub),
|
|
399
411
|
// Read the memo out of the envelope, not out of the server's summary:
|
|
400
412
|
// the envelope is what is being signed, so it is the only source that
|
|
@@ -417,6 +429,10 @@ async function openReview(id) {
|
|
|
417
429
|
$("amount").append(num, tk);
|
|
418
430
|
set("wantsBig", current.wants);
|
|
419
431
|
set("token", current.issuer ? `${current.token} (${current.issuer.slice(0, 12)}\u2026)` : current.token);
|
|
432
|
+
// An escrow claim has no payee: the money goes to the vault itself. What
|
|
433
|
+
// the human verifies instead is the balance id — the ledger address of the
|
|
434
|
+
// locked money — so the row is labelled for that.
|
|
435
|
+
$("payeeLabel").textContent = isClaim ? "Escrow balance" : "Payee";
|
|
420
436
|
set("payee", current.payee);
|
|
421
437
|
set("vault", current.vault);
|
|
422
438
|
set("wants", current.wants);
|
|
@@ -432,6 +448,10 @@ async function openReview(id) {
|
|
|
432
448
|
$("netChip").style.color = current.network === "public" ? "var(--ok)" : "var(--warn)";
|
|
433
449
|
err("signErr", "");
|
|
434
450
|
resetHold();
|
|
451
|
+
// The hold button says what it does. Releasing an escrow is not paying
|
|
452
|
+
// anyone: it is the vault taking money a buyer already locked. After
|
|
453
|
+
// resetHold, which resets the label to the payment wording.
|
|
454
|
+
if (isClaim) set("holdLbl", "Hold to release \u0026 sign");
|
|
435
455
|
show("review", true);
|
|
436
456
|
show("queue", false);
|
|
437
457
|
$("review").scrollIntoView({ behavior: "smooth", block: "start" });
|
package/phone-app/index.html
CHANGED
|
@@ -235,7 +235,7 @@
|
|
|
235
235
|
<div class="hint" id="wantsBig"></div>
|
|
236
236
|
<dl class="kv" style="margin-top:16px">
|
|
237
237
|
<dt>Token</dt><dd id="token"></dd>
|
|
238
|
-
<dt>Payee</dt><dd id="payee"></dd>
|
|
238
|
+
<dt id="payeeLabel">Payee</dt><dd id="payee"></dd>
|
|
239
239
|
<dt>From</dt><dd id="vault"></dd>
|
|
240
240
|
<dt>Wants</dt><dd id="wants"></dd>
|
|
241
241
|
<dt>Reference</dt><dd id="memo"></dd>
|
package/phone-app/xdr.js
CHANGED
|
@@ -76,6 +76,10 @@
|
|
|
76
76
|
const SC_ADDRESS = 4; // soroban account: we refuse it
|
|
77
77
|
const ASSET_NATIVE = 0, ASSET_ALPHANUM4 = 1, ASSET_ALPHANUM12 = 2;
|
|
78
78
|
const OP_PAYMENT = 1;
|
|
79
|
+
// ClaimClaimableBalance is the escrow release: the vault taking the money the
|
|
80
|
+
// buyer locked. The phone must read it as well as payments, or a seller on a
|
|
81
|
+
// --split vault can never release an escrow from the phone.
|
|
82
|
+
const OP_CLAIM_CLAIMABLE_BALANCE = 15;
|
|
79
83
|
const PRECOND_NONE = 0, PRECOND_TIME = 1, PRECOND_V2 = 2;
|
|
80
84
|
const MEMO_NONE = 0, MEMO_TEXT = 1, MEMO_ID = 2, MEMO_HASH = 3, MEMO_RETURN = 4;
|
|
81
85
|
const STROOPS = 10000000;
|
|
@@ -318,14 +322,29 @@
|
|
|
318
322
|
const hasSource = r.u32();
|
|
319
323
|
const source = hasSource ? readMuxedAccount(r) : null;
|
|
320
324
|
const type = r.u32(); // OperationBody union switch
|
|
325
|
+
if (type === OP_CLAIM_CLAIMABLE_BALANCE) {
|
|
326
|
+
// ClaimableBalanceID is an union: switch word, then 32 raw bytes. Only
|
|
327
|
+
// the balance-id form (switch 0) exists today; anything else is refused
|
|
328
|
+
// rather than skipped, because skipping bytes misreads the rest.
|
|
329
|
+
const which = r.u32();
|
|
330
|
+
if (which !== 0) throw new Error("claimable balance id form " + which + " is not supported; refusing to sign");
|
|
331
|
+
// The balance id AS THE LEDGER AND THE SDK NAME IT: the 4-byte union
|
|
332
|
+
// switch ("00000000") followed by the 32-byte hash. Returning only the
|
|
333
|
+
// hash would disagree with every id the desk and Horizon use, and the
|
|
334
|
+
// desk's "this claim targets THIS escrow" check would never match.
|
|
335
|
+
const balanceId = "00000000" + hex(r.fixed(32));
|
|
336
|
+
// The claim op carries NO amount: the money is whatever the ledger says
|
|
337
|
+
// that balance holds. The human verifies the balance id instead.
|
|
338
|
+
return { type, source, balanceId, destination: null, asset: null, amount: null };
|
|
339
|
+
}
|
|
321
340
|
if (type !== OP_PAYMENT) {
|
|
322
|
-
throw new Error("operation type " + type + " is not a payment; this app only signs
|
|
341
|
+
throw new Error("operation type " + type + " is not a payment or an escrow claim; this app only signs those");
|
|
323
342
|
}
|
|
324
343
|
const destination = readMuxedAccount(r);
|
|
325
344
|
const asset = readAsset(r);
|
|
326
345
|
const amount = r.i64();
|
|
327
346
|
if (amount < 0n) throw new Error("payment amount is negative; refusing to sign");
|
|
328
|
-
return { type, source, destination, asset, amount: amount.toString() };
|
|
347
|
+
return { type, source, destination, asset, amount: amount.toString(), balanceId: null };
|
|
329
348
|
}
|
|
330
349
|
|
|
331
350
|
function readTransaction(r) {
|
|
@@ -336,7 +355,7 @@
|
|
|
336
355
|
const memo = readMemo(r);
|
|
337
356
|
const numOps = r.u32();
|
|
338
357
|
if (numOps !== 1) {
|
|
339
|
-
throw new Error("this app signs
|
|
358
|
+
throw new Error("this app signs one operation at a time, and this one has "
|
|
340
359
|
+ numOps + " operations");
|
|
341
360
|
}
|
|
342
361
|
const ops = [];
|
|
@@ -471,6 +490,6 @@
|
|
|
471
490
|
encodeAccountEd25519, decodeAccountEd25519,
|
|
472
491
|
decodeSecretSeed, encodeSecretSeed, importSecretSeed, importSecretSeedPair,
|
|
473
492
|
NETWORK_ID, crc16Xmodem,
|
|
474
|
-
ENVELOPE_TYPE_TX, OP_PAYMENT, STROOPS,
|
|
493
|
+
ENVELOPE_TYPE_TX, OP_PAYMENT, OP_CLAIM_CLAIMABLE_BALANCE, STROOPS,
|
|
475
494
|
};
|
|
476
495
|
})(typeof globalThis !== "undefined" ? globalThis : this);
|
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
|
+
});
|