@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/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 { portalPage } from "./portal.js";
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 committed = this.list().reduce((sum, r) => (r.id !== rec.id && (r.status === "awaiting_phone" || r.status === "approved"))
257
- ? sum + Number(r.price) : sum, 0);
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. A cross-origin fetch POST is a CORS *simple request*: no
644
- // preflight, so the attacker's page never sees the reply but the
645
- // approval still happens. Verified end to end: a page on another port
646
- // settled a parked payment and paid the seller. Browsers always send
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));