Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

sudo is a React app now, hydrated in the browser as the site is: pages are still rendered on the server, then React Router takes over, so a sidebar link opens the next page without reloading. A lavender bar across the top shows while the next page loads (it appears at once, creeps, then fills and fades, as on the site) and the page dims after a moment. The phone menu closes when a link is followed, and a <Form> that was sent goes back to what the page now says, its button letting go of the spinner. The pages run only sudo's own scripts: each page's content security policy names a nonce made for that page (script-src 'self' 'nonce-…', connect-src 'self', everything else as strict as before, still no inline style), entry.server.tsx hands it to React Router's inline scripts and preloads, and every answer that is not a page (data, redirects, files) still forbids every script. The <details> folds still open before any script loads. Comments and the README that said sudo ships no JavaScript now say what is true. Checked: npm run typecheck and npm test in apps/sudo (160, new: the page policy and its nonce), npm run build, and the built worker run locally with the Access check stubbed (not committed) in headless Chrome: no CSP violations or console errors, the page hydrates, a folded section opens on click, a sidebar link navigates client-side with the bar and the dimming, the phone menu closes on navigation, and .data answers keep script-src 'none'. Posting a form was not tried locally (the same-origin check wants https://sudo.g1t.sh).

G1t-Session:
ses_01m50b25ede9b8spq6dcbvpvzh
syntaqxcommitted Parent115a416Browse files
18 files+242−390/18 viewed
+10−3
246246 enterprises, Stripe billing links, invoice emails, invoices and the
247247 webhook show a confirmation step first; a credit needs the workspace's
248248 slug typed out.
249−5. **The pages ship no JavaScript.** The content security policy forbids
250− every script and inline style; responses are `no-store`, `noindex` and
251− cannot be framed. The worker has no `workers.dev` address or preview URLs.
249+5. **The pages run only sudo's own scripts.** Each page is rendered on the
250+ server and hydrated in the browser, as apps/web's are. Its content
251+ security policy names a nonce made for that page
252+ (`script-src 'self' 'nonce-…'`, `app/entry.server.tsx` and
253+ `app/lib/guard.ts`): sudo's files and the inline scripts React Router
254+ writes carrying the nonce run, and nothing else, no other site's and no
255+ other inline script; requests go only back to sudo; no inline style.
256+ Every other answer (data, redirects, files) forbids every script.
257+ Responses are `no-store`, `noindex` and cannot be framed. The worker has
258+ no `workers.dev` address or preview URLs.
252259
253260 ## Setting up Access (once, in the Cloudflare dashboard)
254261
+4−3
7171 }
7272
7373 /*
74− * sudo ships no JavaScript, so a long-running button says it is running
75− * with CSS alone: a clicked button keeps focus while the old page waits for
76− * the response. `:focus-visible` is left out, so tabbing to it does not
74+ * A long-running button says it is running with CSS alone, before any
75+ * script has loaded: a clicked button keeps focus while the old page waits
76+ * for the response (components/progress.tsx lets go of it once a <Form>'s
77+ * answer is in). `:focus-visible` is left out, so tabbing to it does not
7778 * read as running.
7879 */
7980 .run-button .run-busy,
+1−1
11 /**
22 * The billing sections shared by a workspace's page and an enterprise's:
33 * confirmations, terms, plan, pools and caps, credit, bank transfers, the
4− * Stripe billing link, the ledger and the audit log. Plain forms; sudo ships no JavaScript.
4+ * Stripe billing link, the ledger and the audit log. Plain forms, which work before any script has loaded.
55 */
66 import { CreditCard, Gift, Landmark, RotateCcw, ScrollText, UserRound } from "lucide-react";
77 import type { ReactNode } from "react";
+2−2
121121 }
122122
123123 /**
124− * The button that runs the nightly analysis now. sudo runs no JavaScript,
125− * so app.css's run-button rules say it is running while the page waits.
124+ * The button that runs the nightly analysis now. A plain form: app.css's
125+ * run-button rules say it is running while the page waits.
126126 */
127127 function RunButton() {
128128 return (
+2−2
11 /**
22 * The Incidents pages' small parts: chips, a page's back link, the
3− * timers strip and the component impact picker. Forms only: sudo ships
4− * no JavaScript.
3+ * timers strip and the component impact picker. Forms only, which work
4+ * before any script has loaded.
55 */
66 import { ArrowLeft } from "lucide-react";
77 import type { ReactNode } from "react";
+1−1
22 * An incident's parts, check by check: a latency chart per part, drawn as
33 * SVG on the server, with the slow line, slow checks dotted and failures
44 * marked along the top, and the latest checks as a table under it. No
5− * script and no inline style (sudo ships no JavaScript); the table is the
5+ * script and no inline style (sudo's policy allows none); the table is the
66 * way to read exact figures.
77 */
88 import type { CheckHistory } from "@g1t/contracts";
+91−0
1+/**
2+ * The page on its way out while the next one loads, as apps/web's shell
3+ * has it: a bar across the top, and the page dimming after a moment.
4+ */
5+import { useEffect, useRef, useState } from "react";
6+import { useNavigation } from "react-router";
7+
8+/**
9+ * The page while the next one loads: after a moment it dims, under the
10+ * bar below, so it is plainly on its way out rather than frozen, and the
11+ * next page fades up from there. Sending a form does not dim it: the
12+ * form's button says it is working. Spread on the element holding the page.
13+ */
14+export function useLeaving() {
15+ const navigation = useNavigation();
16+ const method = navigation.formMethod?.toUpperCase();
17+ const leaving = navigation.state === "loading" && (method == null || method === "GET");
18+ return {
19+ "aria-busy": leaving || undefined,
20+ className: `transition-opacity motion-reduce:transition-none ${leaving ? "opacity-60 delay-200 duration-300" : "duration-150"}`,
21+ };
22+}
23+
24+/**
25+ * A form React Router sent (<Form>) leaves the page as a fresh one would:
26+ * once the answer is in, the form goes back to what the page now says
27+ * and its button lets go of focus, so app.css's "working" spinner on a
28+ * focused submit button stops. A plain <form> loads a new page anyway.
29+ */
30+export function useSettledForms() {
31+ const navigation = useNavigation();
32+ const sent = useRef<HTMLFormElement | null>(null);
33+ useEffect(() => {
34+ const remember = (event: SubmitEvent) => {
35+ if (event.target instanceof HTMLFormElement) sent.current = event.target;
36+ };
37+ document.addEventListener("submit", remember);
38+ return () => document.removeEventListener("submit", remember);
39+ }, []);
40+ useEffect(() => {
41+ const form = sent.current;
42+ if (navigation.state !== "idle" || !form) return;
43+ sent.current = null;
44+ if (!form.isConnected) return;
45+ form.reset();
46+ if (document.activeElement instanceof HTMLElement && form.contains(document.activeElement)) document.activeElement.blur();
47+ }, [navigation.state]);
48+}
49+
50+/**
51+ * A bar across the top of the page while the next one loads: it appears
52+ * at once, creeps towards the end while waiting, then fills and fades when
53+ * the page arrives. At rest it has no width and no style attribute, so the
54+ * server's page carries no inline style for the policy to refuse; React
55+ * sets the width through the DOM once it moves.
56+ */
57+export function Progress() {
58+ const navigation = useNavigation();
59+ const busy = navigation.state !== "idle";
60+ const [width, setWidth] = useState(0);
61+ const [visible, setVisible] = useState(false);
62+ useEffect(() => {
63+ if (busy) {
64+ setVisible(true);
65+ setWidth(12);
66+ // Slows as it goes, never quite arriving until the page does.
67+ const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
68+ return () => clearInterval(timer);
69+ }
70+ setWidth((now) => (now > 0 ? 100 : 0));
71+ const fade = setTimeout(() => setVisible(false), 250);
72+ const reset = setTimeout(() => setWidth(0), 550);
73+ return () => {
74+ clearTimeout(fade);
75+ clearTimeout(reset);
76+ };
77+ }, [busy]);
78+ return (
79+ <div
80+ aria-hidden="true"
81+ className={`pointer-events-none fixed inset-x-0 top-0 z-60 h-0.75 transition-opacity duration-300 ${
82+ visible ? "opacity-100" : "opacity-0"
83+ }`}
84+ >
85+ <div
86+ className="h-full w-0 bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
87+ style={width > 0 ? { width: `${width}%` } : undefined}
88+ />
89+ </div>
90+ );
91+}
+7−6
22 * sudo's frame. On wide screens, a sidebar: Overview and Reach out as
33 * links, then sections that fold open to their pages, as GitLab and
44 * Vercel do theirs (apps/web's project sidebar is the same pattern). On a
5− * phone, a top bar whose menu holds the same. sudo ships no JavaScript,
6− * so every fold is a <details>, drawn open on the server for the section
7− * holding the current page. All of it comes from lib/nav.ts.
5+ * phone, a top bar whose menu holds the same. Every fold is a <details>,
6+ * which opens and closes before the page's script has loaded, drawn open
7+ * for the section holding the current page. All of it comes from
8+ * lib/nav.ts.
89 */
910 import {
1011 Activity,
279280 <Link to="/" aria-label="sudo overview" className="flex">
280281 <Logo />
281282 </Link>
282− {/* A details element: it opens and closes with no script. A link
283− loads a new page, which arrives with the menu closed. */}
284− <details className="group/menu">
283+ {/* A details element: it opens and closes with no script. Keyed
284+ by the page, so the next page arrives with the menu closed. */}
285+ <details key={pathname} className="group/menu">
285286 <summary
286287 aria-label="Menu"
287288 className="flex h-9 cursor-pointer list-none items-center gap-1.5 rounded-md border border-line px-2.5 text-sm text-muted transition-colors select-none hover:border-line-strong hover:text-fg group-open/menu:border-line-strong group-open/menu:text-fg [&::-webkit-details-marker]:hidden"
+2−2
22 * Moving workspaces off Stripe's test mode before the live key, history
33 * kept: one workspace on its page, every workspace on the Stripe page. A
44 * preview (dry run) lists what will change; the change itself needs the
5− * slug, or "detach", typed. sudo ships no JavaScript: the apply buttons say
6− * they are running with app.css's run-button rules.
5+ * slug, or "detach", typed. Plain forms: the apply buttons say they are
6+ * running with app.css's run-button rules.
77 */
88 import { Eye, Unplug } from "lucide-react";
99
+2−2
5858
5959 /**
6060 * A native select drawn as shadcn's NativeSelect: the browser's own arrow
61− * hidden, a chevron of ours in its place. Native because sudo ships no
62− * JavaScript, so it still opens, types to search and works on phones.
61+ * hidden, a chevron of ours in its place. Native, so it opens, types to
62+ * search and works on phones before any script has loaded.
6363 */
6464 export function Select({ className = "", ...props }: ComponentProps<"select">) {
6565 const classes = className.split(/\s+/).filter(Boolean);
+19−0
1+import { StrictMode, startTransition } from "react";
2+import { hydrateRoot } from "react-dom/client";
3+import { HydratedRouter } from "react-router/dom";
4+
5+startTransition(() => {
6+ hydrateRoot(
7+ document,
8+ <StrictMode>
9+ <HydratedRouter />
10+ </StrictMode>,
11+ {
12+ // React recovers from a page the server rendered differently by
13+ // rendering it again here; say where, so it can be fixed.
14+ onRecoverableError(error, info) {
15+ console.error("sudo: recovered from", error, info.componentStack);
16+ },
17+ },
18+ );
19+});
+12−3
11 import { renderToReadableStream } from "react-dom/server";
22 import { type EntryContext, ServerRouter } from "react-router";
33
4+import { makeNonce, pagePolicy } from "./lib/guard";
5+
46 /**
5− * Renders the whole page before sending it. sudo ships no JavaScript, so
6− * there is nothing to stream into and no inline script to allow.
7+ * Renders the whole page before sending it, then the browser hydrates it.
8+ * The page runs only sudo's own scripts: React Router writes its inline
9+ * scripts and preloads with this page's nonce (ServerRouter hands it to
10+ * <Links>, <Scripts> and <ScrollRestoration>), and the page's policy
11+ * names that nonce and nothing else (lib/guard.ts). workers/app.ts keeps
12+ * the policy set here.
713 */
814 export default async function handleRequest(
915 request: Request,
1218 routerContext: EntryContext,
1319 ) {
1420 let status = responseStatusCode;
15− const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, {
21+ const nonce = makeNonce();
22+ const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} nonce={nonce} />, {
23+ nonce,
1624 signal: request.signal,
1725 onError(error: unknown) {
1826 status = 500;
2129 });
2230 await body.allReady;
2331 responseHeaders.set("content-type", "text/html; charset=utf-8");
32+ responseHeaders.set("content-security-policy", pagePolicy(nonce));
2433 return new Response(body, { headers: responseHeaders, status });
2534 }
+36−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { CONTENT_SECURITY_POLICY, makeNonce, pagePolicy, secure } from "./guard.ts";
5+
6+function directives(policy: string): Map<string, string> {
7+ return new Map(policy.split("; ").map((part) => [part.split(" ")[0]!, part]));
8+}
9+
10+test("a page runs only sudo's own scripts, by its nonce", () => {
11+ const nonce = makeNonce();
12+ assert.match(nonce, /^[A-Za-z0-9+/]{22}==$/);
13+ assert.notEqual(nonce, makeNonce());
14+ const page = directives(pagePolicy(nonce));
15+ assert.equal(page.get("script-src"), `script-src 'self' 'nonce-${nonce}'`);
16+ assert.equal(page.get("connect-src"), "connect-src 'self'");
17+ assert.doesNotMatch(pagePolicy(nonce), /unsafe-|strict-dynamic|https?:|\*/);
18+ // Everything else as strict as an answer that is not a page.
19+ const rest = directives(CONTENT_SECURITY_POLICY);
20+ for (const [name, value] of rest) if (name !== "script-src") assert.equal(page.get(name), value, name);
21+ assert.deepEqual([...page.keys()].filter((name) => !rest.has(name)), ["connect-src"]);
22+});
23+
24+test("an answer that is not a page runs nothing", () => {
25+ assert.equal(directives(CONTENT_SECURITY_POLICY).get("script-src"), "script-src 'none'");
26+ assert.equal(directives(CONTENT_SECURITY_POLICY).get("default-src"), "default-src 'none'");
27+});
28+
29+test("a page keeps its own policy; anything else gets the strict one", () => {
30+ const nonce = makeNonce();
31+ const page = secure(new Response("<html></html>", { headers: { "content-security-policy": pagePolicy(nonce) } }));
32+ assert.equal(page.headers.get("content-security-policy"), pagePolicy(nonce));
33+ assert.equal(page.headers.get("cache-control"), "no-store");
34+ const data = secure(new Response("{}"));
35+ assert.equal(data.headers.get("content-security-policy"), CONTENT_SECURITY_POLICY);
36+});
+37−4
44 */
55
66 /**
7− * The pages ship no JavaScript, so no script may run at all; styles and
8− * images and fonts come only from sudo itself, and forms post only back
9− * to sudo.
7+ * The policy of every answer that is not a page (data, redirects, files):
8+ * no script may run at all; styles, images and fonts come only from sudo
9+ * itself, and forms post only back to sudo.
1010 */
1111 export const CONTENT_SECURITY_POLICY = [
1212 "default-src 'none'",
2020 "upgrade-insecure-requests",
2121 ].join("; ");
2222
23+/** A fresh nonce for one page: 128 random bits, base64. */
24+export function makeNonce(): string {
25+ const bytes = crypto.getRandomValues(new Uint8Array(16));
26+ return btoa(String.fromCharCode(...bytes));
27+}
28+
29+/**
30+ * The policy of a page rendered with `nonce` (entry.server.tsx): as strict
31+ * as every other answer's, except that the page runs sudo's own scripts,
32+ * its files from /assets and the inline scripts React Router writes, each
33+ * carrying the page's nonce, and nothing else, no other site's and no
34+ * other inline script. Those scripts fetch the next page's data from sudo
35+ * itself and nowhere else. Still no inline style: React sets a style
36+ * through the DOM, which the policy allows.
37+ */
38+export function pagePolicy(nonce: string): string {
39+ return [
40+ "default-src 'none'",
41+ `script-src 'self' 'nonce-${nonce}'`,
42+ "style-src 'self'",
43+ "font-src 'self'",
44+ "img-src 'self' data:",
45+ "connect-src 'self'",
46+ "form-action 'self'",
47+ "frame-ancestors 'none'",
48+ "base-uri 'none'",
49+ "upgrade-insecure-requests",
50+ ].join("; ");
51+}
52+
2353 const HEADERS: Record<string, string> = {
2454 "cache-control": "no-store",
2555 "x-robots-tag": "noindex, nofollow, noarchive",
3363 "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()",
3464 };
3565
36−/** The response with sudo's headers; a policy it already set is kept. */
66+/**
67+ * The response with sudo's headers. A policy it already set is kept: a
68+ * page's, with its nonce, or a refusal's.
69+ */
3770 export function secure(response: Response): Response {
3871 const secured = new Response(response.body, response);
3972 for (const [name, value] of Object.entries(HEADERS)) secured.headers.set(name, value);
+2−2
11 /**
22 * Times in the staff member's own zone, on every page (components/ui.tsx
3− * `When`) and in the forms that take a time. sudo ships no script, so the
4− * zone comes from the request: the `sudo_tz` cookie when someone chose one
3+ * `When`) and in the forms that take a time. Pages are drawn on the
4+ * server first, so the zone comes from the request: the `sudo_tz` cookie when someone chose one
55 * (/timezone), else Cloudflare's guess (`request.cf.timezone`), else UTC.
66 * Every time shown says its zone's abbreviation, and `<time datetime>`
77 * plus the hover title keep the UTC instant. No Workers or React imports,
+9−3
11 import { privacy } from "./lib/services.server";
2−import { isRouteErrorResponse, Links, Meta, Outlet, useRouteLoaderData } from "react-router";
2+import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration, useRouteLoaderData } from "react-router";
33
44 import type { Route } from "./+types/root";
55 import "./app.css";
66 import displayFont from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2?url";
77 import sansFont from "@g1t/theme/fonts/hanken-grotesk-latin.woff2?url";
8+import { Progress, useLeaving, useSettledForms } from "./components/progress";
89 import { MobileBar, Sidebar } from "./components/shell";
910 import { ButtonLink } from "./components/ui";
1011 import type { NavCounts } from "./lib/nav";
5455
5556 export function Layout({ children }: { children: React.ReactNode }) {
5657 const root = useRouteLoaderData<typeof loader>("root");
58+ const leaving = useLeaving();
59+ useSettledForms();
5760 return (
5861 <html lang="en">
5962 <head>
6467 <Links />
6568 </head>
6669 <body className="min-h-screen">
70+ <Progress />
6771 <Sidebar email={root?.email} counts={root?.counts} />
6872 <MobileBar email={root?.email} counts={root?.counts} />
6973 <div className="lg:pl-60">
9296 </a>
9397 </div>
9498 )}
95− {children}
99+ <div {...leaving}>{children}</div>
96100 </div>
97− {/* No <Scripts />: sudo ships no JavaScript, and its policy allows none. */}
101+ {/* Each takes the page's nonce from ServerRouter (entry.server.tsx). */}
102+ <ScrollRestoration />
103+ <Scripts />
98104 </body>
99105 </html>
100106 );
+2−2
287287
288288 /**
289289 * Deleting the account, or, once it is deleted, restoring or purging it
290− * and the workspaces deleted with it. Every form is plain HTML: sudo ships
291− * no JavaScript.
290+ * and the workspaces deleted with it. Every form works as plain HTML,
291+ * before any script has loaded.
292292 */
293293 function AccountSection({
294294 user,
+3−3
11 import type { Config } from "@react-router/dev/config";
22
33 export default {
4− // Rendered on the server only: the pages ship no JavaScript at all (root
5− // leaves out <Scripts />), so the content security policy can forbid
6− // every script. Forms are plain HTML forms posting to route actions.
4+ // Rendered on the server, then hydrated in the browser. The page's policy
5+ // lets only sudo's own scripts run, by a nonce made for each page
6+ // (entry.server.tsx, lib/guard.ts). Forms post to route actions.
77 ssr: true,
88 } satisfies Config;

Comments

Sign in to comment.