feat: add ClawHub docs auth broker

This commit is contained in:
Peter Steinberger
2026-05-06 21:08:39 +01:00
parent 8d5eb14919
commit bec9362361
11 changed files with 371 additions and 1 deletions
+7
View File
@@ -42,6 +42,7 @@ import {
transfersGetRouterV1Http,
usersListV1Http,
usersPostRouterV1Http,
verifyDocsSessionV1Http,
whoamiV1Http,
} from "./httpApiV1";
import { preflightHandler } from "./httpPreflight";
@@ -188,6 +189,12 @@ http.route({
handler: whoamiV1Http,
});
http.route({
path: "/api/v1/docs/session/verify",
method: "GET",
handler: verifyDocsSessionV1Http,
});
http.route({
pathPrefix: `${ApiRoutes.users}/`,
method: "POST",
+3
View File
@@ -1,4 +1,5 @@
import { httpAction } from "./functions";
import { verifyDocsSessionV1Handler } from "./httpApiV1/docsSessionV1";
import {
listBundlePluginsV1Handler,
listCodePluginsV1Handler,
@@ -44,6 +45,7 @@ export const mintPublishTokenV1Http = httpAction(mintPublishTokenV1Handler);
export const npmMirrorGetHttp = httpAction(npmMirrorGetHandler);
export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler);
export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler);
export const verifyDocsSessionV1Http = httpAction(verifyDocsSessionV1Handler);
export const searchSkillsV1Http = httpAction(searchSkillsV1Handler);
export const resolveSkillVersionV1Http = httpAction(resolveSkillVersionV1Handler);
@@ -79,6 +81,7 @@ export const __handlers = {
npmMirrorGetHandler,
listCodePluginsV1Handler,
listBundlePluginsV1Handler,
verifyDocsSessionV1Handler,
searchSkillsV1Handler,
resolveSkillVersionV1Handler,
listSkillsV1Handler,
+34
View File
@@ -0,0 +1,34 @@
import { internal } from "../_generated/api";
import type { ActionCtx } from "../_generated/server";
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
import { applyRateLimit } from "../lib/httpRateLimit";
import { json, text } from "./shared";
export async function verifyDocsSessionV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
try {
const userId = await getOptionalActiveAuthUserIdFromAction(ctx);
if (!userId) return text("Unauthorized", 401, rate.headers);
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
if (!user || user.deletedAt || user.deactivatedAt) {
return text("Unauthorized", 401, rate.headers);
}
return json(
{
provider: "github",
user: {
id: user._id,
handle: user.handle ?? user.name ?? null,
displayName: user.displayName ?? null,
image: user.image ?? null,
},
},
200,
rate.headers,
);
} catch {
return text("Unauthorized", 401, rate.headers);
}
}
+32
View File
@@ -0,0 +1,32 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import { buildDocsAuthCallbackUrl, normalizeDocsReturnTo } from "./docsAuth";
describe("docs auth helpers", () => {
it("allows documentation return URLs and rejects unrelated origins", () => {
expect(normalizeDocsReturnTo("https://documentation.openclaw.ai/concepts/models")).toBe(
"https://documentation.openclaw.ai/concepts/models",
);
expect(normalizeDocsReturnTo("https://docs.openclaw.ai/install")).toBe(
"https://docs.openclaw.ai/install",
);
expect(normalizeDocsReturnTo("https://example.com/docs")).toBeNull();
expect(normalizeDocsReturnTo("javascript:alert(1)")).toBeNull();
});
it("uses the production docs callback for public docs hosts", () => {
expect(buildDocsAuthCallbackUrl("https://documentation.openclaw.ai/concepts/models")).toBe(
"https://documentation.openclaw.ai/ask-molty/auth/callback",
);
expect(buildDocsAuthCallbackUrl("https://docs.openclaw.ai/concepts/models")).toBe(
"https://documentation.openclaw.ai/ask-molty/auth/callback",
);
});
it("keeps local callbacks local for dev", () => {
expect(buildDocsAuthCallbackUrl("http://localhost:4173/start")).toBe(
"http://localhost:4173/ask-molty/auth/callback",
);
});
});
+31
View File
@@ -0,0 +1,31 @@
const allowedDocsOrigins = new Set([
"https://documentation.openclaw.ai",
"https://docs.openclaw.ai",
"http://localhost:4173",
"http://127.0.0.1:4173",
]);
const productionDocsOrigin = "https://documentation.openclaw.ai";
export function normalizeDocsReturnTo(value?: string | null) {
if (!value) return null;
try {
const url = new URL(value);
if (!allowedDocsOrigins.has(url.origin)) return null;
if (!["http:", "https:"].includes(url.protocol)) return null;
return url.href;
} catch {
return null;
}
}
export function buildDocsAuthCallbackUrl(returnTo: string) {
const normalized = normalizeDocsReturnTo(returnTo);
if (!normalized) return null;
const url = new URL(normalized);
const callbackOrigin =
url.hostname === "localhost" || url.hostname === "127.0.0.1"
? url.origin
: productionDocsOrigin;
return `${callbackOrigin}/ask-molty/auth/callback`;
}
+9
View File
@@ -7,6 +7,7 @@ import {
getClawHubSiteUrl,
getOnlyCrabsHost,
getOnlyCrabsSiteUrl,
isClawHubHost,
getSiteDescription,
getSiteMode,
getSiteName,
@@ -88,6 +89,14 @@ describe("site helpers", () => {
});
});
it("accepts both ClawHub domains as ClawHub hosts", () => {
expect(isClawHubHost("clawhub.ai")).toBe(true);
expect(isClawHubHost("www.clawhub.ai")).toBe(true);
expect(isClawHubHost("hub.openclaw.ai")).toBe(true);
expect(isClawHubHost("clawdhub.com")).toBe(false);
expect(isClawHubHost("example.com")).toBe(false);
});
it("detects site mode from window when available", () => {
withServerEnv({ VITE_SOULHUB_HOST: "onlycrabs.ai" }, () => {
vi.stubGlobal("window", { location: { hostname: "onlycrabs.ai" } } as unknown as Window);
+11
View File
@@ -6,6 +6,7 @@ const DEFAULT_CLAWHUB_SITE_URL = "https://clawhub.ai";
const DEFAULT_ONLYCRABS_SITE_URL = "https://onlycrabs.ai";
const DEFAULT_ONLYCRABS_HOST = "onlycrabs.ai";
const LEGACY_CLAWDHUB_HOSTS = new Set(["clawdhub.com", "www.clawdhub.com", "auth.clawdhub.com"]);
const OPENCLAW_CLAWHUB_HOSTS = new Set(["hub.openclaw.ai"]);
export function normalizeClawHubSiteOrigin(value?: string | null) {
if (!value) return null;
@@ -20,6 +21,16 @@ export function normalizeClawHubSiteOrigin(value?: string | null) {
}
}
export function isClawHubHost(host?: string | null) {
if (!host) return false;
const normalized = host.toLowerCase();
return (
normalized === "clawhub.ai" ||
normalized === "www.clawhub.ai" ||
OPENCLAW_CLAWHUB_HOSTS.has(normalized)
);
}
export function getClawHubSiteUrl() {
return normalizeClawHubSiteOrigin(getRuntimeEnv("VITE_SITE_URL")) ?? DEFAULT_CLAWHUB_SITE_URL;
}
+21
View File
@@ -34,6 +34,7 @@ import { Route as PluginsNameRouteImport } from './routes/plugins/$name'
import { Route as PackagesNewRouteImport } from './routes/packages/new'
import { Route as PackagesNameRouteImport } from './routes/packages/$name'
import { Route as OrgsHandleRouteImport } from './routes/orgs/$handle'
import { Route as DocsAuthRouteImport } from './routes/docs/auth'
import { Route as CliAuthRouteImport } from './routes/cli/auth'
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
import { Route as PluginsScopeNameRouteImport } from './routes/plugins/$scope/$name'
@@ -168,6 +169,11 @@ const OrgsHandleRoute = OrgsHandleRouteImport.update({
path: '/orgs/$handle',
getParentRoute: () => rootRouteImport,
} as any)
const DocsAuthRoute = DocsAuthRouteImport.update({
id: '/docs/auth',
path: '/docs/auth',
getParentRoute: () => rootRouteImport,
} as any)
const CliAuthRoute = CliAuthRouteImport.update({
id: '/cli/auth',
path: '/cli/auth',
@@ -228,6 +234,7 @@ export interface FileRoutesByFullPath {
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
'/cli/auth': typeof CliAuthRoute
'/docs/auth': typeof DocsAuthRoute
'/orgs/$handle': typeof OrgsHandleRoute
'/packages/$name': typeof PackagesNameRoute
'/packages/new': typeof PackagesNewRoute
@@ -263,6 +270,7 @@ export interface FileRoutesByTo {
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
'/cli/auth': typeof CliAuthRoute
'/docs/auth': typeof DocsAuthRoute
'/orgs/$handle': typeof OrgsHandleRoute
'/packages/$name': typeof PackagesNameRoute
'/packages/new': typeof PackagesNewRoute
@@ -299,6 +307,7 @@ export interface FileRoutesById {
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
'/cli/auth': typeof CliAuthRoute
'/docs/auth': typeof DocsAuthRoute
'/orgs/$handle': typeof OrgsHandleRoute
'/packages/$name': typeof PackagesNameRoute
'/packages/new': typeof PackagesNewRoute
@@ -336,6 +345,7 @@ export interface FileRouteTypes {
| '/upload'
| '/$owner/$slug'
| '/cli/auth'
| '/docs/auth'
| '/orgs/$handle'
| '/packages/$name'
| '/packages/new'
@@ -371,6 +381,7 @@ export interface FileRouteTypes {
| '/upload'
| '/$owner/$slug'
| '/cli/auth'
| '/docs/auth'
| '/orgs/$handle'
| '/packages/$name'
| '/packages/new'
@@ -406,6 +417,7 @@ export interface FileRouteTypes {
| '/upload'
| '/$owner/$slug'
| '/cli/auth'
| '/docs/auth'
| '/orgs/$handle'
| '/packages/$name'
| '/packages/new'
@@ -442,6 +454,7 @@ export interface RootRouteChildren {
UploadRoute: typeof UploadRoute
OwnerSlugRoute: typeof OwnerSlugRouteWithChildren
CliAuthRoute: typeof CliAuthRoute
DocsAuthRoute: typeof DocsAuthRoute
OrgsHandleRoute: typeof OrgsHandleRoute
PackagesNameRoute: typeof PackagesNameRoute
PackagesNewRoute: typeof PackagesNewRoute
@@ -635,6 +648,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OrgsHandleRouteImport
parentRoute: typeof rootRouteImport
}
'/docs/auth': {
id: '/docs/auth'
path: '/docs/auth'
fullPath: '/docs/auth'
preLoaderRoute: typeof DocsAuthRouteImport
parentRoute: typeof rootRouteImport
}
'/cli/auth': {
id: '/cli/auth'
path: '/cli/auth'
@@ -747,6 +767,7 @@ const rootRouteChildren: RootRouteChildren = {
UploadRoute: UploadRoute,
OwnerSlugRoute: OwnerSlugRouteWithChildren,
CliAuthRoute: CliAuthRoute,
DocsAuthRoute: DocsAuthRoute,
OrgsHandleRoute: OrgsHandleRoute,
PackagesNameRoute: PackagesNameRoute,
PackagesNewRoute: PackagesNewRoute,
+100
View File
@@ -0,0 +1,100 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
let mockSearch: { return_to?: string } = {};
let mockAuthToken: string | null = "convex.jwt";
let mockAuthStatus = {
isAuthenticated: true,
isLoading: false,
me: { _id: "user_123" } as { _id: string } | null,
};
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { component: unknown }) => ({
...config,
useSearch: () => mockSearch,
}),
}));
vi.mock("@convex-dev/auth/react", () => ({
useAuthToken: () => mockAuthToken,
}));
vi.mock("../../lib/useAuthStatus", () => ({
useAuthStatus: () => mockAuthStatus,
}));
vi.mock("../../lib/useAuthError", () => ({
useAuthError: () => ({ error: null, clear: vi.fn() }),
}));
vi.mock("../../components/layout/Container", () => ({
Container: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("../../components/SignInButton", () => ({
SignInButton: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
}));
vi.mock("../../components/ui/button", () => ({
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
}));
vi.mock("../../components/ui/card", () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardTitle: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
}));
const { DocsAuth } = await import("./auth");
describe("DocsAuth", () => {
beforeEach(() => {
mockSearch = { return_to: "https://documentation.openclaw.ai/concepts/models" };
mockAuthToken = "convex.jwt";
mockAuthStatus = {
isAuthenticated: true,
isLoading: false,
me: { _id: "user_123" },
};
});
it("posts the ClawHub auth token to the docs callback", () => {
render(<DocsAuth autoSubmit={false} />);
const form = screen.getByRole("button", { name: /continue to docs/i }).closest("form");
expect(form?.getAttribute("method")).toBe("post");
expect(form?.getAttribute("action")).toBe(
"https://documentation.openclaw.ai/ask-molty/auth/callback",
);
expect(document.querySelector<HTMLInputElement>('input[name="token"]')?.value).toBe(
"convex.jwt",
);
expect(document.querySelector<HTMLInputElement>('input[name="return_to"]')?.value).toBe(
"https://documentation.openclaw.ai/concepts/models",
);
});
it("asks for GitHub verification when no ClawHub session exists", () => {
mockAuthStatus = { isAuthenticated: false, isLoading: false, me: null };
render(<DocsAuth autoSubmit={false} />);
expect(screen.getByRole("heading", { name: /verify with github/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /verify with github/i })).toBeTruthy();
});
it("rejects unsafe return URLs", () => {
mockSearch = { return_to: "https://example.com/steal" };
render(<DocsAuth autoSubmit={false} />);
expect(screen.getByText(/invalid docs return url/i)).toBeTruthy();
});
});
+122
View File
@@ -0,0 +1,122 @@
import { useAuthToken } from "@convex-dev/auth/react";
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useMemo, useRef } from "react";
import { Container } from "../../components/layout/Container";
import { SignInButton } from "../../components/SignInButton";
import { Button } from "../../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
import { buildDocsAuthCallbackUrl, normalizeDocsReturnTo } from "../../lib/docsAuth";
import { getClawHubSiteUrl, normalizeClawHubSiteOrigin } from "../../lib/site";
import { useAuthError } from "../../lib/useAuthError";
import { useAuthStatus } from "../../lib/useAuthStatus";
export const Route = createFileRoute("/docs/auth")({
component: DocsAuth,
});
type DocsAuthProps = {
autoSubmit?: boolean;
};
export function DocsAuth({ autoSubmit = true }: DocsAuthProps = {}) {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const authToken = useAuthToken();
const { error: authError, clear: clearAuthError } = useAuthError();
const formRef = useRef<HTMLFormElement>(null);
const submittedRef = useRef(false);
const search = Route.useSearch() as { return_to?: string };
const returnTo = normalizeDocsReturnTo(search.return_to);
const callbackUrl = returnTo ? buildDocsAuthCallbackUrl(returnTo) : null;
const registry = useMemo(() => {
if (typeof window !== "undefined") {
return normalizeClawHubSiteOrigin(window.location.origin) ?? getClawHubSiteUrl();
}
return getClawHubSiteUrl();
}, []);
const canReturn = Boolean(returnTo && callbackUrl);
const ready = canReturn && isAuthenticated && me && authToken;
useEffect(() => {
if (!autoSubmit || !ready || submittedRef.current) return;
submittedRef.current = true;
formRef.current?.submit();
}, [autoSubmit, ready]);
if (!canReturn) {
return (
<AuthFrame title="Docs agent login">
<p className="text-sm text-[color:var(--ink-soft)]">Invalid docs return URL.</p>
<p className="text-sm text-[color:var(--ink-soft)]">
Open Ask Molty from the OpenClaw documentation page and try again.
</p>
</AuthFrame>
);
}
if (!isAuthenticated || !me) {
return (
<AuthFrame title="Verify with GitHub">
<p className="text-sm text-[color:var(--ink-soft)]">
Sign in to ClawHub with GitHub to unlock Ask Molty on the OpenClaw docs.
</p>
{authError ? (
<p
className="rounded-[var(--radius-sm)] border border-red-300/40 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300"
role="alert"
>
{authError}{" "}
<button
type="button"
onClick={clearAuthError}
aria-label="Dismiss"
className="cursor-pointer border-none bg-transparent px-0.5 text-inherit"
>
&times;
</button>
</p>
) : null}
<SignInButton disabled={isLoading}>Verify with GitHub</SignInButton>
</AuthFrame>
);
}
if (!authToken) {
return (
<AuthFrame title="Connecting docs">
<p className="text-sm text-[color:var(--ink-soft)]">Preparing your ClawHub session.</p>
</AuthFrame>
);
}
return (
<AuthFrame title="Connecting docs">
<p className="text-sm text-[color:var(--ink-soft)]">
Returning to the OpenClaw docs with your ClawHub login.
</p>
<form ref={formRef} method="post" action={callbackUrl ?? undefined}>
<input type="hidden" name="token" value={authToken} />
<input type="hidden" name="return_to" value={returnTo ?? ""} />
<input type="hidden" name="registry" value={registry} />
<Button type="submit" variant="primary">
Continue to docs
</Button>
</form>
</AuthFrame>
);
}
function AuthFrame({ title, children }: { title: string; children: React.ReactNode }) {
return (
<main className="py-10">
<Container size="narrow">
<Card>
<CardHeader>
<CardTitle className="text-2xl">{title}</CardTitle>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
</Container>
</main>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests"
"value": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self' https://documentation.openclaw.ai http://localhost:4173 http://127.0.0.1:4173; upgrade-insecure-requests"
},
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "DENY" },