@paydlux/agent 0.1.9 → 0.1.11
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 +8 -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 +50 -0
- package/dist/desk.js +215 -56
- 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 +10 -2
- package/phone-app/app.js +653 -224
- package/phone-app/icon-192.png +0 -0
- package/phone-app/icon-512.png +0 -0
- package/phone-app/index.html +195 -53
- package/phone-app/manifest.webmanifest +19 -0
- package/phone-app/xdr.js +45 -5
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,29 +27,58 @@ 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.
|
|
37
|
+
*
|
|
38
|
+
* PNGs are returned as bytes: reading an image as utf8 text replaces every
|
|
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.
|
|
34
48
|
*/
|
|
35
49
|
function readStatic(dir, name, label) {
|
|
36
|
-
const safe = name.replace(/[^a-
|
|
37
|
-
const
|
|
38
|
-
|
|
50
|
+
const safe = name.replace(/[^a-z0-9._-]/g, "");
|
|
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)
|
|
39
62
|
return hit;
|
|
40
63
|
let text;
|
|
41
64
|
try {
|
|
42
|
-
text = readFileSync(
|
|
65
|
+
text = safe.endsWith(".png") ? readFileSync(file) : readFileSync(file, "utf8");
|
|
43
66
|
}
|
|
44
67
|
catch {
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
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.
|
|
71
|
+
return null;
|
|
48
72
|
}
|
|
49
|
-
staticCache.set(
|
|
73
|
+
staticCache.set(key, text);
|
|
74
|
+
staticStamp.set(key, stamp);
|
|
50
75
|
return text;
|
|
51
76
|
}
|
|
52
77
|
const readAppFile = (name) => readStatic(APP_DIR, name, "approver app");
|
|
78
|
+
const notFound = (res) => {
|
|
79
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
80
|
+
res.end("404");
|
|
81
|
+
};
|
|
53
82
|
const readPortalFile = (name) => readStatic(PORTAL_DIR, name, "portal app");
|
|
54
83
|
/** A portal link is a capability: whoever holds it can approve. So it is long,
|
|
55
84
|
* random, and compared without leaking timing. */
|
|
@@ -165,12 +194,61 @@ export class ApprovalDesk {
|
|
|
165
194
|
* principal only, and hand it to the phone. The money moves when
|
|
166
195
|
* /signed comes back with the phone's signature.
|
|
167
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
|
+
}
|
|
168
242
|
async approve(id) {
|
|
169
243
|
const rec = this.records.get(id);
|
|
170
244
|
if (!rec)
|
|
171
245
|
throw new Error("unknown handle " + id);
|
|
172
246
|
if (rec.status !== "pending")
|
|
173
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;
|
|
174
252
|
if (this.cfg.split)
|
|
175
253
|
return this.requestPhoneSignature(rec);
|
|
176
254
|
if (!this.cfg.principal || !this.cfg.approver) {
|
|
@@ -182,6 +260,7 @@ export class ApprovalDesk {
|
|
|
182
260
|
const res = await this.cfg.vault.release(this.cfg.payee, rec.price, Keypair.fromSecret(this.cfg.principal), Keypair.fromSecret(this.cfg.approver), id);
|
|
183
261
|
rec.status = "settled";
|
|
184
262
|
rec.tx = String(res?.hash ?? "");
|
|
263
|
+
this.recordSpend(rec, rec.tx);
|
|
185
264
|
}
|
|
186
265
|
catch (e) {
|
|
187
266
|
rec.status = "failed";
|
|
@@ -246,28 +325,73 @@ export class ApprovalDesk {
|
|
|
246
325
|
console.log(`[desk] ${id} rejected: ${problem}`);
|
|
247
326
|
return rec;
|
|
248
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;
|
|
249
333
|
rec.signedXdr = signedXdr;
|
|
250
334
|
try {
|
|
251
335
|
const res = await submitApproved(signedXdr, this.cfg.vault.network, this.cfg.vault.horizon);
|
|
252
336
|
rec.status = "settled";
|
|
253
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}`);
|
|
254
342
|
}
|
|
255
343
|
catch (e) {
|
|
256
344
|
rec.status = "failed";
|
|
257
345
|
rec.reason = e.message;
|
|
346
|
+
console.log(`[desk] ${rec.id} failed on the ledger: ${rec.reason}`);
|
|
258
347
|
}
|
|
259
348
|
return rec;
|
|
260
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* The human said no.
|
|
352
|
+
*
|
|
353
|
+
* `awaiting_phone` is a valid state to deny from. Opening the phone link
|
|
354
|
+
* builds the challenge on the spot (so a human needs one tap, not two), which
|
|
355
|
+
* means the first thing a person sees on their phone is already
|
|
356
|
+
* `awaiting_phone` — refusing to deny from that state left the Reject button
|
|
357
|
+
* dead, which was verified on live testnet. Denying is always allowed up to
|
|
358
|
+
* the point the transaction is submitted; after that it is too late.
|
|
359
|
+
*/
|
|
261
360
|
deny(id, reason = "denied by the approver") {
|
|
262
361
|
const rec = this.records.get(id);
|
|
263
362
|
if (!rec)
|
|
264
363
|
throw new Error("unknown handle " + id);
|
|
265
|
-
if (rec.status
|
|
266
|
-
throw new Error(
|
|
364
|
+
if (rec.status === "settled")
|
|
365
|
+
throw new Error("already settled — the money has moved, it cannot be denied");
|
|
366
|
+
if (rec.status === "denied")
|
|
367
|
+
return rec; // idempotent: the app may retry
|
|
267
368
|
rec.status = "denied";
|
|
268
369
|
rec.reason = reason;
|
|
370
|
+
rec.challenge = undefined; // the phone's answer is now worthless
|
|
371
|
+
rec.signedXdr = undefined;
|
|
269
372
|
return rec;
|
|
270
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* What the phone app is allowed to see. Never the paid `content` — that is
|
|
376
|
+
* what the buyer has not paid for yet — and never the raw challenge XDR,
|
|
377
|
+
* which the app fetches per item so the desk can re-check the binding.
|
|
378
|
+
*/
|
|
379
|
+
queue() {
|
|
380
|
+
return this.list().map((r) => ({
|
|
381
|
+
id: r.id,
|
|
382
|
+
at: r.at,
|
|
383
|
+
uri: r.uri,
|
|
384
|
+
price: r.price,
|
|
385
|
+
status: r.status,
|
|
386
|
+
reason: r.reason,
|
|
387
|
+
tx: r.tx,
|
|
388
|
+
/** what the buyer says it wants. Agent-controlled text: render as text. */
|
|
389
|
+
wants: (r.body ?? "").slice(0, 200),
|
|
390
|
+
token: this.cfg.vault.token.getCode(),
|
|
391
|
+
expiresAt: r.challenge?.summary.expiresAt,
|
|
392
|
+
hash: r.challenge?.hash,
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
271
395
|
/** Serve the pending list as JSON (the portal polls this). */
|
|
272
396
|
api(handler) { return handler; }
|
|
273
397
|
listen(port = this.cfg.port) {
|
|
@@ -329,6 +453,65 @@ export class ApprovalDesk {
|
|
|
329
453
|
res.end("ok");
|
|
330
454
|
return;
|
|
331
455
|
}
|
|
456
|
+
// The approver app's static shell is served BEFORE the key gate, on
|
|
457
|
+
// purpose. It is index.html, app.js, xdr.js, the icons and the manifest:
|
|
458
|
+
// client code with no secret and no payment data in it, the same as the
|
|
459
|
+
// portal's own portal.js. Serving it unauthenticated is what lets a phone
|
|
460
|
+
// install it to a home screen from a plain URL, with the seller's key
|
|
461
|
+
// arriving separately in the link. Everything that carries data or moves
|
|
462
|
+
// money — /queue, /challenge, /list, /approve, /deny, /signed — stays
|
|
463
|
+
// behind the gate below.
|
|
464
|
+
//
|
|
465
|
+
// The app uses relative script srcs, so it must be served from a
|
|
466
|
+
// directory URL: /phone without the slash would resolve app.js to /app.js.
|
|
467
|
+
if (url.pathname === "/phone") {
|
|
468
|
+
res.writeHead(302, { location: "/phone/" + url.search });
|
|
469
|
+
res.end();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (url.pathname === "/phone/" || url.pathname === "/phone/index.html") {
|
|
473
|
+
res.writeHead(200, {
|
|
474
|
+
"content-type": "text/html; charset=utf-8",
|
|
475
|
+
// The approver key goes in IndexedDB behind a non-extractable
|
|
476
|
+
// CryptoKey; an injected script is the realistic threat, so lock the
|
|
477
|
+
// origin. No inline script on the page: app.js and xdr.js are
|
|
478
|
+
// separate files, so script-src 'self' actually holds. connect-src
|
|
479
|
+
// 'self' is what stops the app talking anywhere but the desk that
|
|
480
|
+
// served it.
|
|
481
|
+
"content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
|
|
482
|
+
"img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
|
|
483
|
+
"form-action 'none'; frame-ancestors 'none'",
|
|
484
|
+
"referrer-policy": "no-referrer",
|
|
485
|
+
"x-content-type-options": "nosniff",
|
|
486
|
+
"cache-control": "no-store",
|
|
487
|
+
});
|
|
488
|
+
const page = readAppFile("index.html");
|
|
489
|
+
if (page === null)
|
|
490
|
+
return notFound(res);
|
|
491
|
+
res.end(page);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (url.pathname.startsWith("/phone/")) {
|
|
495
|
+
const file = url.pathname.slice("/phone/".length);
|
|
496
|
+
// Icons and the manifest are part of installing the app to a home
|
|
497
|
+
// screen, so they are served alongside the code. Names are still
|
|
498
|
+
// stripped to a safe set before they touch a path.
|
|
499
|
+
if (!/^[a-z0-9_-]+\.(js|png|webmanifest)$/.test(file))
|
|
500
|
+
return notFound(res);
|
|
501
|
+
const type = file.endsWith(".png") ? "image/png"
|
|
502
|
+
: file.endsWith(".webmanifest") ? "application/manifest+json"
|
|
503
|
+
: "text/javascript";
|
|
504
|
+
const body = readAppFile(file);
|
|
505
|
+
if (body === null)
|
|
506
|
+
return notFound(res);
|
|
507
|
+
res.writeHead(200, {
|
|
508
|
+
"content-type": type + (file.endsWith(".png") ? "" : "; charset=utf-8"),
|
|
509
|
+
"cache-control": "no-store",
|
|
510
|
+
"x-content-type-options": "nosniff",
|
|
511
|
+
});
|
|
512
|
+
res.end(body);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
332
515
|
// A public demo link must not be an open approval button. If a portalKey
|
|
333
516
|
// is configured, every route needs it, or the desk answers 403.
|
|
334
517
|
//
|
|
@@ -364,56 +547,22 @@ export class ApprovalDesk {
|
|
|
364
547
|
// approval page and an injected string can't become executable script.
|
|
365
548
|
if (url.pathname === "/portal.js" || url.pathname === "/portal.css") {
|
|
366
549
|
const file = url.pathname.slice(1);
|
|
550
|
+
const body = readPortalFile(file);
|
|
551
|
+
if (body === null)
|
|
552
|
+
return notFound(res);
|
|
367
553
|
res.writeHead(200, {
|
|
368
554
|
"content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
|
|
369
555
|
"x-content-type-options": "nosniff",
|
|
370
556
|
});
|
|
371
|
-
res.end(
|
|
557
|
+
res.end(body);
|
|
372
558
|
return;
|
|
373
559
|
}
|
|
374
560
|
if (url.pathname === "/list")
|
|
375
561
|
return send(200, this.list());
|
|
376
|
-
// The approver app
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// The app uses relative script srcs, so it must be served from a
|
|
381
|
-
// directory URL. /phone without the slash would resolve app.js to
|
|
382
|
-
// /app.js, so send it to the canonical path with the key intact.
|
|
383
|
-
if (url.pathname === "/phone") {
|
|
384
|
-
res.writeHead(302, { location: "/phone/" + url.search });
|
|
385
|
-
res.end();
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
if (url.pathname === "/phone/" || url.pathname === "/phone/index.html") {
|
|
389
|
-
res.writeHead(200, {
|
|
390
|
-
"content-type": "text/html; charset=utf-8",
|
|
391
|
-
// The key lives in IndexedDB behind a non-extractable CryptoKey;
|
|
392
|
-
// an injected script is the realistic threat, so lock the origin.
|
|
393
|
-
// No inline script on the page: app.js and xdr.js are separate
|
|
394
|
-
// files, so script-src 'self' actually holds. connect-src 'self' is
|
|
395
|
-
// what stops the app talking anywhere but the desk that served it.
|
|
396
|
-
"content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
|
|
397
|
-
"img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
|
|
398
|
-
"form-action 'none'; frame-ancestors 'none'",
|
|
399
|
-
"referrer-policy": "no-referrer",
|
|
400
|
-
"x-content-type-options": "nosniff",
|
|
401
|
-
});
|
|
402
|
-
res.end(readAppFile("index.html"));
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
if (url.pathname.startsWith("/phone/")) {
|
|
406
|
-
const file = url.pathname.slice("/phone/".length);
|
|
407
|
-
if (!/^[a-z]+\.(js|css)$/.test(file))
|
|
408
|
-
return send(404, { error: "not found" });
|
|
409
|
-
res.writeHead(200, {
|
|
410
|
-
"content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
|
|
411
|
-
"cache-control": "no-store",
|
|
412
|
-
"x-content-type-options": "nosniff",
|
|
413
|
-
});
|
|
414
|
-
res.end(readAppFile(file));
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
562
|
+
// The approver app's list view. Key-gated like everything else, and it
|
|
563
|
+
// carries no paid content and no challenge XDR.
|
|
564
|
+
if (url.pathname === "/queue")
|
|
565
|
+
return send(200, this.queue());
|
|
417
566
|
// The app fetches the pending transaction by handle rather than asking
|
|
418
567
|
// the human to paste 400 characters of base64 on a phone.
|
|
419
568
|
const ch = url.pathname.match(/^\/challenge\/([\w-]+)$/);
|
|
@@ -431,6 +580,16 @@ export class ApprovalDesk {
|
|
|
431
580
|
return send(200, {
|
|
432
581
|
id: rec.id, network: rec.challenge.network,
|
|
433
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,
|
|
588
|
+
// what the human is being asked to read. Agent-controlled text: the
|
|
589
|
+
// app renders it as text, never as markup.
|
|
590
|
+
wants: (rec.body ?? "").slice(0, 200),
|
|
591
|
+
memo: rec.challenge.summary.memo ?? "",
|
|
592
|
+
expiresAt: rec.challenge.summary.expiresAt,
|
|
434
593
|
});
|
|
435
594
|
}
|
|
436
595
|
const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
|
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) {
|
package/dist/policy.d.ts
CHANGED
|
@@ -48,5 +48,14 @@ export declare class PolicyEngine {
|
|
|
48
48
|
payee: string;
|
|
49
49
|
ttl?: number;
|
|
50
50
|
liveMandates?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Money not spent yet but already promised: payments a human has approved
|
|
53
|
+
* and is waiting on a signature for. Without this the ceiling only counts
|
|
54
|
+
* settled payments, so a seller with a 20/day budget can approve six 4.50
|
|
55
|
+
* requests, spend six Face ID scans, and watch two of them fail at
|
|
56
|
+
* submission. Counting the queue refuses the fifth one at the button
|
|
57
|
+
* instead, which is the point of having a human in the loop.
|
|
58
|
+
*/
|
|
59
|
+
committed?: number;
|
|
51
60
|
}): Verdict;
|
|
52
61
|
}
|
package/dist/policy.js
CHANGED
|
@@ -36,9 +36,9 @@ export class PolicyEngine {
|
|
|
36
36
|
if (req.amount > p.maxPerPayment) {
|
|
37
37
|
return no("max_per_payment", `${req.amount} exceeds the per-payment ceiling of ${p.maxPerPayment}`);
|
|
38
38
|
}
|
|
39
|
-
const spent = this.spentToday();
|
|
39
|
+
const spent = this.spentToday() + (req.committed ?? 0);
|
|
40
40
|
if (spent + req.amount > p.dailyBudget) {
|
|
41
|
-
return no("daily_budget", `${req.amount} would take today to ${(spent + req.amount).toFixed(2)}, over the ${p.dailyBudget} budget (
|
|
41
|
+
return no("daily_budget", `${req.amount} would take today to ${(spent + req.amount).toFixed(2)}, over the ${p.dailyBudget} budget (${this.spentToday().toFixed(2)} paid${req.committed ? ` + ${req.committed.toFixed(2)} already approved and waiting` : ""})`);
|
|
42
42
|
}
|
|
43
43
|
if (p.denylist?.includes(req.payee))
|
|
44
44
|
return no("denylist", `${short(req.payee)} is blocked`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paydlux/agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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",
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"phone-app/index.html",
|
|
17
17
|
"phone-app/app.js",
|
|
18
18
|
"phone-app/xdr.js",
|
|
19
|
+
"phone-app/app.css",
|
|
20
|
+
"phone-app/manifest.webmanifest",
|
|
21
|
+
"phone-app/icon-192.png",
|
|
22
|
+
"phone-app/icon-512.png",
|
|
19
23
|
"portal-app/portal.js",
|
|
20
24
|
"portal-app/portal.css",
|
|
21
25
|
"README.md",
|
|
@@ -36,7 +40,11 @@
|
|
|
36
40
|
"test": "cd contracts && forge test",
|
|
37
41
|
"test:stellar": "tsx stellar/demo.ts",
|
|
38
42
|
"test:portal": "tsx stellar/xss-check.ts",
|
|
39
|
-
"test:
|
|
43
|
+
"test:mandate": "tsx stellar/mandate-check.ts",
|
|
44
|
+
"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",
|
|
45
|
+
"test:ios": "tsx ios/bridge-check.ts",
|
|
46
|
+
"test:link": "swiftc ios/PaydluxApprover/LinkResolver.swift ios/Checks/linkcheck/main.swift -o \"$TMPDIR/linkcheck\" && \"$TMPDIR/linkcheck\"",
|
|
47
|
+
"test:seed": "DEVELOPER_DIR=/Library/Developer/CommandLineTools /usr/bin/swiftc -target arm64-apple-macosx14.0 ios/PaydluxApprover/Seed.swift ios/PaydluxApprover/Hex.swift ios/Checks/main.swift -o \"$TMPDIR/seedcheck\" && \"$TMPDIR/seedcheck\"",
|
|
40
48
|
"typecheck": "tsc --noEmit",
|
|
41
49
|
"app": "tsx stellar/cli.ts",
|
|
42
50
|
"app:mainnet": "tsx stellar/cli.ts setup --mainnet"
|