@paydlux/agent 0.1.5 → 0.1.7
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 +56 -0
- package/dist/desk.js +70 -8
- package/dist/gateway.d.ts +13 -0
- package/dist/gateway.js +8 -2
- package/dist/portal.d.ts +5 -0
- package/dist/portal.js +29 -49
- package/package.json +4 -1
- package/portal-app/portal.css +22 -0
- package/portal-app/portal.js +72 -0
package/README.md
CHANGED
|
@@ -114,6 +114,62 @@ npm install @paydlux/agent
|
|
|
114
114
|
import { StellarVault, Gateway, PaydluxAgent } from "@paydlux/agent";
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
+
### Seller setup, in full (the four things that bite)
|
|
118
|
+
|
|
119
|
+
A stranger following only the previous version of this README hit four walls.
|
|
120
|
+
All four are in the code; here they are up front.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { Keypair } from "@stellar/stellar-sdk";
|
|
124
|
+
import { StellarVault, Gateway } from "@paydlux/agent";
|
|
125
|
+
|
|
126
|
+
const issuer = Keypair.random(), principal = Keypair.random();
|
|
127
|
+
const approver = Keypair.random(), agent = Keypair.random();
|
|
128
|
+
const payee = Keypair.random();
|
|
129
|
+
|
|
130
|
+
await StellarVault.fundTestnet(issuer); // testnet only
|
|
131
|
+
await StellarVault.fundTestnet(payee);
|
|
132
|
+
const vault = await StellarVault.create({
|
|
133
|
+
network: "testnet", issuer, principal, approver, agent, tokenCode: "USDC",
|
|
134
|
+
});
|
|
135
|
+
await vault.trustToken(payee.publicKey(), "1000", payee); // payee must trust the token
|
|
136
|
+
await vault.deposit("50", issuer); // vault must hold the money
|
|
137
|
+
|
|
138
|
+
const gw = new Gateway({
|
|
139
|
+
vault, network: "testnet", payee: payee.publicKey(),
|
|
140
|
+
routes: [{ uri: "/api/data", price: "1.00", mode: "approval" }],
|
|
141
|
+
principal: principal.secret(),
|
|
142
|
+
approver: approver.secret(), // omit this + set `split: true` to approve on a phone
|
|
143
|
+
agentKeys: [agent.publicKey()], // (1) without it, no agent can park a payment
|
|
144
|
+
deskPort: 4022,
|
|
145
|
+
portalKey: newPortalKey(), // (2) required before the desk is public
|
|
146
|
+
deskUrl: "https://desk.example.com", // (3) what the buyer is told, not loopback
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await gw.listen(8080, (uri) => myPayload(uri));
|
|
150
|
+
await gw.desk.listen(4022); // (4) gw.listen() does NOT start the desk
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
1. **`agentKeys`** — an intent is only honoured if signed by a listed key. Empty
|
|
154
|
+
list means nothing can request approval, and the failure looks like a plain
|
|
155
|
+
402 with no explanation.
|
|
156
|
+
2. **`portalKey`** — the approval page has no login. The key *is* the
|
|
157
|
+
authentication, so it is a 22-character secret in the link. A desk configured
|
|
158
|
+
with a public hostname refuses to start without one.
|
|
159
|
+
3. **`deskUrl`** — the 202 the buyer gets says where a human approves. It
|
|
160
|
+
defaults to `http://127.0.0.1:4022`, which is only right when buyer and
|
|
161
|
+
seller share a machine.
|
|
162
|
+
4. **`gw.desk.listen(port)`** — the gateway and the desk are two HTTP servers.
|
|
163
|
+
Starting the gateway alone leaves the queue unreachable.
|
|
164
|
+
|
|
165
|
+
The buyer side needs the agent **secret**, not the vault's signer list:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
const agent = new PaydluxAgent({ agentKey: process.env.AGENT_KEY! });
|
|
169
|
+
const res = await agent.get("https://seller.example.com/api/data", { mandate });
|
|
170
|
+
if (!res.ok && res.handle) console.log("parked, awaiting a human at", res.desk);
|
|
171
|
+
```
|
|
172
|
+
|
|
117
173
|
`npm run build` produces `dist/`. The package depends only on
|
|
118
174
|
`@stellar/stellar-sdk` — no transitive `toml`, so `npm audit` is clean.
|
|
119
175
|
|
package/dist/desk.js
CHANGED
|
@@ -23,25 +23,34 @@ import { createApprovalChallenge, submitApproved, challengeQrPayload, } from "./
|
|
|
23
23
|
* The approver app is plain static files under phone-app/. Read on each request
|
|
24
24
|
* rather than bundled, so the desk and the app can never drift apart.
|
|
25
25
|
*/
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
26
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const APP_DIR = join(HERE, "..", "phone-app");
|
|
28
|
+
const PORTAL_DIR = join(HERE, "..", "portal-app");
|
|
29
|
+
const staticCache = new Map();
|
|
30
|
+
/**
|
|
31
|
+
* Static files are read on each process start rather than bundled, so the desk
|
|
32
|
+
* and its front ends can never drift apart. The name is stripped to [a-z.]
|
|
33
|
+
* before it touches a path, so a request can't walk out of the directory.
|
|
34
|
+
*/
|
|
35
|
+
function readStatic(dir, name, label) {
|
|
29
36
|
const safe = name.replace(/[^a-z.]/g, "");
|
|
30
|
-
const hit =
|
|
37
|
+
const hit = staticCache.get(dir + "/" + safe);
|
|
31
38
|
if (hit)
|
|
32
39
|
return hit;
|
|
33
40
|
let text;
|
|
34
41
|
try {
|
|
35
|
-
text = readFileSync(join(
|
|
42
|
+
text = readFileSync(join(dir, safe), "utf8");
|
|
36
43
|
}
|
|
37
44
|
catch {
|
|
38
|
-
// A package install that shipped without
|
|
45
|
+
// A package install that shipped without the folder should say so instead of
|
|
39
46
|
// throwing out of the request handler.
|
|
40
|
-
throw new Error("
|
|
47
|
+
throw new Error(label + " is missing from this install (expected " + join(dir, safe) + ")");
|
|
41
48
|
}
|
|
42
|
-
|
|
49
|
+
staticCache.set(dir + "/" + safe, text);
|
|
43
50
|
return text;
|
|
44
51
|
}
|
|
52
|
+
const readAppFile = (name) => readStatic(APP_DIR, name, "approver app");
|
|
53
|
+
const readPortalFile = (name) => readStatic(PORTAL_DIR, name, "portal app");
|
|
45
54
|
/** A portal link is a capability: whoever holds it can approve. So it is long,
|
|
46
55
|
* random, and compared without leaking timing. */
|
|
47
56
|
export function newPortalKey() { return randomBytes(16).toString("base64url"); }
|
|
@@ -280,6 +289,21 @@ export class ApprovalDesk {
|
|
|
280
289
|
// again for this host.
|
|
281
290
|
res.setHeader("strict-transport-security", "max-age=31536000; includeSubDomains");
|
|
282
291
|
}
|
|
292
|
+
// Security headers on every response, including the 403. These are set
|
|
293
|
+
// before routing so no branch can forget them.
|
|
294
|
+
//
|
|
295
|
+
// default-src 'self' plus script-src 'self' is the load-bearing one: the
|
|
296
|
+
// portal renders text the buyer's agent chose (its request path and body),
|
|
297
|
+
// so if an escaping bug ever let markup through, CSP still stops it from
|
|
298
|
+
// running or phoning home. Escaping is the fix; CSP is the net under it.
|
|
299
|
+
// frame-ancestors stops another site framing the Approve button, and
|
|
300
|
+
// no-referrer keeps the portal key out of any third-party Referer header.
|
|
301
|
+
res.setHeader("content-security-policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; " +
|
|
302
|
+
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'");
|
|
303
|
+
res.setHeader("x-frame-options", "DENY");
|
|
304
|
+
res.setHeader("referrer-policy", "no-referrer");
|
|
305
|
+
res.setHeader("x-content-type-options", "nosniff");
|
|
306
|
+
res.setHeader("cache-control", "no-store");
|
|
283
307
|
// Liveness probe, before the portal-key gate: it proves the desk and its
|
|
284
308
|
// TLS path are up without revealing anything. No data, safe to poll.
|
|
285
309
|
if (url.pathname === "/healthz") {
|
|
@@ -317,6 +341,18 @@ export class ApprovalDesk {
|
|
|
317
341
|
res.end(portalPage(this.list(), this.cfg.vault.address, this.cfg.vault.network, qs));
|
|
318
342
|
return;
|
|
319
343
|
}
|
|
344
|
+
// The portal's own css and js. Served as separate files, never inline,
|
|
345
|
+
// so script-src 'self' and style-src 'self' actually hold on the
|
|
346
|
+
// approval page and an injected string can't become executable script.
|
|
347
|
+
if (url.pathname === "/portal.js" || url.pathname === "/portal.css") {
|
|
348
|
+
const file = url.pathname.slice(1);
|
|
349
|
+
res.writeHead(200, {
|
|
350
|
+
"content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
|
|
351
|
+
"x-content-type-options": "nosniff",
|
|
352
|
+
});
|
|
353
|
+
res.end(readPortalFile(file));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
320
356
|
if (url.pathname === "/list")
|
|
321
357
|
return send(200, this.list());
|
|
322
358
|
// The approver app. Served from the desk so the human opens one link on
|
|
@@ -381,6 +417,32 @@ export class ApprovalDesk {
|
|
|
381
417
|
}
|
|
382
418
|
const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
|
|
383
419
|
if (m && req.method === "POST") {
|
|
420
|
+
// These routes move money, so they must not be reachable from another
|
|
421
|
+
// page. A cross-origin fetch POST is a CORS *simple request*: no
|
|
422
|
+
// preflight, so the attacker's page never sees the reply but the
|
|
423
|
+
// approval still happens. Verified end to end: a page on another port
|
|
424
|
+
// settled a parked payment and paid the seller. Browsers always send
|
|
425
|
+
// Origin on POST and Sec-Fetch-Site on every request, so checking both
|
|
426
|
+
// closes that path while leaving local automation (curl, tests, the
|
|
427
|
+
// phone app on its own origin) alone.
|
|
428
|
+
const origin = String(req.headers.origin ?? "");
|
|
429
|
+
if (origin) {
|
|
430
|
+
const here = String(req.headers.host ?? "").toLowerCase();
|
|
431
|
+
let oh;
|
|
432
|
+
try {
|
|
433
|
+
oh = new URL(origin).host.toLowerCase();
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
oh = "";
|
|
437
|
+
}
|
|
438
|
+
if (oh !== here) {
|
|
439
|
+
return send(403, { error: "cross_origin", reason: `this desk only accepts actions from itself, not from ${origin}` });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const site = String(req.headers["sec-fetch-site"] ?? "").toLowerCase();
|
|
443
|
+
if (site && site !== "same-origin" && site !== "none") {
|
|
444
|
+
return send(403, { error: "cross_origin", reason: `this desk only accepts same-site actions (got ${site})` });
|
|
445
|
+
}
|
|
384
446
|
if (m[1] === "signed") {
|
|
385
447
|
const body = await readBody(req);
|
|
386
448
|
const { signedXdr } = JSON.parse(body || "{}");
|
package/dist/gateway.d.ts
CHANGED
|
@@ -32,6 +32,19 @@ export type GatewayConfig = {
|
|
|
32
32
|
portalKey?: string;
|
|
33
33
|
/** hostname the desk is publicly served on; pins the Host header. See DeskConfig. */
|
|
34
34
|
publicHost?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Agent public keys allowed to park a payment for approval. An intent is only
|
|
37
|
+
* honoured if it is signed by one of these, so an unlisted key gets a plain
|
|
38
|
+
* 402 instead of a queue entry. Leave empty and nothing can request approval.
|
|
39
|
+
*/
|
|
40
|
+
agentKeys?: string[];
|
|
41
|
+
/**
|
|
42
|
+
* Where a human approves, as the BUYER should see it. Set this to the public
|
|
43
|
+
* desk URL (e.g. "https://desk.paydlux.com") when the desk is reachable there;
|
|
44
|
+
* it is returned in the 202 so the buyer knows where to go. Defaults to the
|
|
45
|
+
* loopback address, which is only useful on one machine.
|
|
46
|
+
*/
|
|
47
|
+
deskUrl?: string;
|
|
35
48
|
};
|
|
36
49
|
export type PaymentProof = {
|
|
37
50
|
/** pre-authorised envelope, base64 XDR, as issued by the vault */
|
package/dist/gateway.js
CHANGED
|
@@ -26,6 +26,7 @@ export class Gateway {
|
|
|
26
26
|
cfg;
|
|
27
27
|
constructor(cfg) {
|
|
28
28
|
this.cfg = cfg;
|
|
29
|
+
this.agentKeys = cfg.agentKeys ?? [];
|
|
29
30
|
for (const r of cfg.routes)
|
|
30
31
|
this.routes.set(r.uri, r);
|
|
31
32
|
this.desk = new ApprovalDesk({
|
|
@@ -79,7 +80,7 @@ export class Gateway {
|
|
|
79
80
|
return false;
|
|
80
81
|
}
|
|
81
82
|
/** Keys allowed to place approval requests. */
|
|
82
|
-
agentKeys
|
|
83
|
+
agentKeys;
|
|
83
84
|
requirements(uri) {
|
|
84
85
|
const r = this.routes.get(uri);
|
|
85
86
|
return {
|
|
@@ -191,7 +192,12 @@ export class Gateway {
|
|
|
191
192
|
}
|
|
192
193
|
const handle = this.desk.enqueue(uri, r.price, body, getContent(uri));
|
|
193
194
|
res.writeHead(202, { "content-type": "application/json", "x-payment-hold": handle });
|
|
194
|
-
|
|
195
|
+
// The buyer needs to know where a human approves. Loopback is only
|
|
196
|
+
// right when both sides are on one machine; anything real sets deskUrl
|
|
197
|
+
// (or publicHost) so this points at the https desk.
|
|
198
|
+
const deskUrl = this.cfg.deskUrl
|
|
199
|
+
?? (this.cfg.publicHost ? `https://${this.cfg.publicHost}` : `http://127.0.0.1:${this.cfg.deskPort ?? 4022}`);
|
|
200
|
+
res.end(JSON.stringify({ status: "awaiting_approval", handle, desk: deskUrl }));
|
|
195
201
|
return;
|
|
196
202
|
}
|
|
197
203
|
try {
|
package/dist/portal.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import type { DeskRecord } from "./desk.ts";
|
|
3
|
+
/**
|
|
4
|
+
* HTML escaping. Not optional: the request path and body are attacker-chosen
|
|
5
|
+
* text that lands inside the one page that can authorise a payment.
|
|
6
|
+
*/
|
|
7
|
+
export declare function esc(v: unknown): string;
|
|
3
8
|
export declare function portalPage(records: DeskRecord[], vault: string, network: string, qs?: string): string;
|
|
4
9
|
export declare function servePortal(req: IncomingMessage, res: ServerResponse, html: string): void;
|
package/dist/portal.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML escaping. Not optional: the request path and body are attacker-chosen
|
|
3
|
+
* text that lands inside the one page that can authorise a payment.
|
|
4
|
+
*/
|
|
5
|
+
export function esc(v) {
|
|
6
|
+
return String(v ?? "")
|
|
7
|
+
.replace(/&/g, "&")
|
|
8
|
+
.replace(/</g, "<")
|
|
9
|
+
.replace(/>/g, ">")
|
|
10
|
+
.replace(/"/g, """)
|
|
11
|
+
.replace(/'/g, "'");
|
|
12
|
+
}
|
|
1
13
|
export function portalPage(records, vault, network, qs = "") {
|
|
2
14
|
const rows = records.length
|
|
3
15
|
? records.map((r) => render(r, qs)).join("\n")
|
|
@@ -5,72 +17,40 @@ export function portalPage(records, vault, network, qs = "") {
|
|
|
5
17
|
return `<!doctype html><html><head>
|
|
6
18
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
7
19
|
<title>Paydlux — approvals</title>
|
|
8
|
-
<
|
|
9
|
-
:root{--ink:#0d0f0e;--line:#262a28;--text:#eceae4;--muted:#9aa39d;--gold:#e8b04b;--green:#5fbf8b}
|
|
10
|
-
*{box-sizing:border-box}
|
|
11
|
-
body{margin:0;background:var(--ink);color:var(--text);font:16px/1.5 -apple-system,"Avenir Next",system-ui,sans-serif;padding:24px}
|
|
12
|
-
h1{font-size:20px;margin:0 0 4px}
|
|
13
|
-
.sub{color:var(--muted);font-size:13px;margin-bottom:24px;word-break:break-all}
|
|
14
|
-
table{width:100%;border-collapse:collapse}
|
|
15
|
-
th{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.06em;text-align:left;padding:8px;border-bottom:1px solid var(--line)}
|
|
16
|
-
td{padding:14px 8px;border-bottom:1px solid var(--line);vertical-align:middle}
|
|
17
|
-
.amt{font-weight:700;color:var(--gold);white-space:nowrap}
|
|
18
|
-
.uri{font-family:ui-monospace,monospace;font-size:13px}
|
|
19
|
-
.body{color:var(--muted);font-size:12px;max-width:340px;word-break:break-word}
|
|
20
|
-
button{border:0;border-radius:10px;padding:12px 18px;font-size:15px;font-weight:600;cursor:pointer;display:block;width:100%;margin:4px 0}
|
|
21
|
-
.yes{background:var(--green);color:#08110c}
|
|
22
|
-
.no{background:transparent;color:var(--muted);border:1px solid var(--line)}
|
|
23
|
-
.st{font-size:12px;text-transform:uppercase;letter-spacing:.06em}
|
|
24
|
-
.settled{color:var(--green)} .denied,.failed{color:#e07a6b} .pending{color:var(--gold)}
|
|
25
|
-
.tx{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted);word-break:break-all}
|
|
26
|
-
.qr{font-size:10px;white-space:pre-wrap;background:#141715;padding:8px;border-radius:8px;color:var(--text)}
|
|
27
|
-
details summary{cursor:pointer}
|
|
28
|
-
.phlink{color:var(--gold);text-decoration:underline;font-size:12px}
|
|
29
|
-
.foot{margin-top:28px;color:var(--muted);font-size:12px}
|
|
30
|
-
@media(max-width:640px){.body{max-width:180px}}
|
|
31
|
-
</style></head><body>
|
|
20
|
+
<link rel="stylesheet" href="portal.css"></head><body>
|
|
32
21
|
<h1>Paydlux — approvals</h1>
|
|
33
|
-
<div class="sub">vault ${vault} · ${network} · refreshed <span id="t">now</span></div>
|
|
22
|
+
<div class="sub">vault ${esc(vault)} · ${esc(network)} · refreshed <span id="t">now</span></div>
|
|
34
23
|
<table><thead><tr><th>when</th><th>what</th><th>cost</th><th>status</th><th></th></tr></thead>
|
|
35
24
|
<tbody>${rows}</tbody></table>
|
|
36
25
|
<div class="foot">Approving signs a Stellar transaction with your key. The vault needs two signatures, so this device alone cannot release funds.</div>
|
|
37
|
-
<script
|
|
38
|
-
const Q=${JSON.stringify(qs)};
|
|
39
|
-
async function act(kind,id){await fetch('/'+kind+'/'+id+Q,{method:'POST'});load()}
|
|
40
|
-
async function load(){const r=await fetch('/list'+Q);const d=await r.json();
|
|
41
|
-
document.querySelector('tbody').innerHTML=d.map(row).join('')||'<tr><td colspan="5" class="empty">nothing waiting</td></tr>';
|
|
42
|
-
document.getElementById('t').textContent=new Date().toLocaleTimeString()}
|
|
43
|
-
function row(x){const st='<span class="st '+x.status+'">'+x.status+'</span>'+(x.tx?'<div class="tx">'+x.tx+'</div>':'')+(x.reason?'<div class="tx">'+x.reason+'</div>':'');
|
|
44
|
-
const btns=x.status==='pending'?'<button class="yes" onclick="act(\'approve\',\''+x.id+'\')">Approve</button><button class="no" onclick="act(\'deny\',\''+x.id+'\')">Deny</button>':'';
|
|
45
|
-
const ph=x.status==='awaiting_phone'?'<div class="tx">awaiting your phone</div>':'';
|
|
46
|
-
return '<tr><td>'+new Date(x.at).toLocaleTimeString()+'</td><td><div class="uri">'+x.uri+'</div><div class="body">'+(x.body||'')+'</div></td><td class="amt">'+x.price+'</td><td>'+st+ph+'</td><td style="min-width:130px">'+btns+'</td></tr>'}
|
|
47
|
-
setInterval(load,3000);
|
|
48
|
-
</script></body></html>`;
|
|
26
|
+
<script src="portal.js"></script></body></html>`;
|
|
49
27
|
}
|
|
50
28
|
function render(r, qs = "") {
|
|
51
29
|
// qs is the portal key query string ("?k=..."). The approver link has to carry
|
|
52
30
|
// it, because the desk answers 403 for anything without it.
|
|
53
31
|
const link = `phone/?id=${encodeURIComponent(r.id)}${qs.replace("?", "&")}`;
|
|
32
|
+
// The id is desk-generated (pay_NNNN) but it still lands in an attribute, so it
|
|
33
|
+
// is escaped like everything else. Buttons are wired by portal.js through
|
|
34
|
+
// data-*, not an inline onclick, so the page needs no 'unsafe-inline'.
|
|
54
35
|
const btns = r.status === "pending"
|
|
55
|
-
? `<button class="yes"
|
|
56
|
-
<button class="no"
|
|
36
|
+
? `<button class="yes" data-act="approve" data-id="${esc(r.id)}">Approve</button>
|
|
37
|
+
<button class="no" data-act="deny" data-id="${esc(r.id)}">Deny</button>`
|
|
57
38
|
: "";
|
|
58
39
|
// Split mode: the desk has no approver key, so approving hands the payment to
|
|
59
|
-
// a phone. Show the human exactly what the phone will be asked to sign.
|
|
60
|
-
// Split mode: the desk has no approver key, so approving hands the payment to
|
|
61
40
|
// a phone. Show the human exactly what the phone will be asked to sign, and
|
|
62
41
|
// give the approver app a link so nobody pastes base64 on a small screen.
|
|
63
42
|
const phone = r.status === "awaiting_phone" && r.challenge
|
|
64
|
-
? `<div class="tx">awaiting your phone · ${r.challenge.summary.amount} ${r.challenge.summary.token} to ${r.challenge.summary.payee.slice(0, 12)}…</div>
|
|
65
|
-
<div class="tx"><a class="phlink" href="${link}">open on your phone to sign</a></div>
|
|
66
|
-
<details class="tx"><summary>approval payload for the phone</summary><pre class="qr">${r.qr}</pre></details>`
|
|
43
|
+
? `<div class="tx">awaiting your phone · ${esc(r.challenge.summary.amount)} ${esc(r.challenge.summary.token)} to ${esc(r.challenge.summary.payee.slice(0, 12))}…</div>
|
|
44
|
+
<div class="tx"><a class="phlink" href="${esc(link)}">open on your phone to sign</a></div>
|
|
45
|
+
<details class="tx"><summary>approval payload for the phone</summary><pre class="qr">${esc(r.qr)}</pre></details>`
|
|
67
46
|
: "";
|
|
68
|
-
const meta = [phone, r.tx && `<div class="tx">${r.tx}</div>`,
|
|
47
|
+
const meta = [phone, r.tx && `<div class="tx">${esc(r.tx)}</div>`,
|
|
48
|
+
r.reason && `<div class="tx">${esc(r.reason)}</div>`].filter(Boolean).join("");
|
|
69
49
|
return `<tr>
|
|
70
|
-
<td>${new Date(r.at).toLocaleTimeString()}</td>
|
|
71
|
-
<td><div class="uri">${r.uri}</div><div class="body">${r.body
|
|
72
|
-
<td class="amt">${r.price}</td>
|
|
73
|
-
<td><span class="st ${r.status}">${r.status}</span>${meta}</td>
|
|
50
|
+
<td>${esc(new Date(r.at).toLocaleTimeString())}</td>
|
|
51
|
+
<td><div class="uri">${esc(r.uri)}</div><div class="body">${esc(r.body)}</div></td>
|
|
52
|
+
<td class="amt">${esc(r.price)}</td>
|
|
53
|
+
<td><span class="st ${esc(r.status)}">${esc(r.status)}</span>${meta}</td>
|
|
74
54
|
<td style="min-width:130px">${btns}</td></tr>`;
|
|
75
55
|
}
|
|
76
56
|
export function servePortal(req, res, html) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paydlux/agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"phone-app/index.html",
|
|
17
17
|
"phone-app/app.js",
|
|
18
18
|
"phone-app/xdr.js",
|
|
19
|
+
"portal-app/portal.js",
|
|
20
|
+
"portal-app/portal.css",
|
|
19
21
|
"README.md",
|
|
20
22
|
"LICENSE"
|
|
21
23
|
],
|
|
@@ -33,6 +35,7 @@
|
|
|
33
35
|
"test:tls": "tsx stellar/tls-check.ts",
|
|
34
36
|
"test": "cd contracts && forge test",
|
|
35
37
|
"test:stellar": "tsx stellar/demo.ts",
|
|
38
|
+
"test:portal": "tsx stellar/xss-check.ts",
|
|
36
39
|
"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",
|
|
37
40
|
"typecheck": "tsc --noEmit"
|
|
38
41
|
},
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
:root{--ink:#0d0f0e;--line:#262a28;--text:#eceae4;--muted:#9aa39d;--gold:#e8b04b;--green:#5fbf8b}
|
|
2
|
+
*{box-sizing:border-box}
|
|
3
|
+
body{margin:0;background:var(--ink);color:var(--text);font:16px/1.5 -apple-system,"Avenir Next",system-ui,sans-serif;padding:24px}
|
|
4
|
+
h1{font-size:20px;margin:0 0 4px}
|
|
5
|
+
.sub{color:var(--muted);font-size:13px;margin-bottom:24px;word-break:break-all}
|
|
6
|
+
table{width:100%;border-collapse:collapse}
|
|
7
|
+
th{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.06em;text-align:left;padding:8px;border-bottom:1px solid var(--line)}
|
|
8
|
+
td{padding:14px 8px;border-bottom:1px solid var(--line);vertical-align:middle}
|
|
9
|
+
.amt{font-weight:700;color:var(--gold);white-space:nowrap}
|
|
10
|
+
.uri{font-family:ui-monospace,monospace;font-size:13px;word-break:break-all}
|
|
11
|
+
.body{color:var(--muted);font-size:12px;max-width:340px;word-break:break-word}
|
|
12
|
+
button{border:0;border-radius:10px;padding:12px 18px;font-size:15px;font-weight:600;cursor:pointer;display:block;width:100%;margin:4px 0}
|
|
13
|
+
.yes{background:var(--green);color:#08110c}
|
|
14
|
+
.no{background:transparent;color:var(--muted);border:1px solid var(--line)}
|
|
15
|
+
.st{font-size:12px;text-transform:uppercase;letter-spacing:.06em}
|
|
16
|
+
.settled{color:var(--green)} .denied,.failed{color:#e07a6b} .pending{color:var(--gold)}
|
|
17
|
+
.tx{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted);word-break:break-all}
|
|
18
|
+
.qr{font-size:10px;white-space:pre-wrap;background:#141715;padding:8px;border-radius:8px;color:var(--text)}
|
|
19
|
+
details summary{cursor:pointer}
|
|
20
|
+
.phlink{color:var(--gold);text-decoration:underline;font-size:12px}
|
|
21
|
+
.foot{margin-top:28px;color:var(--muted);font-size:12px}
|
|
22
|
+
@media(max-width:640px){.body{max-width:180px}}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The portal's client half. Served by the desk at /portal.js.
|
|
2
|
+
//
|
|
3
|
+
// It polls /list and re-renders the table. The desk already escapes the rows it
|
|
4
|
+
// sends, but this renderer builds HTML from JSON, so it escapes again: /list is
|
|
5
|
+
// the same agent-controlled text (request path and body) arriving through a
|
|
6
|
+
// second path, and one unescaped field here is enough to run script on the page
|
|
7
|
+
// that authorises payments.
|
|
8
|
+
//
|
|
9
|
+
// No inline handlers: buttons carry data-act/data-id and are wired here, so the
|
|
10
|
+
// page's Content-Security-Policy can forbid inline script entirely.
|
|
11
|
+
//
|
|
12
|
+
// This must render what the server-side render() in stellar/portal.ts renders.
|
|
13
|
+
// The page loads with server-rendered rows, then this replaces them every few
|
|
14
|
+
// seconds — anything missing here disappears from the human's screen a moment
|
|
15
|
+
// after they act, which is exactly what happened to the phone link.
|
|
16
|
+
const Q = new URLSearchParams(location.search).toString();
|
|
17
|
+
const qs = Q ? "?" + Q : "";
|
|
18
|
+
|
|
19
|
+
const esc = (v) => String(v ?? "")
|
|
20
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
21
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
22
|
+
|
|
23
|
+
const row = (x) => {
|
|
24
|
+
const st = `<span class="st ${esc(x.status)}">${esc(x.status)}</span>`;
|
|
25
|
+
// Split mode: the desk has no approver key, so approving handed the payment to
|
|
26
|
+
// a phone. Keep the link and the amount visible after the poll, or the human
|
|
27
|
+
// presses Approve and then loses the thing they were told to do next.
|
|
28
|
+
const phone = x.status === "awaiting_phone" && x.challenge
|
|
29
|
+
? `<div class="tx">awaiting your phone · ${esc(x.challenge.summary.amount)} ` +
|
|
30
|
+
`${esc(x.challenge.summary.token)} to ${esc(x.challenge.summary.payee.slice(0, 12))}…</div>` +
|
|
31
|
+
`<div class="tx"><a class="phlink" href="phone/?id=${encodeURIComponent(x.id)}${qs.replace("?", "&")}">` +
|
|
32
|
+
`open on your phone to sign</a></div>` +
|
|
33
|
+
`<details class="tx"><summary>approval payload for the phone</summary>` +
|
|
34
|
+
`<pre class="qr">${esc(x.qr)}</pre></details>`
|
|
35
|
+
: x.status === "awaiting_phone" ? `<div class="tx">awaiting your phone</div>` : "";
|
|
36
|
+
const meta = [
|
|
37
|
+
phone,
|
|
38
|
+
x.tx && `<div class="tx">${esc(x.tx)}</div>`,
|
|
39
|
+
x.reason && `<div class="tx">${esc(x.reason)}</div>`,
|
|
40
|
+
].filter(Boolean).join("");
|
|
41
|
+
const btns = x.status === "pending"
|
|
42
|
+
? `<button class="yes" data-act="approve" data-id="${esc(x.id)}">Approve</button>` +
|
|
43
|
+
`<button class="no" data-act="deny" data-id="${esc(x.id)}">Deny</button>`
|
|
44
|
+
: "";
|
|
45
|
+
return `<tr><td>${esc(new Date(x.at).toLocaleTimeString())}</td>` +
|
|
46
|
+
`<td><div class="uri">${esc(x.uri)}</div><div class="body">${esc(x.body)}</div></td>` +
|
|
47
|
+
`<td class="amt">${esc(x.price)}</td><td>${st}${meta}</td>` +
|
|
48
|
+
`<td style="min-width:130px">${btns}</td></tr>`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
async function act(kind, id) {
|
|
52
|
+
await fetch("/" + kind + "/" + encodeURIComponent(id) + qs, { method: "POST" });
|
|
53
|
+
load();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function load() {
|
|
57
|
+
const r = await fetch("/list" + qs);
|
|
58
|
+
if (!r.ok) return;
|
|
59
|
+
const d = await r.json();
|
|
60
|
+
document.querySelector("tbody").innerHTML =
|
|
61
|
+
d.map(row).join("") || '<tr><td colspan="5" class="empty">nothing waiting</td></tr>';
|
|
62
|
+
document.getElementById("t").textContent = new Date().toLocaleTimeString();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// One delegated listener instead of a handler per button, so nothing about a
|
|
66
|
+
// row ever needs to be executable markup.
|
|
67
|
+
document.addEventListener("click", (e) => {
|
|
68
|
+
const b = e.target.closest("button[data-act]");
|
|
69
|
+
if (b) act(b.dataset.act, b.dataset.id);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
setInterval(load, 3000);
|