@paydlux/agent 0.1.10 → 0.1.12

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
@@ -213,11 +213,32 @@ if (res.ok) console.log(res.data, "paid in tx", res.tx);
213
213
  `npx tsx stellar/onboard.ts` creates the vault and prints that key. The key is
214
214
  **one of two signatures** — it cannot spend alone.
215
215
 
216
+ ### Your spending mandate (one command, enforced at the approve button)
217
+
218
+ ```bash
219
+ paydlux sell --price 4.50 --max 10 --daily 20
220
+ ```
221
+
222
+ No payment above 10, nothing past 20 in a day. The desk checks this **before**
223
+ it asks your phone, and again at submission — the budget can be spent between
224
+ your approval and the ledger call, so a stale signature is refused rather than
225
+ honoured. Approvals already waiting count against the budget, so a queue of six
226
+ 4.50 requests against a 20/day cap sends four to your phone and refuses two at
227
+ the button instead of letting you approve six and watch two fail.
228
+
229
+ A refusal answers the buyer with `error: "mandate_refused"` on the **same**
230
+ handle, so a polite agent cannot park-refuse-park forever. `paydlux sell` also
231
+ refuses to start if `--price` is above `--max` or `--daily`, because that
232
+ combination blocks every payment and looks like a bug in the desk.
233
+
234
+ Verified on the published package: 6 x 4.50 against `--daily 20` -> four
235
+ settled, two refused, vault balance moved exactly 18.00 and stopped.
236
+
216
237
  ### What is enforced where (said honestly)
217
238
 
218
- | enforced by Stellar | enforced by `policy.ts` |
239
+ | enforced by Stellar | enforced by the desk (`policy.ts`) |
219
240
  |---|---|
220
- | max per payment, payee, token, time window, one-shot execution | payments per day, running daily total, seller allow/deny list |
241
+ | payee, token, time window, one-shot execution, per-payment ceiling as a signer rule | per-payment ceiling and daily total at the approve button and again at submission, running daily total, seller allow/deny list |
221
242
 
222
243
  The vault is one payment at a time; counting payments is our job. A rule break
223
244
  means we never build the transaction, so nothing reaches the ledger.
