@paydlux/agent 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,9 +44,35 @@ stellar/onboard.ts keys → funded vault → printed agent key
44
44
  ```bash
45
45
  npm i
46
46
  npx tsx stellar/demo.ts # 17 checks, live testnet
47
- npx tsx stellar/story.ts --public --split # unguessable link; approve on your phone
47
+ npx tsx stellar/story.ts --tls --split # https://desk.paydlux.com; approve on your phone
48
48
  ```
49
49
 
50
+ `--tls` is the one to use for anything real. It runs a named Cloudflare tunnel
51
+ so the desk is served over `https://desk.paydlux.com` with a real certificate,
52
+ instead of a random `trycloudflare.com` hostname (`--public`) that nothing tells
53
+ the human is *your* desk. Setup is one-time and free:
54
+
55
+ ```bash
56
+ cloudflared tunnel login
57
+ cloudflared tunnel create paydlux-desk
58
+ cloudflared tunnel route dns paydlux-desk desk.paydlux.com
59
+ ```
60
+
61
+ Then `~/.cloudflared/config.yml` points the hostname at `http://127.0.0.1:4022`.
62
+ The tunnel's credentials file stays in `~/.cloudflared/` and must never be
63
+ committed: whoever holds it can serve your hostname.
64
+
65
+ What the desk enforces once it is public (`npx tsx stellar/tls-check.ts`, 19 checks):
66
+
67
+ - plain `http://` is 301'd to `https://` and never served, with HSTS set
68
+ - the desk listens on `127.0.0.1` only, and refuses any `Host` that is not
69
+ `desk.paydlux.com` — so the local port cannot be driven directly to skip TLS
70
+ - a desk configured with a public hostname **refuses to start** without a portal
71
+ key, rather than coming up as an open approval button
72
+ - the portal key also rides in an `HttpOnly; SameSite=Strict; Secure` cookie, so
73
+ the approver app can load its own scripts (a `<script src>` cannot inherit
74
+ `?k=` from the page URL) without the key ever appearing in the DOM
75
+
50
76
  No wallet, no faucet, no config.
51
77
 
52
78
  ### The human key does not live on the seller's machine
package/dist/desk.d.ts CHANGED
@@ -38,6 +38,15 @@ export type DeskConfig = {
38
38
  /** If set, the portal and its actions require this key in the URL.
39
39
  * Required before putting the desk on any public URL. */
40
40
  portalKey?: string;
41
+ /**
42
+ * The hostname this desk is served on, e.g. "desk.paydlux.com". When set, the
43
+ * desk answers ONLY requests for that Host. A named Cloudflare tunnel gives
44
+ * real TLS on your own domain, but the local port it forwards to is still
45
+ * plain HTTP: if anything on this machine (or network) reached 127.0.0.1:4022
46
+ * directly it would bypass TLS and the HSTS/cookie protections the domain
47
+ * gives you. Pinning the Host means the only way in is through the tunnel.
48
+ */
49
+ publicHost?: string;
41
50
  };
42
51
  export declare class ApprovalDesk {
43
52
  private records;
package/dist/desk.js CHANGED
@@ -97,6 +97,20 @@ function readBody(req) {
97
97
  req.on("error", reject);
98
98
  });
99
99
  }
