@paydlux/agent 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/cli.js +102 -23
- package/dist/desk.d.ts +101 -1
- package/dist/desk.js +400 -29
- package/dist/gateway.d.ts +12 -1
- package/dist/gateway.js +14 -0
- package/dist/policy.d.ts +51 -1
- package/dist/policy.js +102 -3
- package/dist/portal.d.ts +17 -0
- package/dist/portal.js +97 -1
- package/package.json +4 -2
- package/phone-app/app.js +26 -6
- package/phone-app/index.html +1 -1
- package/phone-app/xdr.js +23 -4
- package/portal-app/portal.css +22 -0
- package/portal-app/rules.js +62 -0
package/dist/desk.js
CHANGED
|
@@ -17,8 +17,10 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
17
17
|
import { Keypair } from "@stellar/stellar-sdk";
|
|
18
18
|
import { TransactionBuilder } from "@stellar/stellar-sdk";
|
|
19
19
|
import { passphrase } from "./vault.js";
|
|
20
|
-
import {
|
|
20
|
+
import { DEFAULT_POLICY, normalizeCategory } from "./policy.js";
|
|
21
|
+
import { portalPage, rulesPage } from "./portal.js";
|
|
21
22
|
import { createApprovalChallenge, submitApproved, challengeQrPayload, } from "./phone.js";
|
|
23
|
+
import { claimEscrow, prepareEscrowRelease } from "./escrow.js";
|
|
22
24
|
/**
|
|
23
25
|
* The approver app is plain static files under phone-app/. Read on each request
|
|
24
26
|
* rather than bundled, so the desk and the app can never drift apart.
|
|
@@ -121,6 +123,37 @@ function verifyAgainstChallenge(signedXdr, rec) {
|
|
|
121
123
|
function recNetwork(rec) {
|
|
122
124
|
return rec.challenge.network;
|
|
123
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Same binding check as verifyAgainstChallenge, for an escrow release. The
|
|
128
|
+
* signed transaction must be the claim the desk issued (same hash), carry both
|
|
129
|
+
* signatures, and — the escrow-specific part — claim EXACTLY the balance this
|
|
130
|
+
* record names. Without that last check a signature collected for one escrow
|
|
131
|
+
* could be replayed against another order's queue entry.
|
|
132
|
+
*/
|
|
133
|
+
function verifyEscrowAgainstChallenge(signedXdr, rec) {
|
|
134
|
+
const ch = rec.challenge;
|
|
135
|
+
let signed;
|
|
136
|
+
try {
|
|
137
|
+
signed = TransactionBuilder.fromXDR(signedXdr, passphrase(recNetwork(rec)));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return "the phone returned something that is not a valid transaction for this network";
|
|
141
|
+
}
|
|
142
|
+
const hash = Buffer.from(signed.hash()).toString("hex");
|
|
143
|
+
if (hash !== ch.hash) {
|
|
144
|
+
return `signed a different transaction (${hash.slice(0, 12)}… was not issued for ${rec.id}, which is ${ch.hash.slice(0, 12)}…)`;
|
|
145
|
+
}
|
|
146
|
+
if (signed.signatures.length < 2) {
|
|
147
|
+
return `only ${signed.signatures.length} signature(s); the vault needs 2`;
|
|
148
|
+
}
|
|
149
|
+
const op = signed.operations?.[0];
|
|
150
|
+
if (!op || op.type !== "claimClaimableBalance")
|
|
151
|
+
return "the transaction is not an escrow claim";
|
|
152
|
+
if (op.balanceId !== rec.escrowId) {
|
|
153
|
+
return `the claim targets ${String(op.balanceId).slice(0, 12)}…, not the escrow ${String(rec.escrowId).slice(0, 12)}… this approval is for`;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
124
157
|
function readBody(req) {
|
|
125
158
|
return new Promise((resolve, reject) => {
|
|
126
159
|
let data = "";
|
|
@@ -149,6 +182,30 @@ function cookieOf(req, name) {
|
|
|
149
182
|
}
|
|
150
183
|
return null;
|
|
151
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Form-field readers for /rules. A form is untrusted input arriving on a route
|
|
187
|
+
* that gates money, so each reader narrows to one type instead of trusting the
|
|
188
|
+
* field name. Empty means "not supplied", which lets a blank input fall back to
|
|
189
|
+
* the engine's default rather than becoming a zero.
|
|
190
|
+
*/
|
|
191
|
+
function numField(v) {
|
|
192
|
+
const s = String(v ?? "").trim();
|
|
193
|
+
if (!s)
|
|
194
|
+
return undefined;
|
|
195
|
+
const n = Number(s);
|
|
196
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
197
|
+
}
|
|
198
|
+
/** One account id per line. Anything that is not G…56 is dropped, not warned. */
|
|
199
|
+
function linesField(v) {
|
|
200
|
+
return String(v ?? "").split(/[\s,]+/).map((s) => s.trim().toUpperCase())
|
|
201
|
+
.filter((s) => /^G[A-Z2-7]{55}$/.test(s));
|
|
202
|
+
}
|
|
203
|
+
/** A Stellar account id, and nothing else. Same shape the policy engine accepts. */
|
|
204
|
+
const isAccountId = (s) => /^G[A-Z2-7]{55}$/.test(s);
|
|
205
|
+
/** A comma-separated list of ids carried by a hidden field. */
|
|
206
|
+
function listField(v) {
|
|
207
|
+
return String(v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
208
|
+
}
|
|
152
209
|
function same(a, b) {
|
|
153
210
|
const x = Buffer.from(a), y = Buffer.from(b);
|
|
154
211
|
return x.length === y.length && timingSafeEqual(x, y);
|
|
@@ -156,6 +213,8 @@ function same(a, b) {
|
|
|
156
213
|
export class ApprovalDesk {
|
|
157
214
|
records = new Map();
|
|
158
215
|
seq = 0;
|
|
216
|
+
/** when /rules last saved, so the page can say "saved" without a query flag */
|
|
217
|
+
rulesSavedAt = 0;
|
|
159
218
|
server;
|
|
160
219
|
cfg;
|
|
161
220
|
constructor(cfg) { this.cfg = cfg; }
|
|
@@ -222,6 +281,46 @@ export class ApprovalDesk {
|
|
|
222
281
|
findByKey(key) {
|
|
223
282
|
return this.allByKey(key)[0] ?? null;
|
|
224
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Queue an escrow release for the human. This is the missing half of
|
|
286
|
+
* `paydlux escrow --release` on a --split vault: the money already sits in a
|
|
287
|
+
* claimable balance the buyer locked, so releasing it is the vault CLAIMING
|
|
288
|
+
* that balance — money coming IN, not going out. The claim still needs the
|
|
289
|
+
* vault's threshold of 2 (agent + approver), and the approver half lives on
|
|
290
|
+
* the phone, so it goes through the same challenge/sign/submit loop as a
|
|
291
|
+
* payment, tagged `kind: "escrow"` so the spending mandate — which caps
|
|
292
|
+
* money OUT — never touches it.
|
|
293
|
+
*
|
|
294
|
+
* The generic fields (uri/price/body) are filled so the portal page renders
|
|
295
|
+
* the row without knowing about escrow; the structured fields carry the
|
|
296
|
+
* truth the verification uses.
|
|
297
|
+
*/
|
|
298
|
+
addEscrow(input) {
|
|
299
|
+
this.sweepExpired();
|
|
300
|
+
// One live record per balance id. A seller who runs --release twice must
|
|
301
|
+
// not get two Approve buttons for the same money: the second signature
|
|
302
|
+
// would fail on the ledger (balance already claimed) and confuse the human.
|
|
303
|
+
for (const r of this.records.values()) {
|
|
304
|
+
if (r.kind === "escrow" && r.escrowId === input.escrowId &&
|
|
305
|
+
r.status !== "denied" && r.status !== "failed" && r.status !== "expired")
|
|
306
|
+
return r;
|
|
307
|
+
}
|
|
308
|
+
const id = `esc_${input.escrowId.slice(-8)}${randomBytes(3).toString("hex")}`;
|
|
309
|
+
const label = input.order || `${input.escrowId.slice(0, 16)}…`;
|
|
310
|
+
const rec = {
|
|
311
|
+
id, at: Date.now(),
|
|
312
|
+
uri: `/escrow/${label}`,
|
|
313
|
+
// amount only, like a payment price; the asset rides in `asset` and
|
|
314
|
+
// reaches the phone as `token`, so the row never prints "3.5 XLM XLM".
|
|
315
|
+
price: input.amount,
|
|
316
|
+
body: `escrow release — buyer ${input.buyer ?? "?"} — balance ${input.escrowId}`,
|
|
317
|
+
status: "pending",
|
|
318
|
+
kind: "escrow", escrowId: input.escrowId, order: input.order, asset: input.asset,
|
|
319
|
+
};
|
|
320
|
+
this.records.set(id, rec);
|
|
321
|
+
console.log(`[desk] ${id} escrow release awaiting a human: ${rec.price} (${label})`);
|
|
322
|
+
return rec;
|
|
323
|
+
}
|
|
225
324
|
get(id) { this.sweepExpired(); return this.records.get(id) ?? null; }
|
|
226
325
|
list() { this.sweepExpired(); return [...this.records.values()]; }
|
|
227
326
|
/**
|
|
@@ -253,13 +352,20 @@ export class ApprovalDesk {
|
|
|
253
352
|
// `pending` deliberately does NOT count: that is a request nobody has
|
|
254
353
|
// approved, and counting the whole queue would refuse the first approval
|
|
255
354
|
// just because more requests exist than the budget can cover.
|
|
256
|
-
const
|
|
257
|
-
|
|
355
|
+
const waiting = this.list().filter((r) => r.id !== rec.id && (r.status === "awaiting_phone" || r.status === "approved"));
|
|
356
|
+
const committed = waiting.reduce((sum, r) => sum + Number(r.price), 0);
|
|
357
|
+
// The same promise, counted inside the payee's category. A category budget
|
|
358
|
+
// that ignored the queue would let the human approve five payments and have
|
|
359
|
+
// the fifth fail at submission. This desk pays one payee, so every waiting
|
|
360
|
+
// record belongs to that payee's bucket — no per-record lookup needed.
|
|
361
|
+
const category = policy.categoryOf(this.cfg.payee);
|
|
362
|
+
const committedInCategory = category ? committed : undefined;
|
|
258
363
|
const verdict = policy.check({
|
|
259
364
|
amount: Number(rec.price),
|
|
260
365
|
payee: this.cfg.payee,
|
|
261
366
|
ttl: this.cfg.decideWithinSeconds,
|
|
262
367
|
committed,
|
|
368
|
+
committedInCategory,
|
|
263
369
|
});
|
|
264
370
|
if (verdict.ok)
|
|
265
371
|
return null;
|
|
@@ -278,6 +384,136 @@ export class ApprovalDesk {
|
|
|
278
384
|
uri: rec.uri, mandateHash: tx,
|
|
279
385
|
});
|
|
280
386
|
}
|
|
387
|
+
/** The rules as they stand, for the /rules page. */
|
|
388
|
+
policyView() {
|
|
389
|
+
return this.cfg.policy?.policy ?? { ...DEFAULT_POLICY };
|
|
390
|
+
}
|
|
391
|
+
/** What today's counters read, per category, for the /rules page. */
|
|
392
|
+
spentView() {
|
|
393
|
+
const p = this.cfg.policy;
|
|
394
|
+
if (!p)
|
|
395
|
+
return { total: 0, byCategory: {} };
|
|
396
|
+
const byCategory = {};
|
|
397
|
+
for (const name of Object.keys(p.policy.categories ?? {}))
|
|
398
|
+
byCategory[name] = p.spentInCategory(name);
|
|
399
|
+
return { total: p.spentToday(), byCategory };
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The cross-origin gate used by every route that changes money or the rules
|
|
403
|
+
* that gate it. A cross-origin fetch POST is a CORS *simple request*: no
|
|
404
|
+
* preflight, so the attacker's page never sees the reply but the action still
|
|
405
|
+
* happens. Browsers always send Origin on POST and Sec-Fetch-Site on every
|
|
406
|
+
* request, so checking both closes that path while leaving local automation
|
|
407
|
+
* (curl, tests, the phone app on its own origin) alone.
|
|
408
|
+
*
|
|
409
|
+
* Returns a reason string when the request must be refused, null when it may
|
|
410
|
+
* proceed.
|
|
411
|
+
*/
|
|
412
|
+
crossOriginGate(req) {
|
|
413
|
+
const origin = String(req.headers.origin ?? "");
|
|
414
|
+
if (origin) {
|
|
415
|
+
const here = String(req.headers.host ?? "").toLowerCase();
|
|
416
|
+
let oh;
|
|
417
|
+
try {
|
|
418
|
+
oh = new URL(origin).host.toLowerCase();
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
oh = "";
|
|
422
|
+
}
|
|
423
|
+
if (oh !== here)
|
|
424
|
+
return `this desk only accepts actions from itself, not from ${origin}`;
|
|
425
|
+
}
|
|
426
|
+
const site = String(req.headers["sec-fetch-site"] ?? "").toLowerCase();
|
|
427
|
+
if (site && site !== "same-origin" && site !== "none") {
|
|
428
|
+
return `this desk only accepts same-site actions (got ${site})`;
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Apply an edit from the /rules form.
|
|
434
|
+
*
|
|
435
|
+
* The form carries the whole rule set, so this rebuilds rather than patches:
|
|
436
|
+
* what you see on the page is what gets saved, and a stale field cannot
|
|
437
|
+
* survive an edit. Names and numbers are validated inside PolicyEngine.update
|
|
438
|
+
* — this function only decides which fields the form is allowed to touch.
|
|
439
|
+
*
|
|
440
|
+
* Deliberately absent: maxMandateTtl and maxLiveMandates. Those are ledger
|
|
441
|
+
* constraints (sequence binding), not owner preferences, and a form field for
|
|
442
|
+
* them would be an invitation to configure a mandate the chain will refuse.
|
|
443
|
+
*/
|
|
444
|
+
applyRules(action, form) {
|
|
445
|
+
const engine = this.cfg.policy;
|
|
446
|
+
if (!engine)
|
|
447
|
+
return { ...DEFAULT_POLICY };
|
|
448
|
+
const current = engine.policy;
|
|
449
|
+
const dropped = new Set(listField(form.get("delcat")));
|
|
450
|
+
// A category whose name was edited, old -> new. The page renders the name as
|
|
451
|
+
// a hidden field, so this only fires for a hand-crafted POST; keeping the
|
|
452
|
+
// mapping rather than dropping it means a rename never silently removes a
|
|
453
|
+
// wallet's ceiling.
|
|
454
|
+
const renamed = new Map();
|
|
455
|
+
const next = {};
|
|
456
|
+
const maxPer = numField(form.get("maxPerPayment"));
|
|
457
|
+
const daily = numField(form.get("dailyBudget"));
|
|
458
|
+
if (maxPer !== undefined)
|
|
459
|
+
next.maxPerPayment = maxPer;
|
|
460
|
+
if (daily !== undefined)
|
|
461
|
+
next.dailyBudget = daily;
|
|
462
|
+
next.allowlist = linesField(form.get("allowlist"));
|
|
463
|
+
next.denylist = linesField(form.get("denylist"));
|
|
464
|
+
// Categories: rebuilt from the rows the page shows, minus the ones the
|
|
465
|
+
// human removed, plus the one they just typed.
|
|
466
|
+
const categories = {};
|
|
467
|
+
for (const [key, value] of form) {
|
|
468
|
+
const m = /^cat_(.{1,32})_name$/.exec(key);
|
|
469
|
+
if (!m)
|
|
470
|
+
continue;
|
|
471
|
+
const was = m[1];
|
|
472
|
+
const name = normalizeCategory(value || was);
|
|
473
|
+
if (!name || dropped.has(was) || dropped.has(name))
|
|
474
|
+
continue;
|
|
475
|
+
if (name !== was)
|
|
476
|
+
renamed.set(was, name);
|
|
477
|
+
categories[name] = {
|
|
478
|
+
maxPerPayment: numField(form.get(`cat_${was}_max`)),
|
|
479
|
+
dailyBudget: numField(form.get(`cat_${was}_daily`)),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
// Both "add" boxes are processed whenever they are filled, not only when
|
|
483
|
+
// their own button was pressed. The alternative is a form where typing a
|
|
484
|
+
// wallet and pressing the wrong button silently discards what was typed —
|
|
485
|
+
// and a rule that vanishes without an error is worse than a strict one.
|
|
486
|
+
const addCat = normalizeCategory(form.get("newcat"));
|
|
487
|
+
if (addCat && !dropped.has(addCat)) {
|
|
488
|
+
categories[addCat] = {
|
|
489
|
+
maxPerPayment: numField(form.get("newcat_max")),
|
|
490
|
+
dailyBudget: numField(form.get("newcat_daily")),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
next.categories = categories;
|
|
494
|
+
// Wallet → category map: keep what is there, drop what was removed, add the
|
|
495
|
+
// new one. Rebuilt from the engine rather than the form, because a form
|
|
496
|
+
// field for the whole map would let a POST invent mappings for accounts
|
|
497
|
+
// nobody typed. A renamed bucket carries its wallets with it, so a rename
|
|
498
|
+
// can never quietly drop a ceiling.
|
|
499
|
+
const droppedMap = new Set(listField(form.get("delmap")));
|
|
500
|
+
const payeeCategory = {};
|
|
501
|
+
for (const [payee, name] of Object.entries(current.payeeCategory ?? {})) {
|
|
502
|
+
if (droppedMap.has(payee))
|
|
503
|
+
continue;
|
|
504
|
+
payeeCategory[payee] = renamed.get(name) ?? name;
|
|
505
|
+
}
|
|
506
|
+
const addWallet = String(form.get("newpayee") ?? "").trim().toUpperCase();
|
|
507
|
+
const addCatForWallet = normalizeCategory(form.get("newpayee_cat"));
|
|
508
|
+
if (isAccountId(addWallet) && addCatForWallet && categories[addCatForWallet]) {
|
|
509
|
+
payeeCategory[addWallet] = addCatForWallet;
|
|
510
|
+
}
|
|
511
|
+
next.payeeCategory = payeeCategory;
|
|
512
|
+
const policy = engine.update(next);
|
|
513
|
+
console.log(`[desk] spending rules updated (${action}): max ${policy.maxPerPayment}, daily ${policy.dailyBudget}, `
|
|
514
|
+
+ `${Object.keys(policy.categories ?? {}).length} categories, ${Object.keys(policy.payeeCategory ?? {}).length} wallets mapped`);
|
|
515
|
+
return policy;
|
|
516
|
+
}
|
|
281
517
|
async approve(id) {
|
|
282
518
|
this.sweepExpired();
|
|
283
519
|
const rec = this.records.get(id);
|
|
@@ -285,6 +521,27 @@ export class ApprovalDesk {
|
|
|
285
521
|
throw new Error("unknown handle " + id);
|
|
286
522
|
if (rec.status !== "pending")
|
|
287
523
|
throw new Error(`already ${rec.status}`);
|
|
524
|
+
// An escrow release is money coming IN. The spending mandate caps money
|
|
525
|
+
// going OUT, so applying it here would refuse a release for a budget that
|
|
526
|
+
// the release does not touch — and would make the seller's own money
|
|
527
|
+
// unclaimable on a day they already spent.
|
|
528
|
+
if (rec.kind === "escrow") {
|
|
529
|
+
// dev mode (both keys on this machine): claim straight away.
|
|
530
|
+
if (this.cfg.approver && this.cfg.agentSeed) {
|
|
531
|
+
try {
|
|
532
|
+
const out = await claimEscrow(this.cfg.vault.network, rec.escrowId, this.cfg.vault.address, { agent: Keypair.fromSecret(this.cfg.agentSeed), approver: Keypair.fromSecret(this.cfg.approver) });
|
|
533
|
+
rec.status = "settled";
|
|
534
|
+
rec.tx = out.txHash;
|
|
535
|
+
console.log(`[desk] ${rec.id} ESCROW RELEASED ${rec.price} — ledger ${rec.tx}`);
|
|
536
|
+
}
|
|
537
|
+
catch (e) {
|
|
538
|
+
rec.status = "failed";
|
|
539
|
+
rec.reason = e.message;
|
|
540
|
+
}
|
|
541
|
+
return rec;
|
|
542
|
+
}
|
|
543
|
+
return this.requestEscrowSignature(rec);
|
|
544
|
+
}
|
|
288
545
|
// Check before asking the phone. A human who approves a payment the mandate
|
|
289
546
|
// will refuse has spent a Face ID scan and a signature for nothing.
|
|
290
547
|
if (this.mandateBlock(rec, "refused before asking"))
|
|
@@ -344,6 +601,64 @@ export class ApprovalDesk {
|
|
|
344
601
|
}
|
|
345
602
|
return rec;
|
|
346
603
|
}
|
|
604
|
+
/**
|
|
605
|
+
* Split mode step 1 for an ESCROW release: build the claim, sign it with the
|
|
606
|
+
* agent key, hand it to the phone. `prepareEscrowRelease` reads the ledger
|
|
607
|
+
* and refuses a balance that does not exist or whose timer has not passed,
|
|
608
|
+
* so a queued-but-fake escrow dies here with a real reason instead of
|
|
609
|
+
* reaching the human as a signable challenge.
|
|
610
|
+
*/
|
|
611
|
+
async requestEscrowSignature(rec) {
|
|
612
|
+
if (!this.cfg.agentSeed) {
|
|
613
|
+
rec.status = "failed";
|
|
614
|
+
rec.reason = "an escrow release needs the agent seed on this machine to build the claim";
|
|
615
|
+
return rec;
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
const challenge = await prepareEscrowRelease(this.cfg.vault.network, rec.escrowId, this.cfg.vault.address, Keypair.fromSecret(this.cfg.agentSeed));
|
|
619
|
+
// The challenge carries its own id; keep the desk's handle so
|
|
620
|
+
// /challenge/:id and /signed/:id line up with what the queue shows.
|
|
621
|
+
challenge.id = rec.id;
|
|
622
|
+
rec.challenge = challenge;
|
|
623
|
+
rec.qr = challengeQrPayload(challenge);
|
|
624
|
+
rec.status = "awaiting_phone";
|
|
625
|
+
console.log(`[desk] ${rec.id} escrow claim sent to the phone: ${rec.price}`);
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
rec.status = "failed";
|
|
629
|
+
rec.reason = e.message;
|
|
630
|
+
console.log(`[desk] ${rec.id} escrow claim refused before asking: ${rec.reason}`);
|
|
631
|
+
}
|
|
632
|
+
return rec;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Split mode step 2 for an ESCROW release: the phone signed the claim.
|
|
636
|
+
* No mandate check — this is money coming IN — and no recordSpend: the
|
|
637
|
+
* daily budget counts payments OUT, and counting a release against it would
|
|
638
|
+
* make the seller's own earned money unclaimable.
|
|
639
|
+
*/
|
|
640
|
+
async completeEscrow(rec, signedXdr) {
|
|
641
|
+
const problem = verifyEscrowAgainstChallenge(signedXdr, rec);
|
|
642
|
+
if (problem) {
|
|
643
|
+
rec.status = "failed";
|
|
644
|
+
rec.reason = problem;
|
|
645
|
+
console.log(`[desk] ${rec.id} rejected: ${problem}`);
|
|
646
|
+
return rec;
|
|
647
|
+
}
|
|
648
|
+
rec.signedXdr = signedXdr;
|
|
649
|
+
try {
|
|
650
|
+
const res = await submitApproved(signedXdr, this.cfg.vault.network, this.cfg.vault.horizon);
|
|
651
|
+
rec.status = "settled";
|
|
652
|
+
rec.tx = res.hash;
|
|
653
|
+
console.log(`[desk] ${rec.id} ESCROW RELEASED ${rec.price} — ledger ${rec.tx}`);
|
|
654
|
+
}
|
|
655
|
+
catch (e) {
|
|
656
|
+
rec.status = "failed";
|
|
657
|
+
rec.reason = e.message;
|
|
658
|
+
console.log(`[desk] ${rec.id} failed on the ledger: ${rec.reason}`);
|
|
659
|
+
}
|
|
660
|
+
return rec;
|
|
661
|
+
}
|
|
347
662
|
/**
|
|
348
663
|
* Split mode step 2: the phone answered. `signedXdr` is the transaction with
|
|
349
664
|
* the approver signature added, returned by signChallenge() on the phone.
|
|
@@ -359,6 +674,11 @@ export class ApprovalDesk {
|
|
|
359
674
|
throw new Error(`not awaiting a phone signature (it is ${rec.status})`);
|
|
360
675
|
if (!rec.challenge)
|
|
361
676
|
throw new Error("no challenge was issued for " + id);
|
|
677
|
+
// An escrow release verifies against its own shape (a claim of THIS
|
|
678
|
+
// balance id) and bypasses the spending mandate, which only caps payments
|
|
679
|
+
// going out.
|
|
680
|
+
if (rec.kind === "escrow")
|
|
681
|
+
return this.completeEscrow(rec, signedXdr);
|
|
362
682
|
// Bind the answer to the question. Without this check the desk would submit
|
|
363
683
|
// any signed transaction posted to this route — including one the phone
|
|
364
684
|
// signed for a DIFFERENT payment, at a different price. That is how a
|
|
@@ -432,9 +752,14 @@ export class ApprovalDesk {
|
|
|
432
752
|
tx: r.tx,
|
|
433
753
|
/** what the buyer says it wants. Agent-controlled text: render as text. */
|
|
434
754
|
wants: (r.body ?? "").slice(0, 200),
|
|
435
|
-
token: this.cfg.vault.token.getCode(),
|
|
755
|
+
token: r.kind === "escrow" ? r.asset ?? this.cfg.vault.token.getCode() : this.cfg.vault.token.getCode(),
|
|
436
756
|
expiresAt: r.challenge?.summary.expiresAt,
|
|
437
757
|
hash: r.challenge?.hash,
|
|
758
|
+
// The phone renders an escrow row differently: it is money IN, and the
|
|
759
|
+
// human verifies the balance id, not a payee.
|
|
760
|
+
kind: r.kind ?? "payment",
|
|
761
|
+
escrowId: r.escrowId,
|
|
762
|
+
order: r.order,
|
|
438
763
|
}));
|
|
439
764
|
}
|
|
440
765
|
/** Serve the pending list as JSON (the portal polls this). */
|
|
@@ -604,6 +929,43 @@ export class ApprovalDesk {
|
|
|
604
929
|
}
|
|
605
930
|
if (url.pathname === "/list")
|
|
606
931
|
return send(200, this.list());
|
|
932
|
+
// The spending rules. Read-only view of the mandate the desk enforces,
|
|
933
|
+
// plus the form that edits it. Key-gated like everything else: these
|
|
934
|
+
// numbers decide what money leaves the vault, so an unauthenticated
|
|
935
|
+
// /rules would be an open "raise the budget" endpoint.
|
|
936
|
+
if (url.pathname === "/rules" && req.method === "GET") {
|
|
937
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
938
|
+
res.end(rulesPage(this.policyView(), this.spentView(), this.cfg.vault.address, this.cfg.vault.network, qs, Date.now() - this.rulesSavedAt < 15_000));
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (url.pathname === "/rules.js") {
|
|
942
|
+
const body = readPortalFile("rules.js");
|
|
943
|
+
if (body === null)
|
|
944
|
+
return notFound(res);
|
|
945
|
+
res.writeHead(200, {
|
|
946
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
947
|
+
"x-content-type-options": "nosniff",
|
|
948
|
+
});
|
|
949
|
+
res.end(body);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
if (url.pathname === "/rules" && req.method === "POST") {
|
|
953
|
+
// Editing the rules moves money indirectly, so it gets the same
|
|
954
|
+
// cross-origin gate as /approve: a page on another site must not be
|
|
955
|
+
// able to POST a form that raises your daily budget.
|
|
956
|
+
const gate = this.crossOriginGate(req);
|
|
957
|
+
if (gate)
|
|
958
|
+
return send(403, { error: "cross_origin", reason: gate });
|
|
959
|
+
const body = await readBody(req);
|
|
960
|
+
const form = new URLSearchParams(body);
|
|
961
|
+
const action = form.get("action") ?? "save";
|
|
962
|
+
const policy = this.applyRules(action, form);
|
|
963
|
+
this.rulesSavedAt = Date.now();
|
|
964
|
+
this.cfg.onPolicyChange?.(policy);
|
|
965
|
+
res.writeHead(303, { location: "/rules" + (this.cfg.portalKey ? `?k=${encodeURIComponent(this.cfg.portalKey)}` : "") });
|
|
966
|
+
res.end();
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
607
969
|
// The approver app's list view. Key-gated like everything else, and it
|
|
608
970
|
// carries no paid content and no challenge XDR.
|
|
609
971
|
if (url.pathname === "/queue")
|
|
@@ -635,36 +997,45 @@ export class ApprovalDesk {
|
|
|
635
997
|
wants: (rec.body ?? "").slice(0, 200),
|
|
636
998
|
memo: rec.challenge.summary.memo ?? "",
|
|
637
999
|
expiresAt: rec.challenge.summary.expiresAt,
|
|
1000
|
+
// An escrow challenge renders differently on the phone: the op
|
|
1001
|
+
// carries no amount and no payee, so the app shows the amount from
|
|
1002
|
+
// here and asks the human to check the balance id.
|
|
1003
|
+
kind: rec.kind ?? "payment",
|
|
1004
|
+
escrowId: rec.escrowId,
|
|
1005
|
+
// The claim op carries no amount, so the phone shows the amount
|
|
1006
|
+
// the desk read from the ledger alongside the balance id it puts
|
|
1007
|
+
// in front of the human.
|
|
1008
|
+
amount: rec.kind === "escrow" ? rec.price : undefined,
|
|
1009
|
+
asset: rec.kind === "escrow" ? rec.asset : undefined,
|
|
638
1010
|
});
|
|
639
1011
|
}
|
|
1012
|
+
// The CLI's hand-off for `paydlux escrow --release` on a --split vault:
|
|
1013
|
+
// queue this balance on the running desk so the phone can sign it.
|
|
1014
|
+
// Key-gated like every other route, and it only queues — approving
|
|
1015
|
+
// still needs the human's hold-and-sign on the phone.
|
|
1016
|
+
if (url.pathname === "/escrow" && req.method === "POST") {
|
|
1017
|
+
const body = await readBody(req);
|
|
1018
|
+
const input = JSON.parse(body || "{}");
|
|
1019
|
+
if (!input.escrowId)
|
|
1020
|
+
return send(400, { error: "escrowId is required" });
|
|
1021
|
+
if (!input.amount || !input.asset)
|
|
1022
|
+
return send(400, { error: "amount and asset are required" });
|
|
1023
|
+
const rec = this.addEscrow({
|
|
1024
|
+
escrowId: String(input.escrowId),
|
|
1025
|
+
order: input.order ? String(input.order) : undefined,
|
|
1026
|
+
amount: String(input.amount),
|
|
1027
|
+
asset: String(input.asset),
|
|
1028
|
+
buyer: input.buyer ? String(input.buyer) : undefined,
|
|
1029
|
+
});
|
|
1030
|
+
return send(200, { id: rec.id, status: rec.status, escrowId: rec.escrowId });
|
|
1031
|
+
}
|
|
640
1032
|
const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
|
|
641
1033
|
if (m && req.method === "POST") {
|
|
642
1034
|
// These routes move money, so they must not be reachable from another
|
|
643
|
-
// page
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
// Origin on POST and Sec-Fetch-Site on every request, so checking both
|
|
648
|
-
// closes that path while leaving local automation (curl, tests, the
|
|
649
|
-
// phone app on its own origin) alone.
|
|
650
|
-
const origin = String(req.headers.origin ?? "");
|
|
651
|
-
if (origin) {
|
|
652
|
-
const here = String(req.headers.host ?? "").toLowerCase();
|
|
653
|
-
let oh;
|
|
654
|
-
try {
|
|
655
|
-
oh = new URL(origin).host.toLowerCase();
|
|
656
|
-
}
|
|
657
|
-
catch {
|
|
658
|
-
oh = "";
|
|
659
|
-
}
|
|
660
|
-
if (oh !== here) {
|
|
661
|
-
return send(403, { error: "cross_origin", reason: `this desk only accepts actions from itself, not from ${origin}` });
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
const site = String(req.headers["sec-fetch-site"] ?? "").toLowerCase();
|
|
665
|
-
if (site && site !== "same-origin" && site !== "none") {
|
|
666
|
-
return send(403, { error: "cross_origin", reason: `this desk only accepts same-site actions (got ${site})` });
|
|
667
|
-
}
|
|
1035
|
+
// page — same gate as /rules, see crossOriginGate.
|
|
1036
|
+
const gate = this.crossOriginGate(req);
|
|
1037
|
+
if (gate)
|
|
1038
|
+
return send(403, { error: "cross_origin", reason: gate });
|
|
668
1039
|
if (m[1] === "signed") {
|
|
669
1040
|
const body = await readBody(req);
|
|
670
1041
|
const { signedXdr } = JSON.parse(body || "{}");
|
package/dist/gateway.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type IncomingMessage, type Server, type ServerResponse } from "node:htt
|
|
|
2
2
|
import { Keypair, 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
|
+
import type { PolicyEngine, Policy } from "./policy.ts";
|
|
6
6
|
import { claimEscrow } from "./escrow.ts";
|
|
7
7
|
export declare const PAY_HEADER = "x-payment";
|
|
8
8
|
export type Route = {
|
|
@@ -40,6 +40,12 @@ export type GatewayConfig = {
|
|
|
40
40
|
* phone sends back.
|
|
41
41
|
*/
|
|
42
42
|
split?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The agent seed, passed to the desk so it can build an ESCROW RELEASE
|
|
45
|
+
* challenge (the claim is signed agent + phone approver). Same trust level
|
|
46
|
+
* as `principal`: it is the machine-side half, never the approving half.
|
|
47
|
+
*/
|
|
48
|
+
agentSeed?: string;
|
|
43
49
|
deskPort?: number;
|
|
44
50
|
/** gate the approval portal behind a single unguessable link */
|
|
45
51
|
portalKey?: string;
|
|
@@ -65,6 +71,11 @@ export type GatewayConfig = {
|
|
|
65
71
|
* enforced — see DeskConfig.policy for why the gateway is the wrong place.
|
|
66
72
|
*/
|
|
67
73
|
policy?: PolicyEngine;
|
|
74
|
+
/**
|
|
75
|
+
* Persist an edited spending policy. Passed straight to the desk; see
|
|
76
|
+
* DeskConfig.onPolicyChange for why it is a callback.
|
|
77
|
+
*/
|
|
78
|
+
onPolicyChange?: (policy: Policy) => void;
|
|
68
79
|
};
|
|
69
80
|
export type PaymentProof = {
|
|
70
81
|
/** pre-authorised envelope, base64 XDR, as issued by the vault */
|
package/dist/gateway.js
CHANGED
|
@@ -45,9 +45,11 @@ export class Gateway {
|
|
|
45
45
|
port: cfg.deskPort ?? 4022,
|
|
46
46
|
portalKey: cfg.portalKey,
|
|
47
47
|
split: cfg.split,
|
|
48
|
+
agentSeed: cfg.agentSeed,
|
|
48
49
|
publicHost: cfg.publicHost,
|
|
49
50
|
decideWithinSeconds: cfg.decideWithinSeconds,
|
|
50
51
|
policy: cfg.policy,
|
|
52
|
+
onPolicyChange: cfg.onPolicyChange,
|
|
51
53
|
});
|
|
52
54
|
}
|
|
53
55
|
/** Mandates the vault currently holds, so the gateway can hand them out. */
|
|
@@ -89,6 +91,18 @@ export class Gateway {
|
|
|
89
91
|
: outcome.settled ? "released" : o.escrowId ? "gone" : "awaiting_escrow";
|
|
90
92
|
if (state === "released" && !opts.includeSettled)
|
|
91
93
|
continue;
|
|
94
|
+
// A releasable escrow goes to the desk automatically, so the phone sees
|
|
95
|
+
// the release without anyone re-running the CLI. addEscrow is idempotent
|
|
96
|
+
// per balance id, so this is safe to call from a read path.
|
|
97
|
+
if (state === "releasable" && e) {
|
|
98
|
+
this.desk.addEscrow({
|
|
99
|
+
escrowId: o.escrowId,
|
|
100
|
+
order: o.id,
|
|
101
|
+
amount: e.amount,
|
|
102
|
+
asset: e.asset === "native" ? "XLM" : e.asset.split(":")[0],
|
|
103
|
+
buyer: e.buyer,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
92
106
|
out.push({ ...o, state, escrow: e, outcome });
|
|
93
107
|
}
|
|
94
108
|
return out.sort((a, b) => (a.releaseAt ?? 0) - (b.releaseAt ?? 0));
|