Skip to content
Merged
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
10 changes: 10 additions & 0 deletions apps/cloud/src/auth/route-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Route sets shared between the SSR auth gate (server) and the root
// AuthGate (client) — one source of truth so the two layers can't disagree
// about which pages a given auth state may see. Pure data, safe in both
// bundles.

/** Pages that render for SIGNED-OUT visitors (the gate lets them through). */
export const PUBLIC_PATHS = new Set(["/login"]);

/** Pages an authenticated-but-org-less user is FOR (everything else redirects to onboarding). */
export const ONBOARDING_PATHS = new Set(["/create-org", "/setup-mcp"]);
146 changes: 128 additions & 18 deletions apps/cloud/src/auth/ssr-gate.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,51 @@
// ---------------------------------------------------------------------------
// SSR auth gate — the server-side session check for DOCUMENT requests.
//
// Before this gate, every signed-out visitor was served the SPA, whose root
// AuthGate SSRs the AUTHENTICATED app-shell skeleton until a client-side
// `/account/me` round trip 401s — the "bad skeleton on unauthed state" flash.
// The sealed `wos-session` cookie can be verified right here in the worker
// The sealed `wos-session` cookie is verified right here in the worker
// (unseal + JWT check against cached JWKS — no per-request WorkOS round trip
// except token refresh), so signed-out visitors are 302'd to /login before any
// app HTML exists, and signed-in visitors proceed knowing the session is real.
// except token refresh), so by the time the SPA is served the server KNOWS
// who it's serving:
//
// - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists
// - org-less → 302 /create-org (onboarding owns those sessions)
// - signed in → the document is served WITH the verified identity: the
// auth-hint travels to the SSR render via request-middleware context (the
// root loader picks it up), and is minted as a cookie when the browser
// doesn't hold a current one — so the very first paint is the real app
// shell, never a skeleton. The hint is display-only; /account/me remains
// the authority and the client keeps it fresh from then on.
//
// Scope: GET/HEAD requests that are document navigations (sec-fetch-dest /
// accept), excluding app-owned paths (/api, /mcp — they answer for themselves
// earlier in the middleware chain). Everything else passes through untouched.
// ---------------------------------------------------------------------------

import { createMiddleware } from "@tanstack/react-start";
import { Effect, Exit, ManagedRuntime } from "effect";
import { Effect, Exit, Layer, ManagedRuntime } from "effect";

import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint";
import {
AUTH_HINT_COOKIE,
AUTH_HINT_MAX_AGE_SECONDS,
decodeAuthHint,
encodeAuthHint,
type AuthHint,
} from "@executor-js/react/multiplayer/auth-hint";

import { isAppOwnedPath } from "../app-paths";
import { makeDbLayer } from "../db/db";
import { makeUserStoreLayer, UserStoreService } from "./context";
import { parseCookie } from "./cookies";
import { sealedSessionDisplayName } from "./middleware";
import { loginPath, safeReturnTo } from "./return-to";
import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths";
import { WorkOSClient } from "./workos";

const SESSION_COOKIE = "wos-session";
/** Mirrors the handlers' COOKIE_OPTIONS (path /, HttpOnly, Lax, 7d, Secure). */
const SESSION_COOKIE_ATTRIBUTES = "Path=/; HttpOnly; Secure; SameSite=Lax";
const SESSION_MAX_AGE = 60 * 60 * 24 * 7;
/** Same attributes the client write uses — minus HttpOnly: the SPA reads it. */
const HINT_COOKIE_ATTRIBUTES = "Path=/; Secure; SameSite=Lax";

