Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions admin/src/hooks/use-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function useSession() {
const [status, setStatus] = useState(allowDemo ? "local demo data loaded" : "loading");
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(allowDemo ? Date.now() : null);
const [demoMode, setDemoMode] = useState(allowDemo);
const [loginRequired, setLoginRequired] = useState(false);
const statusPresentation = useMemo(() => consoleStatusPresentation(status, demoMode), [demoMode, status]);
const busy = statusPresentation.tone === "pending";

Expand Down Expand Up @@ -45,6 +46,8 @@ export function useSession() {
setLastUpdatedAt,
demoMode,
setDemoMode,
loginRequired,
setLoginRequired,
statusPresentation,
statusTone: statusPresentation.tone,
busy,
Expand Down
10 changes: 8 additions & 2 deletions admin/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { AppShell } from "./app-shell";
import { ConsoleControllerProvider } from "./console-controller-context";
import { ConsoleControllerProvider, useConsole } from "./console-controller-context";
import { LoginScreen } from "./screens/login";
import "@fontsource-variable/archivo/standard.css";
import "@fontsource-variable/spline-sans-mono";
import "./style.css";

function App() { return <ConsoleControllerProvider><AppShell /></ConsoleControllerProvider>; }
function Gate() {
const { session, refresh } = useConsole();
if (session.loginRequired) return <LoginScreen gatewayOrigin={session.gatewayOrigin} onSuccess={() => { session.setLoginRequired(false); void refresh(); }} />;
return <AppShell />;
}
function App() { return <ConsoleControllerProvider><Gate /></ConsoleControllerProvider>; }
createRoot(document.getElementById("root")!).render(<React.StrictMode><App /></React.StrictMode>);
50 changes: 50 additions & 0 deletions admin/src/screens/login.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from "react";
import { LogIn, Route } from "lucide-react";
import { InlineError } from "../components";
import { localLogin } from "../ui-helpers";

export function LoginScreen({ gatewayOrigin, onSuccess }: { gatewayOrigin: string; onSuccess: () => void }) {
const [token, setToken] = React.useState("");
const [error, setError] = React.useState("");
const [busy, setBusy] = React.useState(false);

async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
const failure = await localLogin(gatewayOrigin, token.trim());
if (failure) setError(failure);
else onSuccess();
} catch {
setError("sign-in request failed; gateway unreachable");
} finally {
setBusy(false);
}
}

return (
<main className="loginShell">
<form className="loginCard" onSubmit={submit}>
<div className="brandBlock">
<span className="brandMark"><Route aria-hidden="true" /></span>
<div>
<strong>ClawRouter</strong>
<span>access gateway</span>
</div>
</div>
<h1>Sign in</h1>
<p>This self-hosted console uses local sign-in. Paste the admin token configured for this deployment.</p>
{error ? <InlineError message={error} /> : null}
<label>
<span>admin token</span>
<input type="password" autoComplete="current-password" autoFocus value={token} onChange={(event) => setToken(event.target.value)} />
</label>
<button type="submit" disabled={busy || !token.trim()}>
<LogIn className="buttonIcon" aria-hidden="true" />
<span>Sign in</span>
</button>
</form>
</main>
);
}
52 changes: 52 additions & 0 deletions admin/src/styles/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -537,3 +537,55 @@
margin-left: 0;
}
}

/* Local sign-in screen (self-host profile). */

.loginShell {
display: grid;
min-height: 100vh;
place-items: center;
background: var(--canvas);
padding: 24px;
}

.loginCard {
display: grid;
gap: 14px;
width: min(360px, 100%);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
padding: 0 22px 22px;
}

.loginCard .brandBlock {
margin: 0 -22px;
padding: 0 22px;
}

.loginCard h1 {
margin: 6px 0 0;
color: var(--ink);
font-size: 19px;
letter-spacing: -0.01em;
}

.loginCard p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}

.loginCard label {
display: grid;
gap: 5px;
}

.loginCard label span {
color: var(--muted);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.09em;
text-transform: uppercase;
}
22 changes: 22 additions & 0 deletions admin/src/ui-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,28 @@ export async function request<T>(baseUrl: string, path: string, init: RequestIni
return response.json() as Promise<T>;
}

export async function localLoginAvailable(baseUrl: string): Promise<boolean> {
try {
const index = await request<{ endpoints?: { sessionLogin?: unknown } }>(baseUrl, "/v1");
return typeof index.endpoints?.sessionLogin === "string";
} catch {
return false;
}
}

