@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 CHANGED
@@ -242,6 +242,31 @@ too, because a budget that silently reads as unspent is worse than no budget.
242
242
  The banner shows what is left: `policy: max 10 per payment, 50 per day (27.50
243
243
  left today)`.
244
244
 
245
+ ### Spend it by category, and edit the rules in a browser
246
+
247
+ One vault, several pockets. Open **spending rules** on the approvals page (or
248
+ `/rules` on the desk) and set, per named category, its own per-payment ceiling and
249
+ daily budget — then say which seller wallet belongs to which category:
250
+
251
+ ```
252
+ data-api max 3 / payment 6 / day ← api.example.com wallet
253
+ coding max 1 / payment 2 / day ← assistant wallet
254
+ ```
255
+
256
+ A category can only be **tighter** than the limits above it; it can never raise
257
+ them, because the base ceilings are checked first. That asymmetry is the security
258
+ property, and `npm run test:policy` proves it, along with the queue counting
259
+ inside a category and the fact that a form posted from another site cannot raise
260
+ your budget.
261
+
262
+ Edits are saved to the vault's config file, so they survive a restart. Once you
263
+ have edited them there, the saved numbers win over `--max`/`--daily` on the next
264
+ start, and the seller prints which it is using — a rule that quietly reverts by
265
+ morning is worse than no rule.
266
+
267
+ `stellar/policy.ts` is the engine; the page is a plain HTML form, so it works with
268
+ JavaScript off.
269
+
245
270
  ### What is enforced where (said honestly)
246
271
 
247
272
  | enforced by Stellar | enforced by the desk (`policy.ts`) |
