eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Minimal DNSimple client for the connected-services prototype
2// (docs/connected-services.md). Kept tiny and dependency-free so the only impure
3// part is the fetch — point it at a fake with LCHAT_DNSIMPLE_BASE in tests.
4//
5// The token this receives is a *scoped* DNSimple access token, handed to the bot
6// inside a time-boxed, sealed capability grant — never stored on this host. We
7// hold it only for the duration of one call and drop it.
8const BASE = (process.env.LCHAT_DNSIMPLE_BASE || "https://api.dnsimple.com").replace(/\/$/, "");
9
10async function api(token, method, path) {
11 const r = await fetch(`${BASE}${path}`, {
12 method,
13 headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
14 });
15 const text = await r.text();
16 let data = {};
17 try {
18 data = text ? JSON.parse(text) : {};
19 } catch {}
20 if (!r.ok) throw new Error(data && data.message ? data.message : `DNSimple ${r.status}`);
21 return data;
22}
23
24// exchangeViaBroker swaps an authorization code for an access token via the
25// connect broker (docs/connected-services.md). DNSimple only offers CONFIDENTIAL
26// OAuth apps, so the exchange needs the client_secret — which lives ONLY on the
27// broker (like the APNs .p8 on the push relay), never on this sidecar. We
28// authenticate to the broker with a per-instance bearer token and get back only
29// the user's access token, which the caller seals to the member and drops.
30export async function exchangeViaBroker({ code, state, brokerUrl, brokerToken }) {
31 if (!brokerUrl || !brokerToken)
32 throw new Error("connect broker not configured on this sidecar (set LCHAT_CONNECT_BROKER_URL/_TOKEN)");
33 const r = await fetch(`${brokerUrl.replace(/\/$/, "")}/api/exchange`, {
34 method: "POST",
35 headers: { "Content-Type": "application/json", Authorization: `Bearer ${brokerToken}` },
36 body: JSON.stringify({ code, state }),
37 });
38 const d = await r.json().catch(() => ({}));
39 if (!r.ok || !d.access_token) throw new Error(d && d.message ? d.message : `broker exchange ${r.status}`);
40 return d.access_token;
41}
42
43// whoami resolves the single account the token acts for. An account-scoped token
44// returns `{ data: { account: { id } } }`; a user token that spans several
45// accounts returns a user instead — we refuse it, since registrar calls are
46// account-scoped and we won't guess which account to spend from.
47export async function whoami(token) {
48 const d = await api(token, "GET", "/v2/whoami");
49 const id = d && d.data && d.data.account && d.data.account.id;
50 if (!id) throw new Error("that token isn't scoped to a single account — create an account access token");
51 return id;
52}
53
54// Run an async fn over items with a bounded number in flight — the domains list
55// doesn't include pricing, so we fetch it per-domain and don't want N sockets at
56// once (nor to trip DNSimple's rate limit).
57async function mapLimit(items, limit, fn) {
58 const queue = items.map((item, i) => [item, i]);
59 const worker = async () => {
60 for (;;) {
61 const next = queue.shift();
62 if (!next) return;
63 await fn(next[0], next[1]);
64 }
65 };
66 await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
67}
68
69// listDomains returns structured rows for the DNSimple app's table. GET
70// /v2/{account}/domains gives name/state/expiry/auto_renew; the renewal price
71// isn't in that payload, so we look it up per-domain (best-effort, bounded
72// concurrency) — a failure just leaves that row's price undefined ("—").
73export async function listDomains(token) {
74 const account = await whoami(token);
75 const d = await api(token, "GET", `/v2/${account}/domains?per_page=100`);
76 const rows = ((d && d.data) || [])
77 .filter((r) => r && r.name)
78 .map((r) => ({
79 name: r.name,
80 state: r.state || "",
81 auto_renew: !!r.auto_renew,
82 expires_on: r.expires_on || (r.expires_at ? String(r.expires_at).slice(0, 10) : ""),
83 // account id → the app can deep-link to the domain's page on dnsimple.com.
84 account_id: r.account_id || account,
85 }));
86 await mapLimit(rows, 8, async (row) => {
87 try {
88 const p = await api(token, "GET", `/v2/${account}/registrar/domains/${encodeURIComponent(row.name)}/prices`);
89 const price = p && p.data && (p.data.renewal_price ?? p.data.registration_price);
90 if (price != null) row.price = Number(price);
91 } catch {
92 /* pricing is best-effort — leave undefined */
93 }
94 });
95 return rows;
96}
97
98const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/i;
99
100// checkAvailability is the read-only call `/register` makes first: is the domain
101// free to register, and is it premium-priced? No money moves.
102// GET /v2/{account}/registrar/domains/{domain}/check
103export async function checkAvailability(domain, token) {
104 if (!DOMAIN_RE.test(domain || "")) throw new Error(`"${domain}" doesn't look like a domain name`);
105 const account = await whoami(token);
106 const d = await api(token, "GET", `/v2/${account}/registrar/domains/${encodeURIComponent(domain.toLowerCase())}/check`);
107 const info = (d && d.data) || {};
108 return { domain: info.domain || domain, available: !!info.available, premium: !!info.premium };
109}
110
111// registerDomain is the money-spending, irreversible step. It is deliberately
112// NOT called by the availability check — a real registration is confirmed by the
113// member tapping the "Register" chip (see docs/connected-services.md: irreversible
114// actions confirm in place, never as a command side effect). Wired here so the
115// shape is visible; the runtime does not auto-invoke it.
116// POST /v2/{account}/registrar/domains/{domain}/registrations { registrant_id }
117export async function registerDomain(domain, token, { registrantId } = {}) {
118 if (!DOMAIN_RE.test(domain || "")) throw new Error(`"${domain}" doesn't look like a domain name`);
119 if (!registrantId) throw new Error("registration needs a registrant contact id (registrantId)");
120 const account = await whoami(token);
121 const r = await fetch(
122 `${BASE}/v2/${account}/registrar/domains/${encodeURIComponent(domain.toLowerCase())}/registrations`,
123 {
124 method: "POST",
125 headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
126 body: JSON.stringify({ registrant_id: registrantId }),
127 },
128 );
129 const d = await r.json().catch(() => ({}));
130 if (!r.ok) throw new Error(d && d.message ? d.message : `DNSimple ${r.status}`);
131 return { id: d && d.data && d.data.id, state: (d && d.data && d.data.state) || "requested" };
132}
133