@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 +23 -2
- package/dist/agent.d.ts +8 -0
- package/dist/agent.js +1 -1
- package/dist/cli.d.ts +20 -0
- package/dist/cli.js +165 -8
- package/dist/desk.d.ts +34 -0
- package/dist/desk.js +97 -12
- package/dist/gateway.d.ts +10 -2
- package/dist/gateway.js +20 -1
- package/dist/policy.d.ts +9 -0
- package/dist/policy.js +2 -2
- package/package.json +6 -2
- package/phone-app/app.js +264 -14
- package/phone-app/index.html +134 -55
- package/phone-app/manifest.webmanifest +5 -1
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
|
-
|
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
41
|
-
|
|
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
|
-
//
|
|
51
|
-
//
|
|
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(
|
|
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<
|
|
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
|
-
|
|
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) {
|