Skip to content
68 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

sudo: Access service tokens listed in STAFF_SERVICE_TOKENS are staff, recorded as <name>@service.g1t.sh1#!/usr/bin/env node
2// Uses sudo (https://sudo.g1t.sh) without a browser, through the Access
3// service token in .credentials/sudo-service-token.json. sudo records it
4// as claude@service.g1t.sh (apps/sudo/README.md, "Service tokens").
5//
6// node scripts/ops/sudo.mjs get /costs
7// node scripts/ops/sudo.mjs post /costs intent=run
8// node scripts/ops/sudo.mjs get /users/g1t-reviewer --html
9//
10// Prints the status, where a redirect goes, and the page as text (or the
11// raw HTML with --html). POSTs are form-encoded, the way sudo's pages send
12// them, with sudo's own Origin. The secret is never printed.
13import { readFileSync } from "node:fs";
14
15const SUDO = "https://sudo.g1t.sh";
16const CREDENTIALS = new URL("../../.credentials/sudo-service-token.json", import.meta.url);
17
18/** The headers that get a request past Access. */
19export function accessHeaders() {
20 const { client_id, client_secret } = JSON.parse(readFileSync(CREDENTIALS, "utf8"));
21 if (!client_id || !client_secret) throw new Error("sudo-service-token.json needs client_id and client_secret");
22 return { "CF-Access-Client-Id": client_id, "CF-Access-Client-Secret": client_secret };
23}
24
25/** A page's readable text: no scripts, styles or tags, one line per block. */
26export function pageText(html) {
27 return html
28 .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, "")
29 .replace(/<(br|\/p|\/div|\/li|\/tr|\/h[1-6]|\/section|\/summary)\b[^>]*>/gi, "\n")
30 .replace(/<\/t[dh]>/gi, "\t")
31 .replace(/<[^>]+>/g, "")
32 .replace(/&nbsp;/g, " ")
33 .replace(/&amp;/g, "&")
34 .replace(/&lt;/g, "<")
35 .replace(/&gt;/g, ">")
36 .replace(/&quot;/g, '"')
37 .replace(/&#39;/g, "'")
38 .split("\n")
39 .map((line) => line.replace(/[ \t]+/g, " ").trim())
40 .filter(Boolean)
41 .join("\n");
42}
43
44/** GETs or POSTs `path`; `fields` are the form's name=value pairs. */
45export async function sudo(method, path, fields = []) {
46 const headers = { ...accessHeaders() };
47 let body;
48 if (method === "POST") {
49 headers.origin = SUDO;
50 headers["content-type"] = "application/x-www-form-urlencoded";
51 body = new URLSearchParams(fields.map((field) => field.split(/=(.*)/s).slice(0, 2))).toString();
52 }
53 const response = await fetch(new URL(path, SUDO), { method, headers, body, redirect: "manual" });
54 return { status: response.status, location: response.headers.get("location"), html: await response.text() };
55}
56
57if (process.argv[1]?.replace(/\\/g, "/").endsWith("scripts/ops/sudo.mjs")) {
58 const [verb = "get", path = "/", ...rest] = process.argv.slice(2);
59 const html = rest.includes("--html");
60 const fields = rest.filter((arg) => arg !== "--html");
61 const result = await sudo(verb.toUpperCase() === "POST" ? "POST" : "GET", path, fields);
62 console.log(`${result.status}${result.location ? ` -> ${result.location}` : ""}`);
63 if (result.location?.includes("cloudflareaccess.com")) {
64 console.log("Access did not accept the service token: the sudo application needs a Service Auth policy that includes it.");
65 } else {
66 console.log(html ? result.html : pageText(result.html));
67 }
68}