export async function localLogin(baseUrl: string, token: string): Promise<string | null> {
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/v1/session/login`, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token }),
});
if (response.ok) return null;
if (response.status === 401) return "invalid admin token";
if (response.status === 429) return "too many sign-in attempts; wait a minute and retry";
return `sign-in failed with status ${response.status}`;
}

export async function playgroundRequest(baseUrl: string, path: string, init: RequestInit = {}): Promise<PlaygroundHttpResponse> {
const headers = new Headers(init.headers);
const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, { ...init, credentials: "same-origin", headers });
Expand Down
8 changes: 7 additions & 1 deletion admin/src/use-console-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useUsage } from "./hooks/use-usage";
import { useSelfServiceKeys } from "./hooks/use-self-service-keys";
import { installAutoRefresh } from "./auto-refresh";
import { demo } from "./ui-config";
import { localDemoRole, oauthCallbackStatus, request, settled, usagePolicyId } from "./ui-helpers";
import { localDemoRole, localLoginAvailable, oauthCallbackStatus, request, settled, usagePolicyId } from "./ui-helpers";
import { syntheticUsageTimeline } from "./usage-analytics";
import type {
AccessUser,
Expand Down Expand Up @@ -126,6 +126,7 @@ export function useConsoleController() {
staticCatalog,
]);
session.setValue(sessionData);
session.setLoginRequired(false);
selfServiceKeys.setPrincipal(sessionData.email ?? "");
catalog.setProviders(providerData.providers);
catalog.setRoutes(routeData);
Expand Down Expand Up @@ -154,6 +155,11 @@ export function useConsoleController() {
if (!background) session.setStatus(warnings.length ? warnings.join("; ") : oauthCallbackStatus() ?? "connected");
} catch (caught) {
const message = errorMessage(caught);
if (message.includes("access_session_required") && await localLoginAvailable(session.gatewayOrigin)) {
session.setLoginRequired(true);
if (!background) session.setStatus("sign-in required");
return;
}
if (session.allowDemo) {
loadAdminDemo();
return;
Expand Down
8 changes: 8 additions & 0 deletions deploy/self-host/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# Required. SHA-256 digest only; keep the raw admin token in your secret manager.
CLAWROUTER_ADMIN_TOKEN_SHA256=

# Local console sign-in, opt-in: set to "enabled" to serve the dashboard
# sign-in form; sign in with the raw admin token. Without it the console
# stays API-only.
# CLAWROUTER_LOCAL_AUTH=enabled

# Identity recorded for local console sign-ins.
# CLAWROUTER_LOCAL_ADMIN_EMAIL=admin@local

# Provider bindings are discovered from the compiled provider snapshot.
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
Expand Down
32 changes: 32 additions & 0 deletions deploy/self-host/entrypoint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,29 @@ export function selfHostVariableNames(providerSnapshot, env) {
names.add(trimmed);
}
names.delete("CLAWROUTER_ADMIN_TOKEN_SHA256");
names.delete("CLAWROUTER_LOCAL_AUTH");
names.delete("CLAWROUTER_LOCAL_ADMIN_EMAIL");
return [...names]
.filter((name) => env[name] !== undefined && env[name] !== "")
.sort();
}

export function localAuthMode(env) {
const value = (env.CLAWROUTER_LOCAL_AUTH ?? "disabled").trim().toLowerCase();
if (!["enabled", "disabled"].includes(value)) {
throw new Error('CLAWROUTER_LOCAL_AUTH must be "enabled" or "disabled"');
}
return value;
}

export function localAdminEmail(env) {
const value = env.CLAWROUTER_LOCAL_ADMIN_EMAIL?.trim();
if (value && !(value.length <= 320 && /^[^\s@]+@[^\s@]+$/.test(value))) {
throw new Error("CLAWROUTER_LOCAL_ADMIN_EMAIL must be a valid email address");
}
return value || null;
}

function main() {
const adminTokenSha256 = process.env.CLAWROUTER_ADMIN_TOKEN_SHA256?.trim();
if (!adminTokenSha256) {
Expand All @@ -56,6 +74,15 @@ function main() {
fail("CLAWROUTER_ADMIN_TOKEN_SHA256 must be a 64-character hexadecimal SHA-256 digest");
}

let localAuth;
let adminEmail;
try {
localAuth = localAuthMode(process.env);
adminEmail = localAdminEmail(process.env);
} catch (error) {
fail(error.message);
}

const sourceConfig = readFileSync(join(root, "wrangler.toml"), "utf8");
writeFileSync(configPath, renderSelfHostConfig(sourceConfig), { mode: 0o600 });

Expand All @@ -76,7 +103,12 @@ function main() {
configPath,
"--var",
`CLAWROUTER_ADMIN_TOKEN_SHA256:${adminTokenSha256}`,
"--var",
`CLAWROUTER_LOCAL_AUTH:${localAuth}`,
];
if (adminEmail) {
args.push("--var", `CLAWROUTER_LOCAL_ADMIN_EMAIL:${adminEmail}`);
}
// Wrangler redacts secret-shaped bindings; --var makes Docker env explicit to local workerd.
for (const name of variableNames) {
args.push("--var", `${name}:${process.env[name]}`);
Expand Down
45 changes: 38 additions & 7 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,30 @@ For a custom manifest or another intentional Worker variable, add its name to
the comma-separated `CLAWROUTER_SELF_HOST_VARS` value. Never add the raw
`CLAWROUTER_ADMIN_TOKEN`; the Worker receives only its digest.

## Console sign-in

Local console sign-in is opt-in. Add `CLAWROUTER_LOCAL_AUTH=enabled` to
`deploy/self-host/.env`, recreate the container, then open
`http://localhost:8787/dashboard` and paste the raw admin token into the
sign-in form; the browser receives a 12-hour session cookie. Without the
flag the console stays API-only. Scripts can obtain the same cookie
directly:

```sh
curl --fail -c cookies.txt http://localhost:8787/v1/session/login \
-H 'content-type: application/json' \
--data "{\"token\": \"$CLAWROUTER_ADMIN_TOKEN\"}"
curl --fail -b cookies.txt http://localhost:8787/v1/session
```

The session authenticates the dashboard, the playground, and the
`/v1/session/*` endpoints (including self-service maintainer keys) as an
administrator identified by `CLAWROUTER_LOCAL_ADMIN_EMAIL` (default
`admin@local`). `POST /v1/session/logout` revokes the session. Sign-in
attempts are rate limited. Local sign-in is refused whenever Cloudflare
Access variables are configured, so it cannot be enabled on a managed
deployment.

## Create a proxy key

The normal key helper uses the running Worker's admin bearer-token API. It does
Expand Down Expand Up @@ -88,6 +112,12 @@ credentials, grants, budgets, settled usage records, and retained content.
Pending, delayed, or retrying local queue messages are memory-only and are lost
on a crash or restart; drain request traffic before planned maintenance.

Upgrading past 0.1.0 does not change the console posture: local sign-in is
opt-in, so the dashboard keeps failing closed until the operator sets
`CLAWROUTER_LOCAL_AUTH=enabled`. With the flag set, the dashboard shell and
`/v1/session/login` become reachable; the login still requires the admin
token, and every API behind the shell stays session-gated.

To upgrade a source checkout, back up `/data`, pull the new source, review the
release notes, then pull fresh base layers, rebuild, and restart:

Expand All @@ -98,10 +128,11 @@ docker compose -f deploy/self-host/docker-compose.yml up -d

## Version 1 limitations

Cloudflare Access is absent. Console sign-in, browser OAuth, and GitHub
maintainer auto-provisioning are unavailable. Manage the service through the
admin bearer-token API and repository scripts; clients use normal proxy keys.
The dashboard and Access-session endpoints remain fail-closed without an Access
identity. This profile is one local workerd process and does not provide
Cloudflare's distributed availability, durable queue delivery, or managed
backups.
Cloudflare Access is absent. GitHub maintainer auto-provisioning is
unavailable, browser OAuth connect flows are untested in this profile, and
local console sign-in currently supports a single admin-token identity
rather than per-user passwords.
Manage the service through the console session or the admin bearer-token API
and repository scripts; clients use normal proxy keys. This profile is one
local workerd process and does not provide Cloudflare's distributed
availability, durable queue delivery, or managed backups.
25 changes: 25 additions & 0 deletions test/self-host-entrypoint.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
localAdminEmail,
localAuthMode,
renderSelfHostConfig,
selfHostVariableNames,
} from "../deploy/self-host/entrypoint.mjs";
Expand Down Expand Up @@ -53,3 +55,26 @@ test("self-host vars include configured provider and explicit custom bindings",
/cannot be passed to the Worker/,
);
});