package/dist/cli.js CHANGED
@@ -221,7 +221,26 @@ async function sell() {
221
221
  // desk is even built.
222
222
  const maxPer = Number(arg("max") ?? data.policy?.maxPerPayment ?? 2);
223
223
  const daily = Number(arg("daily") ?? data.policy?.dailyBudget ?? 10);
224
- const policy = new PolicyEngine({ maxPerPayment: maxPer, dailyBudget: daily });
224
+ // Categories and the wallet map come from the config file only: they are set
225
+ // at the desk (/rules), not from the command line. A flag for them would be a
226
+ // second, easier way to say the same thing, and the page would drift from it.
227
+ //
228
+ // The same is true of the two ceilings once an owner edits them at the desk:
229
+ // the saved file wins over the flags, because the app scripts pass --max and
230
+ // --daily on every start and would otherwise silently undo whatever was set in
231
+ // the browser. The line printed below is what keeps that from being a
232
+ // surprise — a rule that quietly reverts is worse than no rule at all.
233
+ const saved = data.policy?.savedByOwner === true;
234
+ const maxPerFinal = saved ? Number(data.policy.maxPerPayment ?? maxPer) : maxPer;
235
+ const dailyFinal = saved ? Number(data.policy.dailyBudget ?? daily) : daily;
236
+ if (saved && (maxPerFinal !== maxPer || dailyFinal !== daily)) {
237
+ log(`using the rules you saved at the desk (max ${maxPerFinal}, daily ${dailyFinal}) instead of the command line (${maxPer}/${daily}).\n to change them: open the approvals link and press "spending rules".`);
238
+ }
239
+ const policy = new PolicyEngine({
240
+ maxPerPayment: maxPerFinal, dailyBudget: dailyFinal,
241
+ allowlist: data.policy?.allowlist, denylist: data.policy?.denylist,
242
+ categories: data.policy?.categories, payeeCategory: data.policy?.payeeCategory,
243
+ });
225
244
  // A ceiling below the price blocks every payment, and the failure looks like
226
245
  // a bug in the desk: the buyer waits, the human never sees an approvable
227
246
  // item, and the log says "mandate refused". Say it now, before anything is
@@ -233,16 +252,16 @@ async function sell() {
233
252
  // takes money IN from the buyer, so the ceiling does not apply to it — and
234
253
  // applying it would refuse a perfectly good escrow price for no reason.
235
254
  if (mode === "escrow") {
236
- if (priceNum > maxPer) {
237
- log(`note: --escrow price ${price} is above --max ${maxPer}. That is fine here — escrow money comes FROM the buyer, so your spending mandate never touches it.`);
255
+ if (priceNum > maxPerFinal) {
256
+ log(`note: --escrow price ${price} is above --max ${maxPerFinal}. That is fine here — escrow money comes FROM the buyer, so your spending mandate never touches it.`);
238
257
  }
239
258
  }
240
259
  else {
241
- if (priceNum > maxPer) {
242
- bad(`--price ${price} is above --max ${maxPer}, so every payment would be refused by your own mandate.\n raise --max to at least ${price} (for example --max ${price}) or lower --price.`);
260
+ if (priceNum > maxPerFinal) {
261
+ bad(`--price ${price} is above --max ${maxPerFinal}, so every payment would be refused by your own mandate.\n raise --max to at least ${price} (for example --max ${price}) or lower --price.`);
243
262
  }
244
- if (priceNum > daily) {
245
- bad(`--price ${price} is above --daily ${daily}: one payment would spend the whole day's budget and the second would be refused.\n set --daily to at least ${price}.`);
263
+ if (priceNum > dailyFinal) {
264
+ bad(`--price ${price} is above --daily ${dailyFinal}: one payment would spend the whole day's budget and the second would be refused.\n set --daily to at least ${price}.`);
246
265
  }
247
266
  }
248
267
  const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(keys.principal), approver: keys.approver ? pub(keys.approver) : undefined, agent: pub(keys.agent) }, network);
@@ -267,20 +286,31 @@ async function sell() {
267
286
  + `the daily budget of ${daily} cannot be enforced without it, so nothing was started. `
268
287
  + `Check the network and try again.`);
269
288
  }
270
- policy.seedFromLedger(alreadyPaid);
289
+ policy.seedFromLedger(alreadyPaid, policy.categoryOf(payee));
271
290
  if (alreadyPaid > 0) {
272
- log(`ledger says ${alreadyPaid.toFixed(2)} of the ${daily} daily budget is already spent today`);
273
- if (alreadyPaid >= daily) {
274
- bad(`today's budget is already gone: ${alreadyPaid.toFixed(2)} paid, ${daily} allowed.\n `
291
+ log(`ledger says ${alreadyPaid.toFixed(2)} of the ${dailyFinal} daily budget is already spent today`);
292
+ if (alreadyPaid >= dailyFinal) {
293
+ bad(`today's budget is already gone: ${alreadyPaid.toFixed(2)} paid, ${dailyFinal} allowed.\n `
275
294
  + `the mandate would refuse every payment. Raise --daily or wait for the UTC day to roll over.`);
276
295
  }
277
296
  }
297
+ // Say out loud what the rules are, including the categories, so the numbers
298
+ // an owner set in a browser are visible to whoever starts the seller. A rule
299
+ // nobody can see is a rule nobody trusts.
300
+ const catNames = Object.keys(policy.policy.categories ?? {});
301
+ if (catNames.length) {
302
+ const mapped = Object.entries(policy.policy.payeeCategory ?? {});
303
+ log(`spending categories: ${catNames.map((c) => {
304
+ const r = policy.policy.categories[c];
305
+ return `${c} (${r.maxPerPayment ?? policy.policy.maxPerPayment}/payment, ${r.dailyBudget ?? dailyFinal}/day)`;
306
+ }).join(", ")}${mapped.length ? ` · ${mapped.length} wallet(s) mapped` : ""}`);
307
+ }
278
308
  if (network === "public") {
279
309
  console.log(`
280
310
  MAINNET — real money.
281
311
  token ${data.token.code}
282
- max per payment ${maxPer}
283
- daily budget ${daily}
312
+ max per payment ${maxPerFinal}
313
+ daily budget ${dailyFinal}
284
314
  vault ${vault.address}
285
315
  balance ${await vault.balance()} ${data.token.code}
286
316
  `);
@@ -322,6 +352,7 @@ async function sell() {
322
352
  // --split is the honest mode: this process cannot approve anything.
323
353
  approver: split ? undefined : keys.approver,
324
354
  split,
355
+ agentSeed: keys.agent,
325
356
  portalKey,
326
357
  publicHost,
327
358
  agentKeys: [pub(keys.agent)],
@@ -336,6 +367,25 @@ async function sell() {
336
367
  ],
337
368
  // The banner prints these numbers. This line is what makes them true.
338
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
+ },
339
389
  });
340
390
  let tunnel = null;
341
391
  if (tls) {
@@ -846,21 +896,50 @@ async function escrow() {
846
896
  const keys = data.keys ?? {};
847
897
  const agent = Keypair.fromSecret(keys.agent);
848
898
  if (!keys.approver) {
899
+ // The recommended setup: the desk is running with the phone approver, so
900
+ // hand the release to the DESK instead of writing a file nothing reads.
901
+ // The desk queues it, the phone signs the claim, the desk submits it.
902
+ const deskPort = Number(arg("desk") ?? 4022);
903
+ const portalKey = data.desk?.portalKey;
904
+ if (portalKey) {
905
+ try {
906
+ const res = await fetch(`http://127.0.0.1:${deskPort}/escrow?k=${encodeURIComponent(portalKey)}`, {
907
+ method: "POST",
908
+ headers: { "content-type": "application/json" },
909
+ body: JSON.stringify({
910
+ escrowId: hit.balanceId, order: hit.memo || undefined,
911
+ amount: hit.amount,
912
+ asset: hit.asset === "native" ? "XLM" : hit.asset.split(":")[0],
913
+ buyer: hit.buyer,
914
+ }),
915
+ });
916
+ if (res.ok) {
917
+ const queued = await res.json();
918
+ console.log(`\nrelease queued on the approval desk as ${queued.id}.`);
919
+ console.log(` open: ${flag("tls") ? `https://${PUBLIC_HOST}` : `http://127.0.0.1:${deskPort}`}/phone/?k=${portalKey}`);
920
+ console.log(` the order is ${hit.memo || hit.balanceId} — approve on the phone only if you delivered it.`);
921
+ console.log(` the phone signs the claim; the desk submits it. vault balance updates after.\n`);
922
+ return;
923
+ }
924
+ console.log(`the desk answered ${res.status}; falling back to the manual challenge.\n`);
925
+ }
926
+ catch {
927
+ // No desk listening on that port. Fall through to the file path.
928
+ }
929
+ }
849
930
  const challenge = await prepareEscrowRelease(network, hit.balanceId, vault.address, agent);
850
931
  console.log(`\nthis laptop holds one of the two keys, so the release needs your phone.\n`);
851
932
  console.log(describeChallenge(challenge));
852
- console.log(`\n open: ${flag("tls") ? `https://${PUBLIC_HOST}` : "http://127.0.0.1:4022"}/?k=${data.desk?.portalKey ?? "…"}`);
853
- console.log(` the order id is ${hit.memo || hit.balanceId} — sign only if you delivered it.\n`);
854
- // Written out so the second signature can be added by whatever holds the
855
- // approver key, without retyping XDR. Said exactly, because a promise here
856
- // that nothing implements is a lie in the seller's terminal: no process
857
- // reads this file back on its own.
933
+ console.log(`\n the order id is ${hit.memo || hit.balanceId} — sign only if you delivered it.\n`);
934
+ console.log(`no approval desk is answering on port ${deskPort}. start one with \`paydlux sell\` (or the`);
935
+ console.log(`app:mainnet:sell script) and re-run this command, and the release goes to your phone.`);
936
+ console.log(`or finish it on a machine that holds the approver key.\n`);
937
+ // Written out so the second signature can still be added by hand from a
938
+ // machine that holds the approver key. Said exactly: nothing reads this
939
+ // file back on its own.
858
940
  const file = path.replace(/\.json$/, ".escrow-release.json");
859
941
  writeFileSync(file, JSON.stringify({ order: hit.memo || hit.balanceId, challenge }, null, 2), { mode: 0o600 });
860
- console.log(`challenge written to ${file} (0600).`);
861
- console.log(`nothing completes this by itself. finish it from a machine that holds the approver key,`);
862
- console.log(`or run this command there. the phone approver releases PAYMENTS today; releasing ESCROW`);
863
- console.log(`from the phone is not wired yet, so until it is, use the second key's machine.\n`);
942
+ console.log(`challenge also written to ${file} (0600) for the manual path.\n`);
864
943
  return;
865
944
  }
866
945
  const out = await claimEscrow(network, hit.balanceId, vault.address, { agent, approver: Keypair.fromSecret(keys.approver) });
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. */
@@ -32,6 +32,20 @@ export type DeskRecord = {
32
32
  challenge?: ApprovalChallenge;
33
33
  qr?: string;
34
34
  signedXdr?: string;
35
+ /**
36
+ * What the human is being asked to authorise. `payment` is the vault paying
37
+ * money OUT. `escrow` is the vault claiming a claimable balance the buyer
38
+ * already locked — money coming IN. The distinction is load-bearing: the
39
+ * spending mandate caps payments out, so it must not touch an escrow
40
+ * release, and the signed transaction is verified against a different
41
+ * operation type.
42
+ */
43
+ kind?: "payment" | "escrow";
44
+ /** escrow records: the claimable balance to claim, and the order it belongs to */
45
+ escrowId?: string;
46
+ order?: string;
47
+ /** escrow records: the asset the balance pays out ("XLM", "USDC", ...) */
48
+ asset?: string;
35
49
  };
36
50
  export type DeskStore = DeskRecord;
37
51
  export type DeskConfig = {
@@ -46,6 +60,15 @@ export type DeskConfig = {
46
60
  * the desk submits it. The approver seed never touches this machine.
47
61
  */
48
62
  split?: boolean;
63
+ /**
64
+ * The agent seed (the machine-side key). Payments are built with the
65
+ * principal key; an ESCROW CLAIM is built with the agent key — that is the
66
+ * key pair the escrow was designed around (agent + phone approver). The desk
67
+ * needs it to build the release challenge, exactly as it needs the principal
68
+ * seed to build a payment challenge. It is not the approving half: the claim
69
+ * still needs the phone's signature to reach the vault's threshold of 2.
70
+ */
71
+ agentSeed?: string;
49
72
  /** how long a human has to decide before the payment is dead */
50
73
  decideWithinSeconds?: number;
51
74
  port: number;
@@ -73,10 +96,22 @@ export type DeskConfig = {
73
96
  * ceiling can actually hold.
74
97
  */
75
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;
76
109
  };
77
110
  export declare class ApprovalDesk {
78
111
  private records;
79
112
  private seq;
113
+ /** when /rules last saved, so the page can say "saved" without a query flag */
114
+ private rulesSavedAt;
80
115
  private server?;
81
116
  cfg: DeskConfig;
82
117
  constructor(cfg: DeskConfig);
@@ -99,6 +134,27 @@ export declare class ApprovalDesk {
99
134
  /** Every record carrying this key, newest first. */
100
135
  allByKey(key: string): DeskRecord[];
101
136
  findByKey(key: string): DeskRecord | null;
137
+ /**
138
+ * Queue an escrow release for the human. This is the missing half of
139
+ * `paydlux escrow --release` on a --split vault: the money already sits in a
140
+ * claimable balance the buyer locked, so releasing it is the vault CLAIMING
141
+ * that balance — money coming IN, not going out. The claim still needs the
142
+ * vault's threshold of 2 (agent + approver), and the approver half lives on
143
+ * the phone, so it goes through the same challenge/sign/submit loop as a
144
+ * payment, tagged `kind: "escrow"` so the spending mandate — which caps
145
+ * money OUT — never touches it.
146
+ *
147
+ * The generic fields (uri/price/body) are filled so the portal page renders
148
+ * the row without knowing about escrow; the structured fields carry the
149
+ * truth the verification uses.
150
+ */
151
+ addEscrow(input: {
152
+ escrowId: string;
153
+ order?: string;
154
+ amount: string;
155
+ asset: string;
156
+ buyer?: string;
157
+ }): DeskRecord;
102
158
  get(id: string): DeskRecord | null;
103
159
  list(): DeskRecord[];
104
160
  /**
@@ -124,9 +180,53 @@ export declare class ApprovalDesk {
124
180
  private mandateBlock;
125
181
  /** The mandate has been satisfied: count the money as spent. */
126
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;
127
212
  approve(id: string): Promise<DeskRecord>;
128
213
  /** Split mode step 1: ask the phone to sign. Nothing is submitted yet. */
129
214
  private requestPhoneSignature;
215
+ /**
216
+ * Split mode step 1 for an ESCROW release: build the claim, sign it with the
217
+ * agent key, hand it to the phone. `prepareEscrowRelease` reads the ledger
218
+ * and refuses a balance that does not exist or whose timer has not passed,
219
+ * so a queued-but-fake escrow dies here with a real reason instead of
220
+ * reaching the human as a signable challenge.
221
+ */
222
+ private requestEscrowSignature;
223
+ /**
224
+ * Split mode step 2 for an ESCROW release: the phone signed the claim.
225
+ * No mandate check — this is money coming IN — and no recordSpend: the
226
+ * daily budget counts payments OUT, and counting a release against it would
227
+ * make the seller's own earned money unclaimable.
228
+ */
229
+ private completeEscrow;
130
230
  /**
131
231
  * Split mode step 2: the phone answered. `signedXdr` is the transaction with
132
232
  * the approver signature added, returned by signChallenge() on the phone.