@paydlux/agent 0.1.18 → 0.1.19

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.
@@ -0,0 +1,385 @@
1
+ // The public showcase: a page where a stranger watches a real 402 payment
2
+ // happen end to end, and can tip the seller if they want to.
3
+ //
4
+ // Why a separate server instead of more routes on the seller's gateway:
5
+ // the gateway is the thing 41 mandate checks and every suite protect. Mounting
6
+ // the public in front of it means the gateway code does not change at all —
7
+ // this server decides what the internet may touch and delegates the rest.
8
+ //
9
+ // What a visitor's browser actually does (all of it visible in the page):
10
+ // 1. GET /demo/data -> 402 + x-payment terms. Nothing paid.
11
+ // 2. POST /demo/enroll -> the seller vouches for a throwaway agent
12
+ // key the browser made, for a short while.
13
+ // 3. GET /demo/data + intent -> 202. A human now decides on their phone.
14
+ // 4. poll /pay/status -> awaiting_phone, then settled with a tx hash.
15
+ // 5. GET /demo/data + intent -> 200, the payload, and the ledger hash.
16
+ //
17
+ // The visitor never holds a seller key and never signs a payment. The only
18
+ // thing their machine signs is the *request*. The money still needs the
19
+ // seller's phone and still counts against the seller's daily budget.
20
+ import { createServer } from "node:http";
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { dirname, join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ export const DEMO_HOST = "demo.paydlux.com";
25
+ export const DEMO_ROUTE = "/demo/data";
26
+ /** How long an enrolled agent key stays valid, and how many may be live. */
27
+ const KEY_TTL_MS = 30 * 60 * 1000;
28
+ const MAX_KEYS = 100;
29
+ const MAX_KEYS_PER_IP = 5;
30
+ /**
31
+ * A short-lived trust decision: "this public key may place payment requests
32
+ * against my gateway until T". It is not a payment authority — a request still
33
+ * needs a human and still hits the spending mandate. Without it a browser
34
+ * cannot produce a valid intent at all, and shipping a fixed agent key inside
35
+ * public JavaScript would put a private key on every visitor's disk.
36
+ */
37
+ export class AgentKeyStore {
38
+ max;
39
+ ttl;
40
+ keys = new Map();
41
+ constructor(max = MAX_KEYS, ttl = KEY_TTL_MS) {
42
+ this.max = max;
43
+ this.ttl = ttl;
44
+ }
45
+ prune(now = Date.now()) {
46
+ for (const [k, v] of this.keys)
47
+ if (v.until <= now)
48
+ this.keys.delete(k);
49
+ }
50
+ /** Register a key. Returns null when the request is refused, with a reason. */
51
+ enroll(pub, ip) {
52
+ this.prune();
53
+ if (!/^G[A-Z2-7]{55}$/.test(pub ?? ""))
54
+ return null;
55
+ let mine = 0;
56
+ for (const v of this.keys.values())
57
+ if (v.ip === ip)
58
+ mine++;
59
+ if (mine >= MAX_KEYS_PER_IP)
60
+ return null;
61
+ if (this.keys.size >= this.max)
62
+ return null;
63
+ const until = Date.now() + this.ttl;
64
+ this.keys.set(pub, { until, ip });
65
+ return { until };
66
+ }
67
+ /** Public keys the gateway should accept right now. Feed this to agentKeys. */
68
+ live() {
69
+ this.prune();
70
+ return [...this.keys.keys()];
71
+ }
72
+ get size() { this.prune(); return this.keys.size; }
73
+ }
74
+ /** The approver app's StrKey/XDR module, read from disk so it stays one file. */
75
+ function readXdrJs() {
76
+ const here = dirname(fileURLToPath(import.meta.url));
77
+ for (const rel of [["..", "phone-app", "xdr.js"], ["phone-app", "xdr.js"]]) {
78
+ const p = join(here, ...rel);
79
+ if (existsSync(p))
80
+ return readFileSync(p, "utf8");
81
+ }
82
+ return null;
83
+ }
84
+ function clientIp(req) {
85
+ // Cloudflare terminates TLS and forwards the real visitor address here.
86
+ const cf = String(req.headers["cf-connecting-ip"] ?? "").split(",")[0].trim();
87
+ if (cf)
88
+ return cf;
89
+ const xf = String(req.headers["x-forwarded-for"] ?? "").split(",")[0].trim();
90
+ return xf || req.socket.remoteAddress || "unknown";
91
+ }
92
+ /**
93
+ * The payload the seller ships once money has landed. It is deliberately
94
+ * boring and deliberately verifiable: the tx hash is the point, and the
95
+ * visitor can open it on stellar.expert without trusting us.
96
+ */
97
+ function demoPayload(cfg) {
98
+ return JSON.stringify({
99
+ served: new Date().toISOString(),
100
+ note: "served because a human approved this payment and the ledger accepted it",
101
+ vault: cfg.vault,
102
+ network: cfg.network,
103
+ proof: cfg.explorer,
104
+ }, null, 2);
105
+ }
106
+ /**
107
+ * Serve the showcase in front of a running gateway.
108
+ *
109
+ * The Host pin is not decoration. This port is reachable from the tunnel and
110
+ * from loopback; without the pin, anything that could reach 127.0.0.1 could
111
+ * also ask this server to hand out agent keys. Pinning means the only path in
112
+ * is the hostname Cloudflare routes here.
113
+ */
114
+ export function startDemo(cfg) {
115
+ const host = (cfg.host ?? DEMO_HOST).toLowerCase();
116
+ const store = new AgentKeyStore();
117
+ const sync = () => cfg.onKeys?.(store.live());
118
+ const srv = createServer(async (req, res) => {
119
+ const url = new URL(req.url ?? "/", "http://localhost");
120
+ const got = String(req.headers.host ?? "").toLowerCase().replace(/:\d+$/, "");
121
+ if (got !== host) {
122
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
123
+ res.end(`403 — this showcase only answers on ${host}.`);
124
+ return;
125
+ }
126
+ if (String(req.headers["x-forwarded-proto"] ?? "").split(",")[0].trim() === "http") {
127
+ res.writeHead(301, { location: `https://${host}${req.url}` });
128
+ res.end();
129
+ return;
130
+ }
131
+ const p = url.pathname;
132
+ if (p === "/healthz") {
133
+ res.writeHead(200, { "content-type": "text/plain" }).end("ok");
134
+ return;
135
+ }
136
+ // The page. No inline script: the JS is a separate file so a strict
137
+ // script-src actually means something, and the page interpolates nothing
138
+ // the visitor controls.
139
+ if (p === "/" || p === "/index.html") {
140
+ res.writeHead(200, {
141
+ "content-type": "text/html; charset=utf-8",
142
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
143
+ "img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
144
+ "form-action 'none'; frame-ancestors 'none'",
145
+ "x-content-type-options": "nosniff",
146
+ "referrer-policy": "no-referrer",
147
+ "cache-control": "no-store",
148
+ });
149
+ res.end(demoPage(cfg));
150
+ return;
151
+ }
152
+ if (p === "/demo/demo.js" || p === "/demo/xdr.js") {
153
+ // xdr.js is the SAME file the phone approver runs, and the same one 32
154
+ // xdr-check tests cover. Hand-rolling base32/CRC16 here produced a 57
155
+ // character account id on the first live try — the version byte was
156
+ // counted twice and the checksum was missing, so the gateway's
157
+ // Keypair.fromPublicKey would have rejected every visitor. Reuse the
158
+ // tested encoder instead of writing a second one.
159
+ const body = p.endsWith("xdr.js") ? readXdrJs() : demoScript();
160
+ if (body === null) {
161
+ res.writeHead(404, { "content-type": "text/plain" });
162
+ res.end("not found");
163
+ return;
164
+ }
165
+ res.writeHead(200, {
166
+ "content-type": "text/javascript; charset=utf-8",
167
+ "x-content-type-options": "nosniff",
168
+ "cache-control": "no-store",
169
+ });
170
+ res.end(body);
171
+ return;
172
+ }
173
+ // Seller vouches for a throwaway agent key the browser generated.
174
+ if (p === "/demo/enroll" && req.method === "POST") {
175
+ let body = "";
176
+ req.on("data", (c) => { if (body.length < 2000)
177
+ body += c.toString(); });
178
+ await new Promise((r) => req.on("end", () => r()));
179
+ let pub = "";
180
+ try {
181
+ pub = String(JSON.parse(body)?.pub ?? "");
182
+ }
183
+ catch { /* refused below */ }
184
+ const ok = store.enroll(pub, clientIp(req));
185
+ if (!ok) {
186
+ res.writeHead(429, { "content-type": "application/json" });
187
+ res.end(JSON.stringify({ error: "enrollment refused",
188
+ hint: "too many keys from this address, or the showcase is full. Try again later." }));
189
+ return;
190
+ }
191
+ sync();
192
+ res.writeHead(200, { "content-type": "application/json" });
193
+ res.end(JSON.stringify({ ok: true, until: ok.until, vault: cfg.vault }));
194
+ return;
195
+ }
196
+ // Everything else belongs to the gateway: the paid route, /pay/status.
197
+ // Only demo paths are exposed here, so pointing the tunnel at this port
198
+ // cannot reach a seller's private routes by guessing a URI.
199
+ if (!(p === DEMO_ROUTE || p === "/pay/status" || p === "/demo/terms")) {
200
+ res.writeHead(404, { "content-type": "application/json" });
201
+ res.end(JSON.stringify({ error: "not found", routes: [DEMO_ROUTE] }));
202
+ return;
203
+ }
204
+ if (p === "/demo/terms") {
205
+ res.writeHead(200, { "content-type": "application/json" });
206
+ res.end(JSON.stringify(cfg.gateway.requirements(DEMO_ROUTE), null, 2));
207
+ return;
208
+ }
209
+ // Re-sync the accepted set on every proxied request. Syncing only at
210
+ // enrollment meant an expired vouch stayed live inside the gateway until
211
+ // some *other* visitor enrolled — the expiry clock in AgentKeyStore would
212
+ // have governed nothing. The store is tiny, so this is a map walk.
213
+ sync();
214
+ await cfg.gateway.handler(() => demoPayload(cfg))(req, res);
215
+ });
216
+ return new Promise((resolve) => {
217
+ // Loopback only. The public path is the named tunnel; a 0.0.0.0 bind would
218
+ // put an ungated key-enrollment endpoint on the LAN.
219
+ srv.listen(cfg.port, "127.0.0.1", () => resolve(srv));
220
+ });
221
+ }
222
+ function demoPage(cfg) {
223
+ const real = cfg.network === "public";
224
+ return `<!doctype html>
225
+ <html lang="en"><head>
226
+ <meta charset="utf-8">
227
+ <meta name="viewport" content="width=device-width,initial-scale=1">
228
+ <title>Paydlux — watch a payment happen</title>
229
+ <style>
230
+ :root{--bg:#14181a;--ink:#eceae4;--dim:#9aa39d;--gold:#e8b04b;--ok:#5fbf8b;--bad:#d9615c;--line:#2a2f2c}
231
+ *{box-sizing:border-box}
232
+ body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.6 ui-sans-serif,system-ui,-apple-system,sans-serif}
233
+ main{max-width:760px;margin:0 auto;padding:40px 22px 80px}
234
+ h1{font-size:30px;margin:0 0 6px;letter-spacing:-.01em}
235
+ .sub{color:var(--dim);margin:0 0 26px}
236
+ .warn{border:1px solid var(--line);border-left:3px solid var(--gold);background:#1a1f21;padding:12px 14px;border-radius:8px;margin:0 0 26px;font-size:14.5px}
237
+ .steps{border:1px solid var(--line);border-radius:12px;overflow:hidden}
238
+ .step{padding:16px 18px;border-bottom:1px solid var(--line)}
239
+ .step:last-child{border-bottom:0}
240
+ .step h3{margin:0 0 4px;font-size:15px;display:flex;gap:10px;align-items:center}
241
+ .step p{margin:0;color:var(--dim);font-size:14.5px}
242
+ .n{width:22px;height:22px;border-radius:50%;border:1px solid var(--line);display:inline-flex;align-items:center;justify-content:center;font-size:12px;color:var(--dim);flex:0 0 auto}
243
+ .done .n{border-color:var(--ok);color:var(--ok)}
244
+ pre{margin:10px 0 0;padding:10px 12px;background:#0f1213;border:1px solid var(--line);border-radius:8px;font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;word-break:break-word;color:#cfd6d0}
245
+ button{font:inherit;padding:11px 18px;border-radius:9px;border:1px solid var(--gold);background:var(--gold);color:#1a1408;font-weight:600;cursor:pointer;margin:22px 0 0}
246
+ button[disabled]{opacity:.45;cursor:default}
247
+ .door{margin:30px 0 0;border:1px solid var(--line);border-left:3px solid var(--gold);background:#1a1f21;border-radius:10px;padding:18px 20px}
248
+ .door .q{margin:0 0 6px;font-weight:600}
249
+ .door .a{margin:0;color:var(--dim);font-size:15px}
250
+ .door .big{display:inline-block;margin:16px 0 10px;padding:12px 20px;border-radius:9px;background:var(--gold);color:#1a1408;font-weight:700;text-decoration:none}
251
+ .door .big:hover{filter:brightness(1.08)}
252
+ .door .fine{margin:0;color:var(--dim);font-size:13.5px}
253
+ .toplink{margin:0 0 20px;font-size:14px}
254
+ a{color:var(--gold)}
255
+ .foot{margin-top:34px;color:var(--dim);font-size:13.5px;border-top:1px solid var(--line);padding-top:18px}
256
+ code{font:13px ui-monospace,monospace;background:#0f1213;padding:1px 5px;border-radius:4px}
257
+ </style></head><body><main>
258
+ <h1>Watch a payment happen</h1>
259
+ <p class="sub">Paydlux is a payment rail for software agents. A program asks for
260
+ something, the server answers <code>402 Payment Required</code>, a human approves
261
+ on a phone, and the ledger settles it. You are about to drive that loop.</p>
262
+ <p class="toplink"><a href="https://${cfg.donateHost ?? "donate.paydlux.com"}/">or skip to paying for real →</a></p>
263
+
264
+ <div class="warn">${real
265
+ ? "This is <b>mainnet</b>. A tip is real money and cannot be undone. Every payment still needs the seller's phone."
266
+ : "This run is on the <b>Stellar testnet</b> with a practice token, so nothing here costs anything. The mechanics — 402, the human approval, the ledger proof — are the same ones used for real money."}</div>
267
+
268
+ <div class="steps">
269
+ <div class="step" id="s1"><h3><span class="n">1</span>Ask for the data</h3>
270
+ <p>Your browser sends a plain request. The seller refuses it and states its price.</p>
271
+ <pre id="o1">not run</pre></div>
272
+ <div class="step" id="s2"><h3><span class="n">2</span>Get an agent identity</h3>
273
+ <p>A throwaway signing key is made in this browser and the seller vouches for it for 30 minutes. It can request payments. It cannot pay anything.</p>
274
+ <pre id="o2">not run</pre></div>
275
+ <div class="step" id="s3"><h3><span class="n">3</span>Request the payment</h3>
276
+ <p>The browser signs the request — not the payment — and sends it. The seller parks it for a human.</p>
277
+ <pre id="o3">not run</pre></div>
278
+ <div class="step" id="s4"><h3><span class="n">4</span>A human decides</h3>
279
+ <p>The seller sees the exact payment on their phone and holds for 1.2 seconds to approve. Nothing here can skip that.</p>
280
+ <pre id="o4">not run</pre></div>
281
+ <div class="step" id="s5"><h3><span class="n">5</span>The ledger proves it</h3>
282
+ <p>Same request, now served. The transaction hash is public and verifiable without trusting this page.</p>
283
+ <pre id="o5">not run</pre></div>
284
+ </div>
285
+
286
+ <button id="go">Run the loop</button>
287
+
288
+ <div class="door">
289
+ <p class="q">That was practice. Nothing you just did cost anything.</p>
290
+ <p class="a">Your first <em>real</em> agent payment can be a donation to us —
291
+ same 402, same two signatures, same ledger proof, but the money is real and it
292
+ buys the thing this project needs: an always-on machine instead of a laptop.</p>
293
+ <a class="big" href="https://${cfg.donateHost ?? "donate.paydlux.com"}/">Pay a human-approved donation →</a>
294
+ <p class="fine">It draws on <em>your</em> vault and your phone signs it. We never hold your key,
295
+ and you can watch our own vault pay out on the public ledger.</p>
296
+ </div>
297
+
298
+ <p class="foot">Vault <code>${cfg.vault}</code> · token <code>${cfg.token}</code> ·
299
+ network <code>${cfg.network}</code> · <a href="${cfg.explorer}" rel="noopener">open the vault on the explorer</a><br>
300
+ Every payment is capped by the seller's own mandate: at most ${cfg.price} ${cfg.token} per request,
301
+ and the day's total is enforced before the request is ever accepted.</p>
302
+ </main><script src="/demo/xdr.js"></script><script src="/demo/demo.js"></script></body></html>`;
303
+ }
304
+ /**
305
+ * The visitor's side. Kept in its own file so `script-src 'self'` is a real
306
+ * restriction rather than a comment, and so the signing code is readable —
307
+ * anyone can open this and see that it signs requests, never payments.
308
+ */
309
+ function demoScript() {
310
+ return `(() => {
311
+ const ROUTE = "/demo/data";
312
+ const el = (id) => document.getElementById(id);
313
+ const say = (n, txt) => { el("o" + n).textContent = txt; el("s" + n).classList.add("done"); };
314
+ const PAY = "x-payment";
315
+
316
+ async function main() {
317
+ const btn = el("go"); btn.disabled = true;
318
+
319
+ // 1 — the 402 handshake. No payment, no key, just a normal GET.
320
+ let terms;
321
+ try {
322
+ const r = await fetch(ROUTE, { cache: "no-store" });
323
+ terms = JSON.parse(r.headers.get(PAY) || "null");
324
+ say(1, "HTTP " + r.status + "\\n" + JSON.stringify(terms, null, 2));
325
+ if (r.status !== 402) { say(1, "HTTP " + r.status + " — this showcase is not asking for payment right now"); return; }
326
+ } catch (e) { say(1, "request failed: " + e); return; }
327
+
328
+ // 2 — a signing key that never leaves this browser.
329
+ let pair;
330
+ try {
331
+ // extractable only for the PUBLIC half's raw bytes; the private half is
332
+ // never exported, and the key is dropped when the tab closes.
333
+ pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
334
+ }
335
+ catch (e) { say(2, "this browser cannot make Ed25519 keys (needs Chrome 113+, Safari 17+, Firefox 133+).\\n" + e); return; }
336
+ const raw = await crypto.subtle.exportKey("raw", pair.publicKey);
337
+ const X = window.PaydluxXdr;
338
+ if (!X) { say(2, "the address encoder failed to load"); return; }
339
+ const pub = X.encodeAccountEd25519(new Uint8Array(raw));
340
+ const enr = await (await fetch("/demo/enroll", {
341
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ pub }),
342
+ })).json();
343
+ if (!enr.ok) { say(2, "seller refused to vouch for this key: " + enr.error); return; }
344
+ say(2, "agent key (Stellar account form)\\n" + pub + "\\nvouched until " + new Date(enr.until).toLocaleTimeString());
345
+
346
+ // 3 — sign the REQUEST. The payload names the url, the price and the vault,
347
+ // so this signature is worthless for anything else.
348
+ const intent = await buildIntent(pair, terms);
349
+ const parked = await (await fetch(ROUTE, {
350
+ headers: { [PAY]: JSON.stringify({ intent }) }, cache: "no-store",
351
+ })).json();
352
+ say(3, "HTTP 202 — parked as " + (parked.handle || "?") + "\\n" + (parked.reason || ""));
353
+
354
+ // 4 — wait for a person. The page polls the seller's own status route.
355
+ say(4, "waiting for the seller's phone…");
356
+ const seen = await poll(parked.statusUrl);
357
+ say(4, "status: " + seen.status + (seen.reason ? "\\n" + seen.reason : ""));
358
+ if (seen.status !== "settled" && seen.status !== "paid") { btn.disabled = false; return; }
359
+
360
+ // 5 — same request, now paid. The seller ships the payload plus the hash.
361
+ const paid = await fetch(ROUTE, { headers: { [PAY]: JSON.stringify({ intent }) }, cache: "no-store" });
362
+ const body = await paid.json();
363
+ say(5, "HTTP " + paid.status + "\\n" + JSON.stringify(body, null, 2)
364
+ + (body.tx ? "\\n\\nverify it yourself:\\nhttps://stellar.expert/explorer/"
365
+ + (terms.network || "testnet").replace("stellar:", "") + "/transaction/" + body.tx : ""));
366
+ }
367
+
368
+ async function buildIntent(pair, t) {
369
+ const payload = [location.origin + ROUTE, t.maxAmountRequired, t.vault, Date.now()].join("|");
370
+ const sig = await crypto.subtle.sign("Ed25519", pair.privateKey, new TextEncoder().encode(payload));
371
+ return { payload, signature: btoa(String.fromCharCode(...new Uint8Array(sig))) };
372
+ }
373
+
374
+ async function poll(u) {
375
+ for (let i = 0; i < 150; i++) {
376
+ const j = await (await fetch(u, { cache: "no-store" })).json();
377
+ if (j.status && j.status !== "pending" && j.status !== "awaiting_phone") return j;
378
+ await new Promise((r) => setTimeout(r, 2000));
379
+ }
380
+ return { status: "timed out waiting for a human" };
381
+ }
382
+
383
+ el("go").addEventListener("click", main);
384
+ })();`;
385
+ }
package/dist/tunnel.d.ts CHANGED
@@ -6,6 +6,26 @@ export declare const PUBLIC_HOST = "desk.paydlux.com";
6
6
  export declare function tunnelProblem(): string | null;
7
7
  /** True if this machine can run the named tunnel. */
8
8
  export declare function tunnelReady(): boolean;
9
+ /**
10
+ * Does ~/.cloudflared/config.yml already route this hostname to something?
11
+ * A DNS record alone is not enough: cloudflared answers unmatched hostnames
12
+ * with its own error unless ingress names the host, so a new public hostname
13
+ * needs both a route and an ingress line. Reading the file beats probing the
14
+ * public URL at startup, because the URL may be cached, cold, or mid-deploy.
15
+ */
16
+ export declare function tunnelServes(hostname: string): boolean;
17
+ /**
18
+ * Is the named tunnel already running on this machine?
19
+ *
20
+ * Two `sell` processes can legitimately share one hostname set — the free
21
+ * showcase on testnet and the real donation door on mainnet are deliberately
22
+ * separate, because the demo spends the seller's money and must not touch real
23
+ * XLM. If both spawn their own cloudflared there are two runners on one tunnel:
24
+ * Cloudflare allows it, but each extra runner is a process that outlives its
25
+ * parent in a different way, and an orphaned cloudflared is a public hostname
26
+ * with nobody watching the port behind it. So the second one reuses the first.
27
+ */
28
+ export declare function tunnelRunning(): boolean;
9
29
  /**
10
30
  * Start the named tunnel and make sure it dies with us. An orphaned cloudflared
11
31
  * is a public approval desk with nobody watching it, so every exit path kills
package/dist/tunnel.js CHANGED
@@ -15,8 +15,8 @@
15
15
  // The tunnel's credentials live in ~/.cloudflared/<tunnel-id>.json. They are NOT
16
16
  // in this repo and must never be. Anyone holding that file can serve your
17
17
  // hostname. Losing it means deleting the tunnel and making a new one.
18
- import { spawn } from "node:child_process";
19
- import { existsSync, readdirSync } from "node:fs";
18
+ import { execFileSync, spawn } from "node:child_process";
19
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
20
20
  import { homedir } from "node:os";
21
21
  import { join } from "node:path";
22
22
  /**
@@ -67,6 +67,44 @@ export function tunnelProblem() {
67
67
  }
68
68
  /** True if this machine can run the named tunnel. */
69
69
  export function tunnelReady() { return tunnelProblem() === null; }
70
+ /**
71
+ * Does ~/.cloudflared/config.yml already route this hostname to something?
72
+ * A DNS record alone is not enough: cloudflared answers unmatched hostnames
73
+ * with its own error unless ingress names the host, so a new public hostname
74
+ * needs both a route and an ingress line. Reading the file beats probing the
75
+ * public URL at startup, because the URL may be cached, cold, or mid-deploy.
76
+ */
77
+ export function tunnelServes(hostname) {
78
+ const dir = join(homedir(), ".cloudflared");
79
+ let cfg = "";
80
+ try {
81
+ cfg = readFileSync(join(dir, "config.yml"), "utf8");
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ return new RegExp(`hostname:\\s*${hostname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m").test(cfg);
87
+ }
88
+ /**
89
+ * Is the named tunnel already running on this machine?
90
+ *
91
+ * Two `sell` processes can legitimately share one hostname set — the free
92
+ * showcase on testnet and the real donation door on mainnet are deliberately
93
+ * separate, because the demo spends the seller's money and must not touch real
94
+ * XLM. If both spawn their own cloudflared there are two runners on one tunnel:
95
+ * Cloudflare allows it, but each extra runner is a process that outlives its
96
+ * parent in a different way, and an orphaned cloudflared is a public hostname
97
+ * with nobody watching the port behind it. So the second one reuses the first.
98
+ */
99
+ export function tunnelRunning() {
100
+ try {
101
+ const out = execFileSync("pgrep", ["-f", `cloudflared tunnel run ${TUNNEL_NAME}`], { encoding: "utf8" }).trim();
102
+ return out.length > 0;
103
+ }
104
+ catch {
105
+ return false;
106
+ } // pgrep exits 1 when nothing matches
107
+ }
70
108
  export function runTunnel() {
71
109
  const bin = cloudflaredPath();
72
110
  if (!bin)
package/dist/vault.d.ts CHANGED
@@ -46,6 +46,33 @@ export type VaultSigners = {
46
46
  /** the agent key, handed to the software doing the buying */
47
47
  agent: string;
48
48
  };
49
+ /**
50
+ * Does a Horizon balance/payment record name this exact asset?
51
+ *
52
+ * The old code hard-coded "not native" in two places, which quietly made a
53
+ * native-XLM vault blind to its own money: balance() reported 0 and paidToday()
54
+ * reported 0 spent, so the daily budget never moved no matter how much XLM left
55
+ * the vault. Asset identity has to be ONE comparison that handles both cases,
56
+ * because a budget that reads as unspent is worse than no budget at all.
57
+ *
58
+ * Exported as a pure function so the rule is testable without funding an
59
+ * account: this decides whether money counts against a spending mandate.
60
+ */
61
+ export declare function assetMatches(token: Asset, rec: any): boolean;
62
+ /**
63
+ * Turn a token name from a config file into an Asset.
64
+ *
65
+ * "XLM" is not a token. It is the ledger's own currency, and it behaves
66
+ * differently in one way that breaks code written for tokens: an account does
67
+ * not need a trustline to receive it. Passing Asset("XLM", someIssuer) would
68
+ * build a *fake* asset that merely happens to be called XLM — and the ledger
69
+ * would then demand a trustline for an asset that cannot have one, while
70
+ * assetMatches() would refuse to count the real XLM that actually arrived.
71
+ *
72
+ * So the name is resolved here, in one place, and every caller gets either a
73
+ * real native asset or a real code+issuer pair.
74
+ */
75
+ export declare function parseToken(code: string, issuer?: string): Asset;
49
76
  export declare class StellarVault {
50
77
  readonly horizon: Horizon.Server;
51
78
  readonly network: Network;
@@ -90,8 +117,8 @@ export declare class StellarVault {
90
117
  trustUsdc(principal: Keypair, approver: Keypair, limit?: string): Promise<Horizon.HorizonApi.SubmitTransactionResponse>;
91
118
  /** Point this vault at USDC instead of a custom token. */
92
119
  useUsdc(): this;
93
- /** Re-attach to an existing vault by account id. */
94
- static load(address: string, tokenCode: string, issuer: string, signers: VaultSigners, network: Network): Promise<StellarVault>;
120
+ /** Re-attach to an existing vault by account id. `issuer` is omitted for XLM. */
121
+ static load(address: string, tokenCode: string, issuer: string | undefined, signers: VaultSigners, network: Network): Promise<StellarVault>;
95
122
  /** The seller must opt in to holding our token before we can pay them. */
96
123
  trustToken(seller: string, limit: string | undefined, sellerKeypair: Keypair): Promise<void>;
97
124
  /** Move tokens into the vault. Only the vault's signers can move them out. */
@@ -102,6 +129,19 @@ export declare class StellarVault {
102
129
  * succeeded" does NOT mean "money moved just now". Sellers must check.
103
130
  */
104
131
  txExists(hash: string): Promise<boolean>;
132
+ /**
133
+ * Does a Horizon balance/payment record name *this* vault's token?
134
+ *
135
+ * The old code hard-coded "not native" in two places, which quietly made a
136
+ * native-XLM vault blind to its own money: balance() reported 0 and
137
+ * paidToday() reported 0 spent, so the daily budget never moved no matter how
138
+ * much XLM left the vault. Asset identity has to be one comparison that
139
+ * handles both cases, because a budget that reads as unspent is worse than no
140
+ * budget at all.
141
+ */
142
+ private isOurAsset;
143
+ /** True when this vault settles in XLM itself rather than a token. */
144
+ get isNative(): boolean;
105
145
  balance(account?: string): Promise<Amount>;
106
146
  /**
107
147
  * What this vault already paid `payee` in the current UTC day, according to