package/dist/agent.d.ts CHANGED
@@ -19,6 +19,14 @@ export type PayResponse<T = string> = {
19
19
  tx?: string;
20
20
  /** set when a human has to approve */
21
21
  handle?: string;
22
+ /**
23
+ * Why a payment was refused, as the seller named it: "mandate_refused" (the
24
+ * seller's spending ceiling — retrying the same request can never work) or
25
+ * "payment_denied" (a human said no to this one). Without the distinction an
26
+ * agent retries a refused payment forever, which is exactly what a polite
27
+ * buyer does.
28
+ */
29
+ error?: string;
22
30
  /** where a human approves (the seller's desk) */
23
31
  desk?: string;
24
32
  /** where THIS buyer polls for the answer (the gateway, no key needed) */
package/dist/agent.js CHANGED
@@ -46,7 +46,7 @@ export class PaydluxAgent {
46
46
  if (parked.status === 200) {
47
47
  return { ok: true, status: 200, data: (j.data ?? j), tx: j.tx };
48
48
  }
49
- return { ok: false, status: parked.status, handle: j.handle, desk: j.desk,
49
+ return { ok: false, status: parked.status, handle: j.handle, error: j.error, desk: j.desk,
50
50
  statusUrl: j.statusUrl, requirements: reqs,
51
51
  reason: j.reason ?? "a human has to approve this payment" };
52
52
  }
package/dist/cli.d.ts CHANGED
@@ -1,8 +1,28 @@
1
1
  #!/usr/bin/env node
2
+ import { StellarVault } from "./vault.ts";
2
3
  export declare const VAULT_FILE_DEFAULT = "paydlux-vault.json";
3
4
  /** The one file that holds everything. Refuses to read a file that is world-readable. */
4
5
  export declare function loadVault(path?: string): {
5
6
  path: string;
6
7
  data: any;
7
8
  };
9
+ /**
10
+ * Persist the portal key so the phone link survives a restart. A new key every
11
+ * boot means the human re-scans a QR every time and any link already opened on
12
+ * a phone stops working mid-payment.
13
+ */
14
+ export declare function deskKeys(path: string, data: any): string;
15
+ /**
16
+ * Where the money lands.
17
+ *
18
+ * `setup` creates a payee account for you. Two cases break that: a vault made
19
+ * before the payee existed, and a seller who wants the money in their own
20
+ * account (a Binance deposit address, an exchange wallet). Both are handled
21
+ * here, because a payment to an account that does not exist — or that does not
22
+ * trust the token — fails on the ledger with a code the seller never sees.
23
+ *
24
+ * Adding a trustline needs the payee's own key, so for an outside account we can
25
+ * only check and explain. For a payee we generated, we do it ourselves.
26
+ */
27
+ export declare function ensurePayee(path: string, data: any, vault: StellarVault): Promise<any>;
8
28
  export declare function main(): Promise<void>;
package/dist/cli.js CHANGED
@@ -14,11 +14,13 @@
14
14
  // A seller should type one command and get a price, a link, and a key to paste
15
15
  // into their bot.
16
16
  import { createInterface } from "node:readline/promises";
17
+ import { createServer } from "node:http";
18
+ import { execSync } from "node:child_process";
17
19
  import { existsSync, readFileSync, writeFileSync, chmodSync, realpathSync } from "node:fs";
18
20
  import { pathToFileURL } from "node:url";
19
21
  import { resolve } from "node:path";
20
- import { Keypair } from "@stellar/stellar-sdk";
21
- import { StellarVault, USDC_ISSUER } from "./vault.js";
22
+ import { Account, Keypair, Operation, TransactionBuilder } from "@stellar/stellar-sdk";
23
+ import { StellarVault, USDC_ISSUER, passphrase } from "./vault.js";
22
24
  import { Gateway } from "./gateway.js";
23
25
  import { newPortalKey } from "./desk.js";
24
26
  import { PolicyEngine } from "./policy.js";
@@ -33,6 +35,39 @@ const log = (...a) => console.log("•", ...a);
33
35
  /** Print a reason and stop. Declared as a function (not an arrow in a const) so
34
36
  * TypeScript's control-flow analysis actually narrows the caller after it. */
35
37
  function bad(msg) { console.error("\n" + msg + "\n"); process.exit(1); }
38
+ /**
39
+ * Ask the OS whether a port is free, and if not, who owns it. Returns null when
40
+ * the port is available. Naming the owning process is what turns "EADDRINUSE"
41
+ * into something a seller can act on.
42
+ */
43
+ function portHolder(port) {
44
+ return new Promise((resolve) => {
45
+ const probe = createServer();
46
+ probe.once("error", (e) => {
47
+ if (e.code === "EADDRINUSE")
48
+ resolve(findListener(port) ?? "another process");
49
+ else
50
+ resolve(null); // some other bind problem; let the real listen report it
51
+ });
52
+ probe.once("listening", () => { probe.close(() => resolve(null)); });
53
+ probe.listen(port, "127.0.0.1");
54
+ });
55
+ }
56
+ /** Best-effort: which command is holding this port, from `lsof`. */
57
+ function findListener(port) {
58
+ try {
59
+ const out = execSync(`lsof -nP -ti tcp:${port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf8" });
60
+ const pid = out.trim().split("\n")[0];
61
+ if (!pid)
62
+ return null;
63
+ const cmd = execSync(`ps -o command= -p ${pid} 2>/dev/null`, { encoding: "utf8" }).trim();
64
+ const mine = cmd.includes("cli") || cmd.includes("paydlux");
65
+ return `${mine ? "another Paydlux (pid " + pid + ")" : cmd || "pid " + pid}`;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
36
71
  /** The one file that holds everything. Refuses to read a file that is world-readable. */
37
72
  export function loadVault(path = resolve(process.cwd(), arg("file") ?? VAULT_FILE_DEFAULT)) {
38
73
  if (!existsSync(path)) {
@@ -49,7 +84,7 @@ function pub(seed) { return Keypair.fromSecret(seed).publicKey(); }
49
84
  * boot means the human re-scans a QR every time and any link already opened on
50
85
  * a phone stops working mid-payment.
51
86
  */
52
- function deskKeys(path, data) {
87
+ export function deskKeys(path, data) {
53
88
  if (!data.desk)
54
89
  data.desk = {};
55
90
  if (!data.desk.portalKey) {
@@ -60,14 +95,104 @@ function deskKeys(path, data) {
60
95
  }
61
96
  return data.desk.portalKey;
62
97
  }
98
+ /** Does this account already hold the token the vault pays in? */
99
+ async function trusts(vault, address) {
100
+ const acc = await vault.horizon.loadAccount(address).catch(() => null);
101
+ if (!acc)
102
+ return false;
103
+ const code = vault.token.getCode(), issuer = vault.token.getIssuer();
104
+ return acc.balances.some((b) => b.asset_type !== "native"
105
+ && b.asset_code === code && b.asset_issuer === issuer);
106
+ }
107
+ /**
108
+ * Where the money lands.
109
+ *
110
+ * `setup` creates a payee account for you. Two cases break that: a vault made
111
+ * before the payee existed, and a seller who wants the money in their own
112
+ * account (a Binance deposit address, an exchange wallet). Both are handled
113
+ * here, because a payment to an account that does not exist — or that does not
114
+ * trust the token — fails on the ledger with a code the seller never sees.
115
+ *
116
+ * Adding a trustline needs the payee's own key, so for an outside account we can
117
+ * only check and explain. For a payee we generated, we do it ourselves.
118
+ */
119
+ export async function ensurePayee(path, data, vault) {
120
+ const specified = arg("payee");
121
+ if (specified) {
122
+ if (!/^G[A-Z2-7]{55}$/.test(specified))
123
+ bad(`--payee must be a Stellar account (G…56 chars), got "${specified}"`);
124
+ if (specified === vault.address)
125
+ bad("--payee cannot be the vault itself");
126
+ if (!(await trusts(vault, specified))) {
127
+ bad(`${specified} does not hold ${vault.token.getCode()} yet, so a payment to it would be\n rejected by the ledger (OP_NO_TRUST).\n\n add a trustline for ${vault.token.getCode()} from ${vault.token.getIssuer()} to that account\n (the exchange or wallet that owns it does this), or drop --payee and Paydlux will\n create and manage a payee account for you.`);
128
+ }
129
+ data.payee = { address: specified, external: true };
130
+ writeFileSync(path, JSON.stringify(data, null, 2));
131
+ chmodSync(path, 0o600);
132
+ log(`payments land in your account ${specified}`);
133
+ return specified;
134
+ }
135
+ if (data.payee?.address && !data.payee.seed)
136
+ return data.payee.address;
137
+ if (data.payee?.address && (await trusts(vault, data.payee.address)))
138
+ return data.payee.address;
139
+ const keys = data.keys;
140
+ if (!keys.approver) {
141
+ bad(`this vault has no payee account yet, and creating one needs both human keys —\n this machine holds only one.\n\n point the payments at an account you already own that holds ${vault.token.getCode()}:\n\n paydlux sell --payee G…`);
142
+ }
143
+ const kp = Keypair.fromSecret(data.payee?.seed ?? Keypair.random().secret());
144
+ if (!data.payee?.address) {
145
+ log(`creating the payee account (2 XLM of ledger rent)…`);
146
+ const acc = await vault.horizon.loadAccount(vault.address);
147
+ const tx = new TransactionBuilder(new Account(vault.address, acc.sequence), {
148
+ fee: "100", networkPassphrase: passphrase((data.network ?? "testnet")),
149
+ })
150
+ .addOperation(Operation.createAccount({ destination: kp.publicKey(), startingBalance: "2" }))
151
+ .setTimeout(120).build();
152
+ tx.sign(Keypair.fromSecret(keys.principal));
153
+ tx.sign(Keypair.fromSecret(keys.approver));
154
+ await vault.horizon.submitTransaction(tx);
155
+ }
156
+ if (!(await trusts(vault, kp.publicKey())))
157
+ await vault.trustToken(kp.publicKey(), "1000000", kp);
158
+ data.payee = { address: kp.publicKey(), seed: kp.secret() };
159
+ writeFileSync(path, JSON.stringify(data, null, 2));
160
+ chmodSync(path, 0o600);
161
+ log(`payments land in ${kp.publicKey()}`);
162
+ return kp.publicKey();
163
+ }
164
+ /**
165
+ * Change what the vault settles in. USDC only: a custom token would need an
166
+ * issuer key, and holding one quietly is not a decision to make in a flag.
167
+ * The trustline needs both human keys, so this has to run before
168
+ * `approver --forget` takes one away.
169
+ */
170
+ async function switchToken(path, data, code) {
171
+ if (code !== "USDC") {
172
+ bad(`switching to a custom token means holding an issuer key. Only USDC can be\n switched to here. Create a token vault with: paydlux setup --token ${code}`);
173
+ }
174
+ const network = (data.network ?? "testnet");
175
+ if (!data.keys.approver)
176
+ bad(`adding the USDC trustline needs both human keys and this machine holds one. Re-run on the machine with both, then use approver --forget.`);
177
+ const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(data.keys.principal), approver: pub(data.keys.approver), agent: pub(data.keys.agent) }, network);
178
+ log(`adding a USDC trustline to the vault…`);
179
+ await vault.trustUsdc(Keypair.fromSecret(data.keys.principal), Keypair.fromSecret(data.keys.approver));
180
+ data.token = { code: "USDC", issuer: USDC_ISSUER[network] };
181
+ writeFileSync(path, JSON.stringify(data, null, 2));
182
+ chmodSync(path, 0o600);
183
+ log("the vault now settles in Circle USDC");
184
+ }
63
185
  // ------------------------------------------------------------------------ sell
64
186
  async function sell() {
65
187
  const { path, data } = loadVault();
66
188
  const network = (data.network ?? "testnet");
67
189
  const keys = data.keys;
68
- const payee = data.payee?.address;
69
- if (!payee)
70
- bad(`${path} has no payee.address — where should payment land?\n add it, or re-run: paydlux setup`);
190
+ // Settle in real money instead of the token this vault issued. Needs a
191
+ // trustline, which needs both human keys, so it happens here — before
192
+ // `approver --forget` takes one of them away.
193
+ const wantToken = arg("token")?.toUpperCase();
194
+ if (wantToken && wantToken !== data.token.code)
195
+ await switchToken(path, data, wantToken);
71
196
  const price = arg("price") ?? data.policy?.defaultPrice ?? "1";
72
197
  if (!(Number(price) > 0))
73
198
  bad("--price must be a positive number");
@@ -82,9 +207,26 @@ async function sell() {
82
207
  const maxPer = Number(arg("max") ?? data.policy?.maxPerPayment ?? 2);
83
208
  const daily = Number(arg("daily") ?? data.policy?.dailyBudget ?? 10);
84
209
  const policy = new PolicyEngine({ maxPerPayment: maxPer, dailyBudget: daily });
85
- const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(keys.principal), approver: pub(keys.approver), agent: pub(keys.agent) }, network);
210
+ // A ceiling below the price blocks every payment, and the failure looks like
211
+ // a bug in the desk: the buyer waits, the human never sees an approvable
212
+ // item, and the log says "mandate refused". Say it now, before anything is
213
+ // started, because this is a mistake in the flags and not in the product.
214
+ const priceNum = Number(price);
215
+ if (!(priceNum > 0))
216
+ bad(`--price ${price} is not a number greater than zero.`);
217
+ if (priceNum > maxPer) {
218
+ 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.`);
219
+ }
220
+ if (priceNum > daily) {
221
+ 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}.`);
222
+ }
223
+ 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);
86
224
  if (data.token.code === "USDC")
87
225
  vault.useUsdc();
226
+ // Resolve where the money lands before anything starts listening. A parked
227
+ // payment with no valid payee is a payment that fails an hour later, after
228
+ // the human already said yes.
229
+ const payee = await ensurePayee(path, data, vault);
88
230
  if (network === "public") {
89
231
  console.log(`
90
232
  MAINNET — real money.
@@ -112,6 +254,19 @@ async function sell() {
112
254
  const publicHost = tls ? PUBLIC_HOST : (arg("host") ?? undefined);
113
255
  const deskPort = Number(arg("desk") ?? 4022);
114
256
  const gwPort = Number(arg("port") ?? 4021);
257
+ // A taken port used to surface as an unhandled EADDRINUSE stack trace from
258
+ // inside Node. To someone following the README that reads as a broken
259
+ // install, and the stack names neither port nor remedy.
260
+ for (const [label, port] of [["gateway", gwPort], ["approval desk", deskPort]]) {
261
+ const who = await portHolder(port);
262
+ if (who) {
263
+ // Suggest the pair, not the clashing port twice: --port and --desk with
264
+ // the same number would fail on the next line.
265
+ bad(`port ${port} (${label}) is already in use by ${who}.\n` +
266
+ ` another Paydlux is probably running. Stop it, or move this one:\n` +
267
+ ` paydlux sell --port ${gwPort + 100} --desk ${deskPort + 100}`);
268
+ }
269
+ }
115
270
  const gw = new Gateway({
116
271
  vault, network, payee,
117
272
  deskPort,
@@ -123,6 +278,8 @@ async function sell() {
123
278
  publicHost,
124
279
  agentKeys: [pub(keys.agent)],
125
280
  routes: [{ uri, price, mode, holdSeconds: Number(arg("hold") ?? 3600) }],
281
+ // The banner prints these numbers. This line is what makes them true.
282
+ policy,
126
283
  });
127
284
  let tunnel = null;
128
285
  if (tls) {
@@ -186,7 +343,7 @@ async function vaultRent(vault) {
186
343
  async function status() {
187
344
  const { data } = loadVault();
188
345
  const network = (data.network ?? "testnet");
189
- const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(data.keys.principal), approver: pub(data.keys.approver), agent: pub(data.keys.agent) }, network);
346
+ const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(data.keys.principal), approver: data.keys.approver ? pub(data.keys.approver) : undefined, agent: pub(data.keys.agent) }, network);
190
347
  if (data.token.code === "USDC")
191
348
  vault.useUsdc();
192
349
  const rent = await vaultRent(vault);
package/dist/desk.d.ts CHANGED
@@ -1,5 +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
4
  import { type ApprovalChallenge } from "./phone.ts";
4
5
  /** A portal link is a capability: whoever holds it can approve. So it is long,
5
6
  * random, and compared without leaking timing. */
@@ -20,6 +21,13 @@ export type DeskRecord = {
20
21
  * twice and the buyer never gets the content they already bought.
21
22
  */
22
23
  key?: string;
24
+ /**
25
+ * The failure came from the seller's spending mandate, not from the ledger.
26
+ * The gateway needs this distinction: a ledger failure is worth retrying, a
27
+ * mandate refusal never changes, so re-parking the same request would loop
28
+ * forever — refuse, and make the buyer change the request to ask again.
29
+ */
30
+ mandateBlocked?: boolean;
23
31
  /** split mode: what the phone is asked to sign, and what it sent back */
24
32
  challenge?: ApprovalChallenge;
25
33
  qr?: string;
@@ -53,6 +61,18 @@ export type DeskConfig = {
53
61
  * gives you. Pinning the Host means the only way in is through the tunnel.
54
62
  */
55
63
  publicHost?: string;
64
+ /**
65
+ * The spending mandate. Optional only so existing tests keep compiling; every
66
+ * real seller passes one.
67
+ *
68
+ * Enforced HERE, at the moment money moves, not in the gateway. The gateway is
69
+ * the buyer's surface: it can be restarted, bypassed, or run by someone who
70
+ * forgot to configure it. The desk is the one place every payment passes
71
+ * through on its way to the ledger — dev mode signs and submits here, split
72
+ * mode submits the phone's signature here — so this is the only place a
73
+ * ceiling can actually hold.
74
+ */
75
+ policy?: PolicyEngine;
56
76
  };
57
77
  export declare class ApprovalDesk {
58
78
  private records;
@@ -75,6 +95,20 @@ export declare class ApprovalDesk {
75
95
  * principal only, and hand it to the phone. The money moves when
76
96
  * /signed comes back with the phone's signature.
77
97
  */
98
+ /**
99
+ * The mandate check, in one place, called before money moves.
100
+ *
101
+ * Returns null when the payment is allowed, or the reason it is not. The
102
+ * record is marked failed so the buyer's poll gets a real answer instead of a
103
+ * timeout, and the reason is surfaced to the human rather than logged only.
104
+ *
105
+ * Why this exists at all: `--max` and `--daily` were printed in the seller's
106
+ * banner and never applied to anything. A ceiling nobody enforces is worse
107
+ * than no ceiling, because the seller believes they have one.
108
+ */
109
+ private mandateBlock;
110
+ /** The mandate has been satisfied: count the money as spent. */
111
+ private recordSpend;
78
112
  approve(id: string): Promise<DeskRecord>;
79
113
  /** Split mode step 1: ask the phone to sign. Nothing is submitted yet. */
80
114
  private requestPhoneSignature;
package/dist/desk.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // - a stolen phone cannot approve alone
11
11
  // - the approval is a real ledger signature, not a database flag
12
12
  import { createServer } from "node:http";
13
- import { readFileSync } from "node:fs";
13
+ import { readFileSync, statSync } from "node:fs";
14
14
  import { dirname, join } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { randomBytes, timingSafeEqual } from "node:crypto";
@@ -27,31 +27,51 @@ const HERE = dirname(fileURLToPath(import.meta.url));
27
27
  const APP_DIR = join(HERE, "..", "phone-app");
28
28
  const PORTAL_DIR = join(HERE, "..", "portal-app");
29
29
  const staticCache = new Map();
30
+ // mtime of the bytes held in staticCache, so an edited file is picked up on the
31
+ // next request instead of after a restart. See readStatic.
32
+ const staticStamp = new Map();
30
33
  /**
31
- * Static files are read on each process start rather than bundled, so the desk
32
- * and its front ends can never drift apart. The name is stripped to [a-z.]
33
- * before it touches a path, so a request can't walk out of the directory.
34
+ * Static files are read from disk, not bundled, so the desk and its front ends
35
+ * can never drift apart. The name is stripped to [a-z._-] before it touches a
36
+ * path, so a request can't walk out of the directory.
34
37
  *
35
38
  * PNGs are returned as bytes: reading an image as utf8 text replaces every
36
39
  * invalid sequence with U+FFFD and ships a corrupt icon to the phone.
40
+ *
41
+ * The cache is keyed on the file's mtime, not just its name. Caching once per
42
+ * process looked fine and was not: the approver's service worker is deliberately
43
+ * network-first so that a security fix reaches an installed phone on the next
44
+ * open, and a desk that holds the old bytes in memory until it restarts breaks
45
+ * exactly that promise. A patched app.js on disk would have sat invisible for
46
+ * days behind a running desk. One stat() per request is cheap; a stale approver
47
+ * page that keeps signing old-style transactions is not.
37
48
  */
38
49
  function readStatic(dir, name, label) {
39
50
  const safe = name.replace(/[^a-z0-9._-]/g, "");
40
- const hit = staticCache.get(dir + "/" + safe);
41
- if (hit)
51
+ const file = join(dir, safe);
52
+ let stamp;
53
+ try {
54
+ stamp = statSync(file).mtimeMs;
55
+ }
56
+ catch {
57
+ return null; // missing file: a 404, not a crash
58
+ }
59
+ const key = dir + "/" + safe;
60
+ const hit = staticCache.get(key);
61
+ if (hit && staticStamp.get(key) === stamp)
42
62
  return hit;
43
63
  let text;
44
64
  try {
45
- text = safe.endsWith(".png")
46
- ? readFileSync(join(dir, safe))
47
- : readFileSync(join(dir, safe), "utf8");
65
+ text = safe.endsWith(".png") ? readFileSync(file) : readFileSync(file, "utf8");
48
66
  }
49
67
  catch {
50
- // A missing file is a 404, not a crash. Throwing here took the whole desk
51
- // down over one bad URL: the request handler is not a place to raise.
68
+ // Unreadable between the stat and the read, or not a file at all. The
69
+ // request handler is not a place to raise: one bad URL must not take the
70
+ // whole desk down.
52
71
  return null;
53
72
  }
54
- staticCache.set(dir + "/" + safe, text);
73
+ staticCache.set(key, text);
74
+ staticStamp.set(key, stamp);
55
75
  return text;
56
76
  }
57
77
  const readAppFile = (name) => readStatic(APP_DIR, name, "approver app");
@@ -174,12 +194,61 @@ export class ApprovalDesk {
174
194
  * principal only, and hand it to the phone. The money moves when
175
195
  * /signed comes back with the phone's signature.
176
196
  */
197
+ /**
198
+ * The mandate check, in one place, called before money moves.
199
+ *
200
+ * Returns null when the payment is allowed, or the reason it is not. The
201
+ * record is marked failed so the buyer's poll gets a real answer instead of a
202
+ * timeout, and the reason is surfaced to the human rather than logged only.
203
+ *
204
+ * Why this exists at all: `--max` and `--daily` were printed in the seller's
205
+ * banner and never applied to anything. A ceiling nobody enforces is worse
206
+ * than no ceiling, because the seller believes they have one.
207
+ */
208
+ mandateBlock(rec, when) {
209
+ const policy = this.cfg.policy;
210
+ if (!policy)
211
+ return null;
212
+ // Approvals already made that have not settled yet. Those are promises the
213
+ // seller has already given, so a new one has to fit alongside them.
214
+ // `pending` deliberately does NOT count: that is a request nobody has
215
+ // approved, and counting the whole queue would refuse the first approval
216
+ // just because more requests exist than the budget can cover.
217
+ const committed = this.list().reduce((sum, r) => (r.id !== rec.id && (r.status === "awaiting_phone" || r.status === "approved"))
218
+ ? sum + Number(r.price) : sum, 0);
219
+ const verdict = policy.check({
220
+ amount: Number(rec.price),
221
+ payee: this.cfg.payee,
222
+ ttl: this.cfg.decideWithinSeconds,
223
+ committed,
224
+ });
225
+ if (verdict.ok)
226
+ return null;
227
+ rec.status = "failed";
228
+ rec.reason = `mandate refused (${verdict.rule}): ${verdict.detail}`;
229
+ rec.mandateBlocked = true;
230
+ rec.challenge = undefined;
231
+ rec.signedXdr = undefined;
232
+ console.log(`[desk] ${rec.id} ${when}: ${rec.reason}`);
233
+ return rec.reason;
234
+ }
235
+ /** The mandate has been satisfied: count the money as spent. */
236
+ recordSpend(rec, tx) {
237
+ this.cfg.policy?.record({
238
+ at: Date.now(), amount: Number(rec.price), payee: this.cfg.payee,
239
+ uri: rec.uri, mandateHash: tx,
240
+ });
241
+ }
177
242
  async approve(id) {
178
243
  const rec = this.records.get(id);
179
244
  if (!rec)
180
245
  throw new Error("unknown handle " + id);
181
246
  if (rec.status !== "pending")
182
247
  throw new Error(`already ${rec.status}`);
248
+ // Check before asking the phone. A human who approves a payment the mandate
249
+ // will refuse has spent a Face ID scan and a signature for nothing.
250
+ if (this.mandateBlock(rec, "refused before asking"))
251
+ return rec;
183
252
  if (this.cfg.split)
184
253
  return this.requestPhoneSignature(rec);
185
254
  if (!this.cfg.principal || !this.cfg.approver) {
@@ -191,6 +260,7 @@ export class ApprovalDesk {
191
260
  const res = await this.cfg.vault.release(this.cfg.payee, rec.price, Keypair.fromSecret(this.cfg.principal), Keypair.fromSecret(this.cfg.approver), id);
192
261
  rec.status = "settled";
193
262
  rec.tx = String(res?.hash ?? "");
263
+ this.recordSpend(rec, rec.tx);
194
264
  }
195
265
  catch (e) {
196
266
  rec.status = "failed";
@@ -255,15 +325,25 @@ export class ApprovalDesk {
255
325
  console.log(`[desk] ${id} rejected: ${problem}`);
256
326
  return rec;
257
327
  }
328
+ // Re-check at the last possible moment. The budget was verified when the
329
+ // phone was asked; a day is long and other payments have been approved since.
330
+ // Better to refuse now than to submit a payment the mandate forbids.
331
+ if (this.mandateBlock(rec, "refused at submission"))
332
+ return rec;
258
333
  rec.signedXdr = signedXdr;
259
334
  try {
260
335
  const res = await submitApproved(signedXdr, this.cfg.vault.network, this.cfg.vault.horizon);
261
336
  rec.status = "settled";
262
337
  rec.tx = res.hash;
338
+ this.recordSpend(rec, res.hash);
339
+ // The money moving is the one event a seller should never have to
340
+ // discover by refreshing a page.
341
+ console.log(`[desk] ${rec.id} PAID ${rec.price} ${this.cfg.vault.token.getCode()} — ledger ${rec.tx}`);
263
342
  }
264
343
  catch (e) {
265
344
  rec.status = "failed";
266
345
  rec.reason = e.message;
346
+ console.log(`[desk] ${rec.id} failed on the ledger: ${rec.reason}`);
267
347
  }
268
348
  return rec;
269
349
  }
@@ -500,6 +580,11 @@ export class ApprovalDesk {
500
580
  return send(200, {
501
581
  id: rec.id, network: rec.challenge.network,
502
582
  hash: rec.challenge.hash, xdr: rec.challenge.xdr,
583
+ // Which key this vault will actually accept. A phone holding a key
584
+ // from a previous run signs a valid signature over a valid
585
+ // transaction, and the ledger answers BAD_AUTH with no explanation.
586
+ // Naming the expected key here lets the app say so BEFORE signing.
587
+ approver: this.cfg.vault.signers.approver,
503
588
  // what the human is being asked to read. Agent-controlled text: the
504
589
  // app renders it as text, never as markup.
505
590
  wants: (rec.body ?? "").slice(0, 200),
package/dist/gateway.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { type IncomingMessage, type ServerResponse } from "node:http";
1
+ import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
2
  import { 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
6
  export declare const PAY_HEADER = "x-payment";
6
7
  export type Route = {
7
8
  uri: string;
@@ -37,6 +38,8 @@ export type GatewayConfig = {
37
38
  portalKey?: string;
38
39
  /** hostname the desk is publicly served on; pins the Host header. See DeskConfig. */
39
40
  publicHost?: string;
41
+ /** how long a human gets to decide before the parked payment's tx is dead */
42
+ decideWithinSeconds?: number;
40
43
  /**
41
44
  * Agent public keys allowed to park a payment for approval. An intent is only
42
45
  * honoured if it is signed by one of these, so an unlisted key gets a plain
@@ -50,6 +53,11 @@ export type GatewayConfig = {
50
53
  * loopback address, which is only useful on one machine.
51
54
  */
52
55
  deskUrl?: string;
56
+ /**
57
+ * The spending mandate. Passed straight to the desk, which is where it is
58
+ * enforced — see DeskConfig.policy for why the gateway is the wrong place.
59
+ */
60
+ policy?: PolicyEngine;
53
61
  };
54
62
  export type PaymentProof = {
55
63
  /** pre-authorised envelope, base64 XDR, as issued by the vault */
@@ -111,7 +119,7 @@ export declare class Gateway {
111
119
  hash: string;
112
120
  }>;
113
121
  handler(getContent: (uri: string) => string): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
114
- listen(port: number, getContent?: (uri: string) => string): Promise<void>;
122
+ listen(port: number, getContent?: (uri: string) => string): Promise<Server<typeof IncomingMessage, typeof ServerResponse>>;
115
123
  }
116
124
  export { StellarTxError };
117
125
  export type { DeskStore };
package/dist/gateway.js CHANGED
@@ -39,6 +39,8 @@ export class Gateway {
39
39
  portalKey: cfg.portalKey,
40
40
  split: cfg.split,
41
41
  publicHost: cfg.publicHost,
42
+ decideWithinSeconds: cfg.decideWithinSeconds,
43
+ policy: cfg.policy,
42
44
  });
43
45
  }
44
46
  /** Mandates the vault currently holds, so the gateway can hand them out. */
@@ -244,6 +246,20 @@ export class Gateway {
244
246
  return;
245
247
  }
246
248
  }
249
+ // The seller's own spending mandate refused this one. Retrying the
250
+ // identical request can never succeed — the ceiling is not going to
251
+ // move — so re-parking it would loop: park, refuse, park, refuse, with
252
+ // a fresh handle each time and a queue the human cannot clear. Answer
253
+ // 402 and make the buyer change the request (a smaller price, a
254
+ // different route) to ask again.
255
+ if (hit.mandateBlocked) {
256
+ res.writeHead(402, { [PAY_HEADER]: JSON.stringify(this.requirements(uri)) });
257
+ res.end(JSON.stringify({
258
+ error: "mandate_refused", handle: hit.id,
259
+ reason: hit.reason ?? "refused by the seller's spending mandate",
260
+ }));
261
+ return;
262
+ }
247
263
  // A human said no. Do not silently re-open the same request and nag
248
264
  // them with it: the buyer has to change the request to ask again.
249
265
  if (hit.status === "denied") {
@@ -332,7 +348,10 @@ export class Gateway {
332
348
  }
333
349
  listen(port, getContent = (u) => `payload for ${u} at ${new Date().toISOString()}`) {
334
350
  const srv = createServer(this.handler(getContent));
335
- return new Promise((resolve) => srv.listen(port, () => resolve()));
351
+ // Hand the server back. Returning void meant a started gateway could never
352
+ // be stopped, which left tests holding ports and a seller with no clean way
353
+ // to change a route without killing the process.
354
+ return new Promise((resolve) => srv.listen(port, () => resolve(srv)));
336
355
  }
337
356
  }
338
357
  function readBody(req) {