@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,348 @@
1
+ // Paydlux as the SELLER: accept a payment drawn on someone else's vault.
2
+ //
3
+ // Everything built so far pays OUT of our vault. This is the other door — a
4
+ // stranger's agent pays US, from THEIR vault, approved on THEIR phone. Our
5
+ // thumb is nowhere in it, and it cannot be: we hold no key that can move their
6
+ // money, and they hold none that can move ours.
7
+ //
8
+ // The security question flips. When we pay, we sign, so we already know. When
9
+ // we receive, a stranger CLAIMS they paid, and the claim is attacker-controlled
10
+ // text. So the claim is worth nothing until this file has re-read the ledger and
11
+ // matched every field against the order we issued. The ledger is the only
12
+ // witness; the buyer is a witness against themselves.
13
+ //
14
+ // Why this is safe to expose publicly: verifying an incoming payment needs a
15
+ // public ledger read and nothing else. No custody, no key, no counterparty
16
+ // risk. A wrong answer costs us content we did not get paid for, and the checks
17
+ // below are what stop that.
18
+ import { createHash, randomBytes } from "node:crypto";
19
+ import { createServer } from "node:http";
20
+ const HORIZON = {
21
+ testnet: "https://horizon-testnet.stellar.org",
22
+ public: "https://horizon.stellar.org",
23
+ };
24
+ const defaultFetch = async (url) => {
25
+ const r = await fetch(url).catch(() => null);
26
+ if (!r || !r.ok)
27
+ return r ? { status: r.status, body: null } : null;
28
+ return { status: r.status, body: await r.json().catch(() => null) };
29
+ };
30
+ /**
31
+ * Has this transaction really paid us, in full, for this order?
32
+ *
33
+ * Every field below is a separate forgery attempt, so each is checked against
34
+ * the ledger's own answer rather than the caller's:
35
+ *
36
+ * hash unknown / tx failed → nothing happened
37
+ * pays a different account → not our money
38
+ * underpays → partial payment is not payment
39
+ * a fake token called "XLM" → anyone can name a token anything; only
40
+ * native XLM, or an exact code+issuer pair,
41
+ * is the asset we priced the order in
42
+ * wrong or missing memo → cannot be tied to this order, so one
43
+ * real payment could be reused for many
44
+ * pays us for someone else's order→ same reason, from the other side
45
+ * ancient payment → a payment from last year is not this one
46
+ * from a vault we never issued → the order binds its buyer at 402 time
47
+ *
48
+ * Amounts are compared as numbers because Stellar normalises to 7 decimals.
49
+ */
50
+ export async function verifyIncomingPayment(hash, want, fetchJson = defaultFetch) {
51
+ if (!/^[0-9a-f]{64}$/.test(hash ?? ""))
52
+ return { ok: false, reason: "not a transaction hash" };
53
+ const network = want.network ?? "public";
54
+ const base = HORIZON[network];
55
+ const txRes = await fetchJson(`${base}/transactions/${hash}`);
56
+ if (!txRes)
57
+ return { ok: false, reason: "the ledger could not be reached" };
58
+ if (txRes.status === 404)
59
+ return { ok: false, reason: "no such transaction on this network" };
60
+ if (!txRes.body)
61
+ return { ok: false, reason: "the ledger returned an unreadable answer" };
62
+ const tx = txRes.body;
63
+ // A transaction can exist and still have failed. Its hash is real and its
64
+ // money never moved.
65
+ if (tx.successful !== true)
66
+ return { ok: false, reason: "the transaction failed on the ledger" };
67
+ // The memo is on the transaction, so it is bound to the payment itself and
68
+ // cannot be chosen later by whoever submits it.
69
+ if (tx.memo_type !== "text" || tx.memo !== want.memo) {
70
+ return { ok: false, reason: "the payment does not name this order" };
71
+ }
72
+ const age = want.maxAgeSeconds ?? 3600;
73
+ const at = Date.parse(tx.created_at ?? "");
74
+ if (!Number.isFinite(at))
75
+ return { ok: false, reason: "the payment has no readable time" };
76
+ if (Date.now() - at > age * 1000) {
77
+ return { ok: false, reason: `the payment is older than ${age} seconds` };
78
+ }
79
+ const opsRes = await fetchJson(`${base}/transactions/${hash}/operations?limit=100`);
80
+ if (!opsRes || !opsRes.body)
81
+ return { ok: false, reason: "the ledger operations could not be read" };
82
+ const ops = opsRes.body?._embedded?.records ?? [];
83
+ // Exactly one payment to us, at the right price, in the right asset. More
84
+ // than one would let a bundle hide a short payment inside it.
85
+ const ours = ops.filter((o) => o.type === "payment" && o.to === want.payee);
86
+ if (ours.length === 0)
87
+ return { ok: false, reason: "the transaction does not pay this account" };
88
+ if (ours.length > 1)
89
+ return { ok: false, reason: "the transaction pays this account more than once" };
90
+ const op = ours[0];
91
+ if (Number(op.amount) < Number(want.amount)) {
92
+ return { ok: false, reason: `underpaid: sent ${op.amount}, the order is ${want.amount}` };
93
+ }
94
+ if (want.asset.issuer) {
95
+ if (op.asset_type === "native")
96
+ return { ok: false, reason: "the order is priced in a token, not XLM" };
97
+ if (op.asset_code !== want.asset.code || op.asset_issuer !== want.asset.issuer) {
98
+ return { ok: false, reason: `paid in ${op.asset_code}, not the priced asset` };
99
+ }
100
+ }
101
+ else {
102
+ // Native XLM only. A token someone else issued and *named* XLM is not XLM:
103
+ // asset_type is the only field that distinguishes them.
104
+ if (op.asset_type !== "native") {
105
+ return { ok: false, reason: `paid in a token called ${op.asset_code}, not native XLM` };
106
+ }
107
+ }
108
+ if (want.from && op.from !== want.from) {
109
+ return { ok: false, reason: "the payment did not come from the vault this order was issued to" };
110
+ }
111
+ return {
112
+ ok: true,
113
+ tx: hash,
114
+ amount: String(op.amount),
115
+ paidAt: tx.created_at,
116
+ from: op.from,
117
+ ledger: Number(tx.ledger),
118
+ };
119
+ }
120
+ /**
121
+ * Orders live in memory on purpose. An unpaid order is worth nothing to anyone,
122
+ * and a redeemed one is recorded on the ledger forever — the durable record is
123
+ * the payment, not our book. A restart loses pending orders, which is correct:
124
+ * the buyer just asks again.
125
+ */
126
+ export class OrderBook {
127
+ ttlSeconds;
128
+ orders = new Map();
129
+ byMemo = new Map();
130
+ /** tx hashes already spent, so one payment can never buy two orders */
131
+ spent = new Set();
132
+ constructor(ttlSeconds = 3600) {
133
+ this.ttlSeconds = ttlSeconds;
134
+ }
135
+ issue(amount, asset, buyer) {
136
+ this.sweep();
137
+ const id = "ord_" + randomBytes(6).toString("hex");
138
+ // The memo is what ties a ledger payment to an order, so it has to be
139
+ // unguessable and unique. A short or sequential memo would let a stranger
140
+ // pay for an order they never asked for and claim someone else's.
141
+ const memo = id;
142
+ const o = { id, memo, amount, asset, buyer, createdAt: Date.now() };
143
+ this.orders.set(id, o);
144
+ this.byMemo.set(memo, id);
145
+ return o;
146
+ }
147
+ get(id) { return this.orders.get(id); }
148
+ isSpent(tx) { return this.spent.has(tx); }
149
+ markSpent(tx, id) {
150
+ this.spent.add(tx);
151
+ const o = this.orders.get(id);
152
+ if (o)
153
+ o.redeemedTx = tx;
154
+ }
155
+ get size() { return this.orders.size; }
156
+ sweep() {
157
+ const cut = Date.now() - this.ttlSeconds * 1000;
158
+ for (const [id, o] of this.orders)
159
+ if (o.createdAt < cut) {
160
+ this.orders.delete(id);
161
+ this.byMemo.delete(o.memo);
162
+ }
163
+ }
164
+ }
165
+ /**
166
+ * A public seller endpoint. Two routes only:
167
+ *
168
+ * GET /donate → 402 with the price and how to pay
169
+ * POST /donate → same, and issues an order to name in the memo
170
+ * GET /donate?tx=HASH → verify on the ledger, then deliver
171
+ *
172
+ * Nothing else is reachable: no queue, no keys, no admin. The worst this port
173
+ * can do is hand out content we were actually paid for.
174
+ */
175
+ export function startReceive(cfg) {
176
+ const book = new OrderBook(3600);
177
+ const explorer = cfg.explorer ?? `https://stellar.expert/explorer/${cfg.network === "public" ? "account" : "testnet/account"}`;
178
+ const terms = (order) => ({
179
+ scheme: "stellar-exact",
180
+ network: `stellar:${cfg.network}`,
181
+ maxAmountRequired: cfg.price,
182
+ payTo: cfg.payee,
183
+ token: cfg.asset.issuer ? cfg.asset : { code: "XLM", issuer: null },
184
+ mode: "buyer-escrow",
185
+ memo: order?.memo,
186
+ order: order?.id,
187
+ buyerVault: order?.buyer,
188
+ maxTimeoutSeconds: 3600,
189
+ version: "1",
190
+ note: "pay from your own vault with your own mandate; we verify on the ledger",
191
+ });
192
+ const server = createServer(async (req, res) => {
193
+ const url = new URL(req.url ?? "/", `http://${cfg.hostname}`);
194
+ const p = url.pathname;
195
+ const send = (code, body, extra = {}) => {
196
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8", ...extra });
197
+ res.end(JSON.stringify(body));
198
+ };
199
+ // Host pinning, same rule as the desk and the showcase: a public hostname
200
+ // must not be reachable by IP or by another name pointed at this port.
201
+ if ((req.headers.host ?? "").split(":")[0] !== cfg.hostname) {
202
+ send(403, { error: "wrong_host" });
203
+ return;
204
+ }
205
+ if (p === "/donate.js") {
206
+ if (!cfg.script) {
207
+ send(404, { error: "not_found" });
208
+ return;
209
+ }
210
+ res.writeHead(200, {
211
+ "content-type": "text/javascript; charset=utf-8",
212
+ "x-content-type-options": "nosniff", "cache-control": "no-store",
213
+ });
214
+ res.end(cfg.script());
215
+ return;
216
+ }
217
+ if (p === "/qr.svg") {
218
+ if (!cfg.qr) {
219
+ send(404, { error: "not_found" });
220
+ return;
221
+ }
222
+ res.writeHead(200, {
223
+ "content-type": "image/svg+xml; charset=utf-8",
224
+ "x-content-type-options": "nosniff", "cache-control": "public, max-age=3600",
225
+ });
226
+ res.end(cfg.qr());
227
+ return;
228
+ }
229
+ if (p === "/" || p === "/index.html") {
230
+ // A browser, not an agent. Served with the same strict headers as the
231
+ // showcase: no inline script, nothing interpolated from the request.
232
+ if (!cfg.page) {
233
+ send(404, { error: "not_found" });
234
+ return;
235
+ }
236
+ res.writeHead(200, {
237
+ "content-type": "text/html; charset=utf-8",
238
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
239
+ "img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
240
+ "form-action 'none'; frame-ancestors 'none'",
241
+ "x-content-type-options": "nosniff",
242
+ "referrer-policy": "no-referrer",
243
+ "cache-control": "no-store",
244
+ });
245
+ res.end(cfg.page());
246
+ return;
247
+ }
248
+ if (p === "/donate/terms") {
249
+ send(200, terms());
250
+ return;
251
+ }
252
+ if (p !== "/donate") {
253
+ send(404, { error: "not_found" });
254
+ return;
255
+ }
256
+ const tx = url.searchParams.get("tx");
257
+ const asked = url.searchParams.get("order");
258
+ // Status poll: an order id, no tx. This must NOT issue a new order — a
259
+ // buyer polling for their own order would otherwise mint a fresh unpaid
260
+ // order every few seconds and never see the payment they already made.
261
+ if (!tx && asked) {
262
+ const order = book.get(asked);
263
+ if (!order) {
264
+ send(404, { error: "unknown_order", hint: "the order expired; ask for a new one" });
265
+ return;
266
+ }
267
+ if (order.redeemedTx) {
268
+ send(200, { paid: true, tx: order.redeemedTx, order: order.id });
269
+ return;
270
+ }
271
+ // Cheap, honest answer: we have not seen a payment for this order yet.
272
+ // The real check happens when the buyer supplies the tx hash.
273
+ send(202, { paid: false, order: order.id, memo: order.memo, reason: "no payment seen for this order yet" });
274
+ return;
275
+ }
276
+ if (tx) {
277
+ const id = asked;
278
+ const order = id ? book.get(id) : undefined;
279
+ if (!order) {
280
+ send(400, { error: "unknown_order", hint: "POST /donate first, then pay, then retry with ?tx=" });
281
+ return;
282
+ }
283
+ if (order.redeemedTx) {
284
+ send(409, { error: "order_already_paid", tx: order.redeemedTx });
285
+ return;
286
+ }
287
+ // One payment, one order. Without this a stranger who learns a real hash
288
+ // could spend it against every order that names the same amount.
289
+ if (book.isSpent(tx)) {
290
+ send(409, { error: "payment_already_used" });
291
+ return;
292
+ }
293
+ const v = await verifyIncomingPayment(tx, {
294
+ payee: cfg.payee, amount: cfg.price, asset: cfg.asset,
295
+ memo: order.memo, from: order.buyer, network: cfg.network,
296
+ }, cfg.fetchJson);
297
+ if (!v.ok) {
298
+ // Still unpaid: the buyer retries, that is the protocol. 202 keeps the
299
+ // order alive rather than burning it on a bad guess.
300
+ send(202, { error: "not_paid_yet", reason: v.reason, order: order.id, memo: order.memo });
301
+ return;
302
+ }
303
+ book.markSpent(tx, order.id);
304
+ const out = cfg.deliver(order);
305
+ send(200, {
306
+ data: out.content,
307
+ receipt: out.receipt,
308
+ proof: {
309
+ tx: v.tx, amount: v.amount, paidAt: v.paidAt, from: v.from, ledger: v.ledger,
310
+ explorer: `${explorer}/${cfg.payee}`,
311
+ verify: `curl ${HORIZON[cfg.network]}/transactions/${v.tx}`,
312
+ },
313
+ }, { "x-payment-response": JSON.stringify({ tx: v.tx, ledger: v.ledger }) });
314
+ return;
315
+ }
316
+ // No tx yet: issue an order and answer 402. The memo in the terms is what
317
+ // the buyer's mandate must carry, and it is what we will match later.
318
+ const body = await readBody(req);
319
+ const buyer = body?.vault ? String(body.vault) : undefined;
320
+ const order = book.issue(cfg.price, cfg.asset, buyer);
321
+ send(402, { error: "payment_required", ...terms(order) }, { "x-payment": JSON.stringify(terms(order)) });
322
+ });
323
+ return {
324
+ server, book,
325
+ /** Loopback only: the public path is the named tunnel, never this port. */
326
+ listen: (port) => new Promise((res) => server.listen(port, "127.0.0.1", () => res())),
327
+ };
328
+ }
329
+ async function readBody(req) {
330
+ if (req.method !== "POST")
331
+ return null;
332
+ let raw = "";
333
+ for await (const c of req) {
334
+ raw += c;
335
+ if (raw.length > 64_000)
336
+ break;
337
+ }
338
+ try {
339
+ return raw ? JSON.parse(raw) : null;
340
+ }
341
+ catch {
342
+ return null;
343
+ }
344
+ }
345
+ /** Stable, non-secret id for an order, used in logs. */
346
+ export function orderTag(id) {
347
+ return createHash("sha256").update(id).digest("hex").slice(0, 8);
348
+ }
@@ -0,0 +1,54 @@
1
+ import { type Server } from "node:http";
2
+ import type { Gateway } from "./gateway";
3
+ export declare const DEMO_HOST = "demo.paydlux.com";
4
+ export declare const DEMO_ROUTE = "/demo/data";
5
+ /**
6
+ * A short-lived trust decision: "this public key may place payment requests
7
+ * against my gateway until T". It is not a payment authority — a request still
8
+ * needs a human and still hits the spending mandate. Without it a browser
9
+ * cannot produce a valid intent at all, and shipping a fixed agent key inside
10
+ * public JavaScript would put a private key on every visitor's disk.
11
+ */
12
+ export declare class AgentKeyStore {
13
+ private max;
14
+ private ttl;
15
+ private keys;
16
+ constructor(max?: number, ttl?: number);
17
+ private prune;
18
+ /** Register a key. Returns null when the request is refused, with a reason. */
19
+ enroll(pub: string, ip: string): {
20
+ until: number;
21
+ } | null;
22
+ /** Public keys the gateway should accept right now. Feed this to agentKeys. */
23
+ live(): string[];
24
+ get size(): number;
25
+ }
26
+ export interface DemoConfig {
27
+ host?: string;
28
+ port: number;
29
+ price: string;
30
+ network: string;
31
+ vault: string;
32
+ explorer: string;
33
+ token: string;
34
+ /** The gateway that owns the routes, the desk and the mandate. */
35
+ gateway: Gateway;
36
+ /**
37
+ * Where a visitor goes to pay for real. Defaults to the public donation door.
38
+ * The showcase is deliberately free — a stranger should not have to fund a
39
+ * wallet to see the loop — but "free forever, no door" is how demos end at
40
+ * zero revenue. One link, one sentence, no popup.
41
+ */
42
+ donateHost?: string;
43
+ /** Called whenever a key is enrolled, so the gateway's accepted set follows. */
44
+ onKeys?: (pubs: string[]) => void;
45
+ }
46
+ /**
47
+ * Serve the showcase in front of a running gateway.
48
+ *
49
+ * The Host pin is not decoration. This port is reachable from the tunnel and
50
+ * from loopback; without the pin, anything that could reach 127.0.0.1 could
51
+ * also ask this server to hand out agent keys. Pinning means the only path in
52
+ * is the hostname Cloudflare routes here.
53
+ */
54
+ export declare function startDemo(cfg: DemoConfig): Promise<Server>;