| 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). |
| 4 | import http from "node:http"; |
| 5 | import crypto from "node:crypto"; |
| 6 | import { generateUserKeypair, sealForPub, openSealed, pkcePair } from "../web/static/crypto.js"; |
| 7 | |
| 8 | function 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. |
| 17 | async 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. |
| 42 | async 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 | |
| 50 | function 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 | |
| 86 | async 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 | |
| 135 | await testGrant(); |
| 136 | await testPkce(); |
| 137 | await testClient(); |
| 138 | console.log("connect.test.mjs OK"); |
| 139 | |