const isDocumentRequest = (request: Request): boolean => {
if (request.method !== "GET" && request.method !== "HEAD") return false;
Expand All @@ -48,7 +66,14 @@ const isDocumentRequest = (request: Request): boolean => {
let runtime: ManagedRuntime.ManagedRuntime<WorkOSClient, unknown> | undefined;
const getRuntime = () => (runtime ??= ManagedRuntime.make(WorkOSClient.Default));

type VerifiedSession = { readonly refreshedSession?: string | undefined };
type VerifiedSession = {
readonly userId: string;
readonly email: string;
readonly name: string | null;
readonly avatarUrl: string | null;
readonly organizationId: string | null;
readonly refreshedSession?: string | undefined;
};

// EVERY failure collapses to "signed out" — WorkOS errors inside the effect
// and layer-construction errors like a bad cookie password (runPromiseExit
Expand All @@ -58,9 +83,75 @@ const verifySession = async (sealed: string): Promise<VerifiedSession | null> =>
const exit = await getRuntime().runPromiseExit(
Effect.flatMap(WorkOSClient.asEffect(), (workos) => workos.authenticateSealedSession(sealed)),
);
return Exit.isSuccess(exit) ? exit.value : null;
if (!Exit.isSuccess(exit) || exit.value === null) return null;
const result = exit.value;
return {
userId: result.userId,
email: result.email,
name: sealedSessionDisplayName(result),
avatarUrl: result.avatarUrl ?? null,
organizationId: result.organizationId ?? null,
refreshedSession: result.refreshedSession,
};
};

// ── Auth hint ────────────────────────────────────────────────────────────────

/**
* The hint this request should be served with: the browser's own cookie when
* it already matches the verified identity, else one minted fresh from the
* session. `mint` is set when the cookie must also be (re)written — identity
* data freshness (a renamed user/org) is the CLIENT's job via /account/me,
* so the gate only steps in when the ids are wrong, never to rewrite display
* fields (which would ping-pong with the client's authoritative write).
*/
const resolveAuthHint = async (
session: VerifiedSession,
cookieHeader: string | null,
): Promise<{ hint: AuthHint; mint: boolean }> => {
const existing = decodeAuthHint(parseCookie(cookieHeader, AUTH_HINT_COOKIE));
if (
existing &&
existing.user.id === session.userId &&
(existing.organization?.id ?? null) === session.organizationId
) {
return { hint: existing, mint: false };
}
return {
hint: {
v: 1,
user: {
id: session.userId,
email: session.email,
name: session.name,
avatarUrl: session.avatarUrl,
},
organization: session.organizationId
? { id: session.organizationId, name: await organizationName(session.organizationId) }
: null,
},
mint: true,
};
};

// The sealed session carries the org ID but not its name; the local mirror
// has it. Only consulted when minting (absent/mismatched hint) — never on the
// steady-state path — and over per-request layers, because a connection cached
// in the shared runtime would be reused across requests, which Cloudflare
// forbids. A miss or failure reads as "" — display-only, corrected by the
// client's /account/me write.
const organizationName = async (organizationId: string): Promise<string> => {
const exit = await getRuntime().runPromiseExit(
Effect.flatMap(UserStoreService.asEffect(), (users) =>
users.use((store) => store.getOrganization(organizationId)),
).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))),
);
return Exit.isSuccess(exit) ? (exit.value?.name ?? "") : "";
};

const hintSetCookie = (hint: AuthHint) =>
`${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`;

const sessionSetCookie = (sealed: string) =>
`${SESSION_COOKIE}=${sealed}; ${SESSION_COOKIE_ATTRIBUTES}; Max-Age=${SESSION_MAX_AGE}`;

