diff --git a/Cargo.lock b/Cargo.lock index 397988c6d..63d8ddcd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -386,6 +421,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-macros" version = "0.5.0" @@ -618,6 +676,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.60" @@ -748,6 +816,24 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "aes-gcm", + "base64", + "hkdf", + "hmac", + "percent-encoding", + "rand 0.8.5", + "sha2", + "subtle", + "time", + "version_check", +] + [[package]] name = "coolor" version = "1.1.0" @@ -942,9 +1028,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.14.4" @@ -1819,10 +1915,12 @@ version = "0.176.2" dependencies = [ "anyhow", "axum", + "axum-extra", "base64", "bytes", "chrono", "clap", + "cookie", "dirs", "fabro-agent", "fabro-api-types", @@ -1847,9 +1945,12 @@ dependencies = [ "hyper", "hyper-util", "jsonwebtoken", + "mime_guess", "object_store", "openapiv3", + "rand 0.8.5", "reqwest", + "rust-embed", "rustls", "rustls-pemfile", "rustls-pki-types", @@ -2513,6 +2614,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -3094,6 +3205,15 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "insta" version = "1.46.3" @@ -4040,6 +4160,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -4417,6 +4543,18 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -4927,6 +5065,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rust_decimal" version = "1.41.0" @@ -6677,6 +6849,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index 1ab3e532d..eb0eac9d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ license = "MIT" [workspace.dependencies] anyhow = "1" axum = { version = "0.8" } +axum-extra = { version = "0.10", features = ["cookie-private"] } +cookie = { version = "0.18", features = ["percent-encode", "private", "signed", "key-expansion"] } thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } @@ -69,6 +71,7 @@ fork = "0.2" exec = "0.3" slatedb = "0.11.2" object_store = "0.12.5" +rust-embed = "8" [workspace.lints.rust] unsafe_code = "deny" diff --git a/apps/fabro-web/app/api-client.test.ts b/apps/fabro-web/app/api-client.test.ts deleted file mode 100644 index b77950166..000000000 --- a/apps/fabro-web/app/api-client.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, test, expect, beforeEach, mock } from "bun:test"; -import { apiJson } from "./api-client"; - -const originalFetch = globalThis.fetch; - -beforeEach(() => { - globalThis.fetch = originalFetch; -}); - -describe("apiJson", () => { - test("returns parsed JSON on 200", async () => { - globalThis.fetch = mock(() => - Promise.resolve(new Response(JSON.stringify({ id: 1, name: "test" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - })) - ); - - const result = await apiJson<{ id: number; name: string }>("/items/1"); - - expect(result).toEqual({ id: 1, name: "test" }); - }); - - test("throws Response with status 404 and null body on not found", async () => { - globalThis.fetch = mock(() => - Promise.resolve(new Response("Not Found: /items/999", { status: 404 })) - ); - - try { - await apiJson("/items/999"); - expect.unreachable("should have thrown"); - } catch (thrown) { - expect(thrown).toBeInstanceOf(Response); - const res = thrown as Response; - expect(res.status).toBe(404); - expect(res.body).toBeNull(); - } - }); - - test("throws Response with status 500 and null body, stripping sensitive details", async () => { - globalThis.fetch = mock(() => - Promise.resolve( - new Response( - "Internal error: database connection string is postgres://admin:secret@db.internal:5432/prod", - { status: 500 } - ) - ) - ); - - try { - await apiJson("/items/1"); - expect.unreachable("should have thrown"); - } catch (thrown) { - expect(thrown).toBeInstanceOf(Response); - const res = thrown as Response; - expect(res.status).toBe(500); - expect(res.body).toBeNull(); - } - }); -}); diff --git a/apps/fabro-web/app/api-client.ts b/apps/fabro-web/app/api-client.ts deleted file mode 100644 index 912314e79..000000000 --- a/apps/fabro-web/app/api-client.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { importPKCS8, SignJWT } from "jose"; -import { getAppConfig } from "./lib/config.server"; -import { isDemoMode } from "./lib/demo-mode.server"; -import { getUser } from "./lib/session.server"; - -const FABRO_JWT_PRIVATE_KEY = process.env.FABRO_JWT_PRIVATE_KEY; - -function decodePemEnv(value: string): string { - if (value.startsWith("-----")) return value; - return Buffer.from(value, "base64").toString("utf-8"); -} - -let cachedKey: CryptoKey | null = null; - -async function getSigningKey(): Promise { - if (cachedKey) return cachedKey; - if (!FABRO_JWT_PRIVATE_KEY) { - throw new Error("FABRO_JWT_PRIVATE_KEY environment variable is not set"); - } - cachedKey = await importPKCS8(decodePemEnv(FABRO_JWT_PRIVATE_KEY), "EdDSA"); - return cachedKey; -} - -async function signToken(sub?: string): Promise { - const key = await getSigningKey(); - return new SignJWT({ iss: "fabro-web", ...(sub ? { sub } : {}) }) - .setProtectedHeader({ alg: "EdDSA" }) - .setIssuedAt() - .setExpirationTime("30s") - .sign(key); -} - -interface ApiOptions { - init?: RequestInit; - request?: Request; -} - -/** - * Fetch wrapper that signs requests with a JWT for service-to-service auth. - * When a request is provided, the authenticated user's URL is included as - * the JWT `sub` claim. - */ -export async function apiFetch( - path: string, - options?: ApiOptions -): Promise { - const { base_url } = getAppConfig().api; - const { init, request } = options ?? {}; - - let sub: string | undefined; - if (request) { - const user = await getUser(request); - sub = user?.userUrl; - } - - const headers = new Headers(init?.headers); - if (FABRO_JWT_PRIVATE_KEY) { - const token = await signToken(sub); - headers.set("Authorization", `Bearer ${token}`); - } - if (request && isDemoMode(request)) { - headers.set("X-Fabro-Demo", "1"); - } - - const url = `${base_url}${path}`; - try { - return await fetch(url, { ...init, headers }); - } catch (cause) { - throw new Error(`API request to ${url} failed`, { cause }); - } -} - -/** - * Typed JSON fetch helper. Calls apiFetch and parses the JSON response. - */ -export async function apiJson(path: string, options?: ApiOptions): Promise { - const res = await apiFetch(path, options); - if (!res.ok) throw new Response(null, { status: res.status }); - return res.json() as Promise; -} diff --git a/apps/fabro-web/app/api.ts b/apps/fabro-web/app/api.ts new file mode 100644 index 000000000..a212eb500 --- /dev/null +++ b/apps/fabro-web/app/api.ts @@ -0,0 +1,58 @@ +export interface ApiOptions { + init?: RequestInit; + request?: Request; +} + +export async function apiFetch(path: string, options?: ApiOptions): Promise { + const { init } = options ?? {}; + const response = await fetch(`/api/v1${path}`, { + ...init, + credentials: "include", + headers: init?.headers, + }); + + if (response.status === 401) { + window.location.href = "/auth/login"; + throw new Error("Unauthorized"); + } + + return response; +} + +export async function apiJson(path: string, options?: ApiOptions): Promise { + const response = await apiFetch(path, options); + if (!response.ok) { + throw new Response(null, { status: response.status, statusText: response.statusText }); + } + return response.json() as Promise; +} + +export async function getSetupStatus(): Promise<{ configured: boolean }> { + const response = await fetch("/api/v1/setup/status", { credentials: "include" }); + if (!response.ok) { + throw new Response(null, { status: response.status, statusText: response.statusText }); + } + return response.json(); +} + +export async function getAuthMe(): Promise<{ + user: { + login: string; + name: string; + email: string; + avatarUrl: string; + userUrl: string; + }; + provider: string; + demoMode: boolean; + features: { session_sandboxes: boolean; retros: boolean }; +}> { + const response = await fetch("/api/v1/auth/me", { credentials: "include" }); + if (response.status === 401) { + throw new Response(null, { status: 401, statusText: "Unauthorized" }); + } + if (!response.ok) { + throw new Response(null, { status: response.status, statusText: response.statusText }); + } + return response.json(); +} diff --git a/apps/fabro-web/app/entry.tsx b/apps/fabro-web/app/entry.tsx new file mode 100644 index 000000000..542dec68a --- /dev/null +++ b/apps/fabro-web/app/entry.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { createBrowserRouter, RouterProvider } from "react-router"; +import { routes } from "./router"; + +const router = createBrowserRouter(routes); +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error("Missing #root element"); +} + +createRoot(rootElement).render( + + + , +); diff --git a/apps/fabro-web/app/layouts/app-shell.tsx b/apps/fabro-web/app/layouts/app-shell.tsx index f926bcc29..5f9d250ed 100644 --- a/apps/fabro-web/app/layouts/app-shell.tsx +++ b/apps/fabro-web/app/layouts/app-shell.tsx @@ -12,55 +12,19 @@ import { BeakerIcon, ChartBarIcon, CheckBadgeIcon, - Cog6ToothIcon, LightBulbIcon, MoonIcon, PlayIcon, RectangleStackIcon, - SparklesIcon, SunIcon, XMarkIcon, } from "@heroicons/react/24/outline"; -import { Form, Link, Outlet, redirect, useLocation, useMatches } from "react-router"; +import { Form, Link, Outlet, redirect, useLocation, useMatches, useRevalidator } from "react-router"; +import { getAuthMe } from "../api"; import { useTheme } from "../lib/theme"; -import { getAppConfig } from "../lib/config.server"; -import { isDemoMode, demoCookieHeader } from "../lib/demo-mode.server"; -import { isGitHubAppConfigured } from "../lib/github.server"; -import { requireUser } from "../lib/session.server"; -import type { Route } from "./+types/app-shell"; -const DEMO_USER = { - userUrl: "", - login: "demo", - name: "Demo User", - email: "demo@example.com", - avatarUrl: "https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png", -}; - -export async function loader({ request }: Route.LoaderArgs) { - const config = getAppConfig(); - const { provider } = config.web.auth; - const demoMode = isDemoMode(request); - if (provider === "insecure_disabled") { - return { user: DEMO_USER, demoMode, features: config.features }; - } - if (provider === "github" && !isGitHubAppConfigured()) { - throw redirect("/setup"); - } - const user = await requireUser(request); - return { user, provider, demoMode, features: config.features }; -} - -export async function action({ request }: Route.ActionArgs) { - const form = await request.formData(); - if (form.get("intent") === "toggle-demo") { - const enabled = !isDemoMode(request); - const referer = request.headers.get("Referer") ?? "/start"; - const url = new URL(referer); - return redirect(url.pathname + url.search, { - headers: { "Set-Cookie": demoCookieHeader(enabled) }, - }); - } +export async function loader() { + return getAuthMe(); } const navigation = [ @@ -75,10 +39,11 @@ function classNames(...classes: Array) { return classes.filter(Boolean).join(" "); } -export default function AppShell({ loaderData }: Route.ComponentProps) { +export default function AppShell({ loaderData }: any) { const { user, provider, demoMode } = loaderData; const { pathname } = useLocation(); const matches = useMatches(); + const revalidator = useRevalidator(); const { theme, toggle } = useTheme(); const currentNav = navigation.find((item) => pathname.startsWith(item.href)); const title = currentNav?.name ?? ""; @@ -91,6 +56,16 @@ export default function AppShell({ loaderData }: Route.ComponentProps) { const ThemeIcon = theme === "dark" ? SunIcon : MoonIcon; + async function toggleDemoMode() { + await fetch("/api/v1/demo/toggle", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !demoMode }), + }); + revalidator.revalidate(); + } + return (
@@ -128,20 +103,18 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
-
- - -
+ - {provider !== "tailscale" && ( - - - - Open user menu - - + + + + Open user menu + + - - -
- -
-
-
-
- )} + + +
+ +
+
+
+
@@ -240,20 +211,18 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
-
- - -
+