100
+ /** Read one cookie out of the request header. No dependency for this. */
101
+ function cookieOf(req, name) {
102
+ const raw = req.headers.cookie;
103
+ if (!raw)
104
+ return null;
105
+ for (const part of raw.split(";")) {
106
+ const eq = part.indexOf("=");
107
+ if (eq < 0)
108
+ continue;
109
+ if (part.slice(0, eq).trim() === name)
110
+ return decodeURIComponent(part.slice(eq + 1).trim());
111
+ }
112
+ return null;
113
+ }
100
114
  function same(a, b) {
101
115
  const x = Buffer.from(a), y = Buffer.from(b);
102
116
  return x.length === y.length && timingSafeEqual(x, y);
@@ -230,16 +244,68 @@ export class ApprovalDesk {
230
244
  /** Serve the pending list as JSON (the portal polls this). */
231
245
  api(handler) { return handler; }
232
246
  listen(port = this.cfg.port) {
247
+ // A desk reachable from outside this machine must be gated. This is a
248
+ // startup refusal rather than a runtime one because the dangerous case is
249
+ // forgetting a flag: a public desk with no portal key is an open approval
250
+ // button, and it would otherwise start up looking completely healthy.
251
+ if (this.cfg.publicHost && !this.cfg.portalKey) {
252
+ throw new Error("a desk served on " + this.cfg.publicHost + " needs a portalKey; " +
253
+ "refusing to start an ungated approval desk");
254
+ }
233
255
  const srv = createServer(async (req, res) => {
234
256
  // (server handle kept below so close() can stop it)
235
257
  const url = new URL(req.url ?? "/", "http://localhost");
258
+ // Order matters. The Host pin runs first so a request that did not come
259
+ // through the tunnel is refused before the desk reveals anything about
260
+ // itself, including whether a portal key looks correct.
261
+ if (this.cfg.publicHost) {
262
+ const want = this.cfg.publicHost.toLowerCase();
263
+ const got = String(req.headers.host ?? "").toLowerCase().replace(/:\d+$/, "");
264
+ if (got !== want) {
265
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
266
+ res.end("403 — this desk only answers on " + want + ".");
267
+ return;
268
+ }
269
+ // Cloudflare terminates TLS and forwards both http and https to us, so
270
+ // http://desk.paydlux.com answers too unless we send it away. A human
271
+ // approving a payment over plain HTTP is handing the portal key and the
272
+ // transaction to anyone on the path, so plain HTTP gets a redirect and
273
+ // nothing else. x-forwarded-proto is set by Cloudflare, not the client.
274
+ if (String(req.headers["x-forwarded-proto"] ?? "").split(",")[0].trim() === "http") {
275
+ res.writeHead(301, { location: "https://" + want + (req.url ?? "/") });
276
+ res.end();
277
+ return;
278
+ }
279
+ // Once HTTPS is established, tell the browser to never try plain HTTP
280
+ // again for this host.
281
+ res.setHeader("strict-transport-security", "max-age=31536000; includeSubDomains");
282
+ }
283
+ // Liveness probe, before the portal-key gate: it proves the desk and its
284
+ // TLS path are up without revealing anything. No data, safe to poll.
285
+ if (url.pathname === "/healthz") {
286
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
287
+ res.end("ok");
288
+ return;
289
+ }
236
290
  // A public demo link must not be an open approval button. If a portalKey
237
291
  // is configured, every route needs it, or the desk answers 403.
238
- if (this.cfg.portalKey && !same(url.searchParams.get("k") ?? "", this.cfg.portalKey)) {
292
+ //
293
+ // The key may arrive in the URL (the link the human opened) or in a
294
+ // cookie the desk set when that link was first validated. The cookie is
295
+ // what makes the approver app work: its <script src="xdr.js"> cannot
296
+ // inherit ?k= from the page URL, so without a cookie the app 403s on its
297
+ // own code and shows a blank form. HttpOnly + SameSite=Strict + Secure
298
+ // keeps it out of the DOM and off any cross-site request.
299
+ const presented = url.searchParams.get("k") ?? cookieOf(req, "pd_portal");
300
+ if (this.cfg.portalKey && !same(presented ?? "", this.cfg.portalKey)) {
239
301
  res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
240
302
  res.end("403 — this approval link was never issued or has been rotated.");
241
303
  return;
242
304
  }
305
+ if (this.cfg.portalKey && url.searchParams.get("k") !== null) {
306
+ res.setHeader("set-cookie", `pd_portal=${this.cfg.portalKey}; Path=/; HttpOnly; SameSite=Strict; ` +
307
+ `Secure; Max-Age=${12 * 3600}`);
308
+ }
243
309
  const qs = url.search;
244
310
  const send = (code, obj) => {
245
311
  res.writeHead(code, { "content-type": "application/json" });
@@ -332,7 +398,10 @@ export class ApprovalDesk {
332
398
  }
333
399
  });
334
400
  this.server = srv;
335
- return new Promise((r) => srv.listen(port, () => r()));
401
+ // Loopback only. The public path is the Cloudflare tunnel, which connects
402
+ // back to 127.0.0.1; binding to 0.0.0.0 would expose a plain-HTTP desk to
403
+ // the LAN and defeat both the TLS and the Host pin above.
404
+ return new Promise((r) => srv.listen(port, "127.0.0.1", () => r()));
336
405
  }
337
406
  /** Stop serving. Without this the process never exits. */
338
407
  close() {
package/dist/gateway.d.ts CHANGED
@@ -30,6 +30,8 @@ export type GatewayConfig = {
30
30
  deskPort?: number;
31
31
  /** gate the approval portal behind a single unguessable link */
32
32
  portalKey?: string;
33
+ /** hostname the desk is publicly served on; pins the Host header. See DeskConfig. */
34
+ publicHost?: string;
33
35
  };
34
36
  export type PaymentProof = {
35
37
  /** pre-authorised envelope, base64 XDR, as issued by the vault */
package/dist/gateway.js CHANGED
@@ -36,6 +36,7 @@ export class Gateway {
36
36
  port: cfg.deskPort ?? 4022,
37
37
  portalKey: cfg.portalKey,
38
38
  split: cfg.split,
39
+ publicHost: cfg.publicHost,
39
40
  });
40
41
  }
41
42
  /** Mandates the vault currently holds, so the gateway can hand them out. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",
@@ -29,6 +29,8 @@
29
29
  "demo:stellar": "tsx stellar/demo.ts",
30
30
  "setup:stellar": "tsx stellar/onboard.ts",
31
31
  "story:stellar": "tsx stellar/story.ts",
32
+ "story:tls": "tsx stellar/story.ts --tls --split",
33
+ "test:tls": "tsx stellar/tls-check.ts",
32
34
  "test": "cd contracts && forge test",
33
35
  "test:stellar": "tsx stellar/demo.ts",
34
36
  "test:phone": "tsx stellar/phone-check.ts && tsx stellar/desk-split-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",