Expand Down Expand Up @@ -88,12 +179,13 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server(
async ({ pathname, request, next }) => {
if (isAppOwnedPath(pathname) || !isDocumentRequest(request)) return next();

const sealed = parseCookie(request.headers.get("cookie"), SESSION_COOKIE);
const cookieHeader = request.headers.get("cookie");
const sealed = parseCookie(cookieHeader, SESSION_COOKIE);
const url = new URL(request.url);

// /login is the one page signed-out visitors are FOR; a signed-in visitor
// landing here is bounced straight back to where they were headed.
if (pathname === "/login") {
// Public pages are what signed-out visitors are FOR; a signed-in visitor
// landing on /login is bounced straight back to where they were headed.
if (PUBLIC_PATHS.has(pathname)) {
const session = sealed ? await verifySession(sealed) : null;
if (!session) return next();
return redirect(safeReturnTo(url.searchParams.get("returnTo")) ?? "/", {
Expand All @@ -118,14 +210,32 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server(
return redirect("/", { refreshedSession: session.refreshedSession });
}

const result = await next();
// A session with no organization belongs in onboarding — same decision
// the client AuthGate makes mid-session, made here before the document
// exists so the app shell is never painted for an org-less session.
if (!session.organizationId && !ONBOARDING_PATHS.has(pathname)) {
return redirect("/create-org", { refreshedSession: session.refreshedSession });
}

// Serve the document WITH the verified identity: the hint rides to the
// SSR render through middleware context (the root loader reads it), so
// the server paints the real authenticated shell — no loading state, no
// skeleton. Set-cookie writes ride on the rendered response.
const { hint, mint } = await resolveAuthHint(session, cookieHeader);
const result = await next({ context: { authHint: hint } });
if (!mint && !session.refreshedSession) return result;

const response = new Response(result.response.body, result.response);
if (mint) {
// The browser holds no current hint — mint one so the NEXT load (and
// any client-side read) sees the same identity this render used.
response.headers.append("set-cookie", hintSetCookie(hint));
}
if (session.refreshedSession) {
// WorkOS refresh tokens are single-use: the rotated sealed session MUST
// reach the browser or the next expiry logs the user out.
const response = new Response(result.response.body, result.response);
response.headers.append("set-cookie", sessionSetCookie(session.refreshedSession));
return response;
}
return result;
return { ...result, response };
},
);
116 changes: 33 additions & 83 deletions apps/cloud/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,17 @@ import { PostHogProvider } from "posthog-js/react";
import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";
import { ExecutorProvider } from "@executor-js/react/api/provider";
import { OrganizationProvider } from "@executor-js/react/api/organization-context";
import { Skeleton } from "@executor-js/react/components/skeleton";
import { Toaster } from "@executor-js/react/components/sonner";
import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
import { plugins as clientPlugins } from "virtual:executor/plugins-client";
import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint";
import { AuthProvider, useAuth } from "../web/auth";
import { loginPath } from "../auth/return-to";
import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths";
import { SupportOptions } from "../web/components/support-options";
import { Shell } from "../web/shell";
import appCss from "@executor-js/react/globals.css?url";

const ONBOARDING_PATHS = new Set(["/create-org", "/setup-mcp"]);
// Routes that render for SIGNED-OUT visitors — the auth gate stays out of
// their way entirely (the SSR gate in auth/ssr-gate.ts bounces signed-in
// visitors off /login before the document is even served).
const PUBLIC_PATHS = new Set(["/login"]);

if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_PUBLIC_SENTRY_DSN,
Expand Down Expand Up @@ -98,6 +93,16 @@ function NotFoundPage() {

export const Route = createRootRoute({
notFoundComponent: NotFoundPage,
// The verified identity the SSR gate attached to this document request
// (ssr-gate.ts → middleware context → serverContext). Loader data is
// dehydrated, so the client's first render sees the SAME hint the server
// rendered with — the two can't disagree. Client-side re-runs have no
// serverContext and return null, which is fine: the hint only seeds
// initial state (AuthProvider holds it from there).
loader: (opts) => ({
authHint:
(opts as { serverContext?: { authHint?: AuthHint | null } }).serverContext?.authHint ?? null,
}),
head: () => ({
meta: [
{ charSet: "utf-8" },
Expand Down Expand Up @@ -137,74 +142,22 @@ function RootDocument({ children }: { children: React.ReactNode }) {
}

function RootComponent() {
const { authHint } = Route.useLoaderData();
return (
<PostHogProvider client={posthog}>
<AuthProvider>
<AuthProvider initialHint={authHint}>
<AuthGate />
</AuthProvider>
</PostHogProvider>
);
}

function ShellSkeleton() {
return (
<div className="flex h-screen overflow-hidden">
{/* Desktop sidebar skeleton */}
<aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
<div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">
<Skeleton className="h-4 w-20" />
</div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
<Skeleton className="h-7 w-full rounded-md" />
<Skeleton className="h-7 w-full rounded-md" />
<Skeleton className="h-7 w-full rounded-md" />
<Skeleton className="h-7 w-full rounded-md" />
<div className="mt-5 mb-2 px-2.5">
<Skeleton className="h-3 w-14" />
</div>
<div className="flex flex-col gap-1">
<Skeleton className="h-7 w-11/12 rounded-md" />
<Skeleton className="h-7 w-10/12 rounded-md" />
<Skeleton className="h-7 w-9/12 rounded-md" />
</div>
</nav>
<div className="shrink-0 border-t border-sidebar-border px-3 py-2.5">
<div className="flex items-center gap-2.5">
<Skeleton className="size-7 rounded-full" />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-16" />
</div>
</div>
</div>
</aside>

{/* Main content skeleton */}
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Mobile top bar */}
<div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4 md:hidden">
<Skeleton className="size-7 rounded-md" />
<Skeleton className="h-4 w-20" />
<div className="w-7 shrink-0" />
</div>

<div className="flex min-h-0 flex-1 flex-col gap-6 px-6 py-8">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-2">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-8 w-28 rounded-md" />
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-lg" />
))}
</div>
</div>
</main>
</div>
);
// Neutral, layout-free placeholder for the moments no UI is correct yet: a
// redirect in flight, or the (post-gate, near-impossible) hint-less verified
// load. Never the app shell's silhouette — that bet is the bug this file's
// gate exists to prevent.
function BlankScreen() {
return <div className="h-screen bg-background" />;
}

function ShellErrorFallback() {
Expand Down Expand Up @@ -236,6 +189,9 @@ function AuthGate() {
const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname);
const isPublicRoute = PUBLIC_PATHS.has(location.pathname);

// The SSR gate already bounced fresh org-less document requests to
// /create-org; this catches the MID-SESSION transitions (org deleted,
// membership revoked → /account/me now reports no org).
const needsOrgRedirect =
auth.status === "authenticated" &&
auth.organization == null &&
Expand All @@ -262,35 +218,29 @@ function AuthGate() {
return <Outlet />;
}

// Signed-out visitors never reach this point on a fresh document request —
// the SSR gate (auth/ssr-gate.ts) redirected them to /login before the SPA
// was served. So "loading" here means a VERIFIED signed-in user whose
// /account/me is still in flight (and usually not even that: the auth-hint
// cookie resolves them to "authenticated" a frame after mount), making the
// app-shell skeleton the right placeholder again.
if (auth.status === "loading") {
return <ShellSkeleton />;
}

// Mid-session sign-out (logout elsewhere, expiry): blank for the moment the
// redirect effect above needs — never a skeleton for an app they're out of.
if (auth.status === "unauthenticated") {
return <div className="h-screen bg-background" />;
// Every state that isn't "authenticated with an org, on a page that wants
// the shell" is a moment between redirects or an edge the gates make
// near-impossible (a verified user whose hint hasn't seeded yet). Neutral
// blank — the one placeholder that's correct whatever happens next. The
// app-shell skeleton this file used to render here is exactly the
// wrong-UI flash the SSR gate + hint exist to prevent.
if (auth.status === "loading" || auth.status === "unauthenticated") {
return <BlankScreen />;
}

if (isOnboardingRoute) {
return <Outlet />;
}

if (auth.organization == null) {
return <ShellSkeleton />;
return <BlankScreen />;
}

return (
<AutumnProvider pathPrefix="/api/billing">
<Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}>
<ExecutorProvider onHandledError={captureFrontendError}>
<React.Suspense fallback={<ShellSkeleton />}>
<React.Suspense fallback={<BlankScreen />}>
<ExecutorPluginsProvider plugins={clientPlugins}>
<OrganizationProvider organizationId={auth.organization.id}>
<Shell />
Expand Down
Loading
Loading