eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1// Connected-services core (docs/connected-services.md): the capability-grant
2// crypto (sealForPub/openSealed), the OAuth PKCE helper, and the DNSimple client
3// (token exchange + read-only checks).
4import http from "node:http";
5import crypto from "node:crypto";
6import { generateUserKeypair, sealForPub, openSealed, pkcePair } from "../web/static/crypto.js";
7
8function assert(cond, msg) {
9 if (!cond) {
10 console.error("FAIL:", msg);
11 process.exit(1);
12 }
13}
14
15// A grant round-trips WITH its metadata, and only the bot it was sealed to can
16// open it — the whole basis for handing a bot a time-boxed capability.
17async function testGrant() {
18 const bot = await generateUserKeypair();
19 const other = await generateUserKeypair();
20 const grant = { service: "dnsimple", token: "dnsimple_scoped_abc", exp: 1893456000000 };
21
22 const sealed = await sealForPub(bot.publicKey, grant);
23 const opened = await openSealed(sealed, bot.privateKey);
24 assert(
25 opened.token === grant.token && opened.service === "dnsimple" && opened.exp === grant.exp,
26 "grant seals/opens with its metadata intact",
27 );
28
29 let threw = false;
30 try {
31 await openSealed(sealed, other.privateKey);
32 } catch {
33 threw = true;
34 }
35 assert(threw, "a grant sealed to one bot can't be opened by another key");
36}
37
38// A fake DNSimple so the bot's read-only availability check is exercised without
39// a real account (LCHAT_DNSIMPLE_BASE is read at import time, so import late).
40// A PKCE verifier's challenge is exactly base64url(S256(verifier)), a fresh pair
41// each call — the basis of authorizing with no client secret.
42async function testPkce() {
43 const { verifier, challenge } = await pkcePair();
44 const expected = crypto.createHash("sha256").update(verifier).digest("base64url");
45 assert(challenge === expected, "pkce challenge is base64url(S256(verifier))");
46 assert(verifier.length >= 43 && !/[+/=]/.test(verifier), "verifier is url-safe and long enough (RFC 7636)");
47 assert((await pkcePair()).verifier !== verifier, "each pkcePair is fresh");
48}
49
50function startMock() {
51 const server = http.createServer((req, res) => {
52 const send = (obj, code = 200) => {
53 res.writeHead(code, { "content-type": "application/json" });
54 res.end(JSON.stringify(obj));
55 };
56 // Connect broker exchange endpoint: bearer-authed, returns an access token.
57 if (req.method === "POST" && req.url === "/api/exchange") {
58 if (!/^Bearer /.test(req.headers.authorization || "")) return send({ message: "unauthorized" }, 401);
59 let body = "";
60 req.on("data", (ch) => (body += ch));
61 req.on("end", () => {
62 const p = JSON.parse(body || "{}");
63 if (p.code) return send({ access_token: "scoped_from_exchange" });
64 send({ message: "bad exchange request" }, 400);
65 });
66 return;
67 }
68 if (!/^Bearer scoped_/.test(req.headers.authorization || "")) return send({ message: "Authentication failed" }, 401);
69 if (req.url === "/v2/whoami") return send({ data: { account: { id: 4242 } } });
70 if (req.url.startsWith("/v2/4242/domains")) return send({ data: [{ name: "one.example", expires_on: "2027-01-01", auto_renew: true, state: "registered" }, { name: "two.example" }] });
71 const m = req.url.match(/^\/v2\/4242\/registrar\/domains\/([^/]+)\/check$/);
72 if (m) {
73 const d = decodeURIComponent(m[1]);
74 return send({ data: { domain: d, available: d === "free.example", premium: false } });
75 }
76 const pm = req.url.match(/^\/v2\/4242\/registrar\/domains\/([^/]+)\/prices$/);
77 if (pm) {
78 const d = decodeURIComponent(pm[1]);
79 return send({ data: { domain: d, premium: false, registration_price: 14.0, renewal_price: 14.0, transfer_price: 14.0 } });
80 }
81 send({ message: "not found" }, 404);
82 });
83 return new Promise((resolve) => server.listen(0, () => resolve(server)));
84}
85
86async function testClient() {
87 const server = await startMock();
88 process.env.LCHAT_DNSIMPLE_BASE = `http://127.0.0.1:${server.address().port}`;
89 const base = process.env.LCHAT_DNSIMPLE_BASE;
90 const { exchangeViaBroker, checkAvailability, listDomains } = await import("../agent/dnsimple.mjs");
91
92 // The sidecar swaps a code for a token via the broker (which holds the secret).
93 const token = await exchangeViaBroker({ code: "abc", state: "s", brokerUrl: base, brokerToken: "brk" });
94 assert(token === "scoped_from_exchange", "exchangeViaBroker returns the access token from the broker");
95 let noBroker = false;
96 try {
97 await exchangeViaBroker({ code: "abc", state: "s" });
98 } catch {
99 noBroker = true;
100 }
101 assert(noBroker, "exchangeViaBroker refuses without broker config");
102
103 // /list — the wired, read-only command, using the exchanged token.
104 const domains = await listDomains(token);
105 assert(domains.length === 2 && domains.some((d) => d.name === "one.example"), "listDomains returns structured domain rows");
106 const one = domains.find((d) => d.name === "one.example");
107 assert(one.expires_on === "2027-01-01" && one.auto_renew === true, "rows carry expiry + auto-renew for the table");
108 assert(one.account_id === 4242, "rows carry the account id so the app can deep-link to dnsimple.com");
109 assert(one.price === 14, "rows carry the renewal price (best-effort per-domain lookup) for the cost column");
110
111 const free = await checkAvailability("free.example", token);
112 assert(free.available === true, "an unregistered domain reports available");
113 const taken = await checkAvailability("taken.example", token);
114 assert(taken.available === false, "a registered domain reports taken");
115
116 let authThrew = false;
117 try {
118 await checkAvailability("free.example", "rejected");
119 } catch {
120 authThrew = true;
121 }
122 assert(authThrew, "a rejected token surfaces an error");
123
124 let badDomain = false;
125 try {
126 await checkAvailability("not a domain", token);
127 } catch {
128 badDomain = true;
129 }
130 assert(badDomain, "a malformed domain is refused before any network call");
131
132 server.close();
133}
134
135await testGrant();
136await testPkce();
137await testClient();
138console.log("connect.test.mjs OK");
139