test("self-host vars exclude local-auth bindings owned by the entrypoint", () => {
const names = selfHostVariableNames({ providers: [] }, {
CLAWROUTER_LOCAL_AUTH: "enabled",
CLAWROUTER_LOCAL_ADMIN_EMAIL: "ops@example.com",
CUSTOM_BINDING: "custom",
CLAWROUTER_SELF_HOST_VARS: "CUSTOM_BINDING,CLAWROUTER_LOCAL_AUTH,CLAWROUTER_LOCAL_ADMIN_EMAIL",
});
assert.deepEqual(names, ["CUSTOM_BINDING"]);
});

test("local auth mode defaults to disabled and rejects unknown values", () => {
assert.equal(localAuthMode({}), "disabled");
assert.equal(localAuthMode({ CLAWROUTER_LOCAL_AUTH: " Enabled " }), "enabled");
assert.throws(() => localAuthMode({ CLAWROUTER_LOCAL_AUTH: "maybe" }), /must be "enabled" or "disabled"/);
});

test("local admin email is validated at startup instead of first sign-in", () => {
assert.equal(localAdminEmail({}), null);
assert.equal(localAdminEmail({ CLAWROUTER_LOCAL_ADMIN_EMAIL: " ops@example.com " }), "ops@example.com");
assert.throws(() => localAdminEmail({ CLAWROUTER_LOCAL_ADMIN_EMAIL: "admin local" }), /valid email address/);
assert.throws(() => localAdminEmail({ CLAWROUTER_LOCAL_ADMIN_EMAIL: "admin@" }), /valid email address/);
});
Loading