Skip to content

feat(dev): ENABLE_DEV_AUTH — run the stack without Clerk for local dev#145

Open
DIodide wants to merge 3 commits into
stagingfrom
feat/dev-auth
Open

feat(dev): ENABLE_DEV_AUTH — run the stack without Clerk for local dev#145
DIodide wants to merge 3 commits into
stagingfrom
feat/dev-auth

Conversation

@DIodide

@DIodide DIodide commented Jun 21, 2026

Copy link
Copy Markdown
Owner

What

An opt-in, prod-safe flag that bypasses Clerk end-to-end so the app can run loginless locally (e.g. to capture screenshots) without a Clerk project. Off by default.

Prod-safe by construction

When the flag is unset, every layer behaves byte-for-byte as before — the changes are pure pass-throughs:

Layer Change When flag OFF
Convex getIdentity(ctx) helper (convex/authDev.ts); all 72 ctx.auth.getUserIdentity() call sites (17 files) now call it === ctx.auth.getUserIdentity()
FastAPI verify_token/verify_token_optional short-circuit to a dev user when settings.enable_dev_auth unchanged JWT verification
Frontend @/lib/auth re-exports Clerk's useAuth/useUser/useClerk/useReverification; 25 sites import from it; __root.tsx skips ClerkProvider in dev re-exports the real Clerk hooks

All three layers share DEV_USER_ID = "dev-user". Turn it on with VITE_ENABLE_DEV_AUTH=true (web), ENABLE_DEV_AUTH=true (FastAPI), and npx convex env set ENABLE_DEV_AUTH true (Convex). Documented in all three .env.example.

Verification

  • Convex 199 tests pass (the suite uses withIdentity, which exercises the delegate path through all 72 swapped sites) + a new unit test covering both branches of getIdentity.
  • FastAPI 337 tests pass, incl. 3 new dev-auth cases (bypass returns the dev user; off-by-default still 401s).
  • Frontend 235 tests pass; tsc clean; biome clean.

Honest caveat

The off-path is fully test-verified. The in-browser dev path can't be exercised here (no running stack), and there's one known subtlety: the fake token won't complete the Convex client auth handshake, so app.tsx gates the settings fetch on convexReady || DEV_AUTH. This needs a quick functional check on a running stack — if ConvexProviderWithClerk rejects the "dev-auth" token outright, switch the dev getToken to return null. Flagging so it's reviewed before relying on it.

Also

scripts/dev-screenshots.py (Playwright) captures product shots once the stack runs with dev auth — the intended path to refresh the README's screenshots.

🤖 Generated with Claude Code

…cal dev

Adds an opt-in, prod-safe flag that bypasses Clerk end-to-end so the app can be
run loginless (e.g. to capture screenshots) without a Clerk project.

Off by default and prod-safe BY CONSTRUCTION — when the flag is unset every
layer behaves byte-for-byte as before:

- Convex: a `getIdentity(ctx)` helper (convex/authDev.ts) that returns a fixed
  dev identity when the deployment env `ENABLE_DEV_AUTH=true`, else delegates to
  `ctx.auth.getUserIdentity()`. All 72 call sites across 17 files now call it.
  The 199-test suite verifies the delegate path; a new unit test covers both.
- FastAPI: `verify_token` / `verify_token_optional` short-circuit to a fixed dev
  user when `settings.enable_dev_auth`. Covered by new pytest cases.
- Frontend: a `@/lib/auth` wrapper re-exports Clerk's useAuth/useUser/useClerk/
  useReverification, swapping to dev stubs only when `VITE_ENABLE_DEV_AUTH=true`
  (a build-time constant, so it's a pure re-export otherwise). 25 call sites
  import from the wrapper; `__root.tsx` skips ClerkProvider in dev and the
  sign-in/up routes don't mount Clerk components.

The three layers share `DEV_USER_ID = "dev-user"`. Documented in all three
.env.example files. Also adds scripts/dev-screenshots.py (Playwright) to capture
product shots once the stack runs with dev auth.

NOTE: the off-path is fully test-verified; the in-browser dev path (incl. the
Convex client auth handshake) needs functional verification on a running stack.
Comment thread apps/web/src/lib/auth.ts
Comment on lines +14 to +20
* Off by default, and `DEV_AUTH` is a build-time constant, so the exported hooks
* below resolve to the REAL Clerk hooks in every normal build — this module is a
* pure pass-through unless a developer explicitly opts in. Import the auth hooks
* from here (`@/lib/auth`) instead of `@clerk/tanstack-react-start` so the
* bypass reaches every call site.
*/
export const DEV_AUTH = env.VITE_ENABLE_DEV_AUTH === "true";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEV_AUTH is not actually a build-time constant — dev stubs ship in every production bundle.

The comment claims DEV_AUTH is a build-time constant so the stubs are tree-shaken in production. This is not the case. Vite's static substitution only fires when the literal token sequence import.meta.env.VITE_* appears in source. Here DEV_AUTH is read via:

// env.ts
runtimeEnv: { ...import.meta.env, ... }  // spread — Vite does not enumerate keys through a spread

followed by createEnv(...) from @t3-oss/env-core — a runtime function call whose return value Vite cannot inline. So env.VITE_ENABLE_DEV_AUTH is a runtime property read, not a static token Vite replaces.

As a result:

  • The devUseAuth / devUseUser / devUseClerk / devUseReverification stubs are retained in every production bundle.
  • The useAuth = DEV_AUTH ? devUseAuth : clerkUseAuth ternaries are evaluated at runtime, not eliminated at build time.
  • A single VITE_ENABLE_DEV_AUTH=true at build time activates the full auth bypass (isSignedIn: true always, getToken returns "dev-auth") in production.

Fix: Read the env var directly in this file so Vite can inline it:

// Replace:
import { env } from "../env";
export const DEV_AUTH = env.VITE_ENABLE_DEV_AUTH === "true";

// With (Vite statically replaces import.meta.env.VITE_* at build time):
export const DEV_AUTH = import.meta.env.VITE_ENABLE_DEV_AUTH === "true";

With direct access, Vite inlines false for production builds (where the var is unset), allowing the bundler to dead-code-eliminate all four dev stubs.

See:

*
* Off by default, and `DEV_AUTH` is a build-time constant, so the exported hooks
* below resolve to the REAL Clerk hooks in every normal build — this module is a
* pure pass-through unless a developer explicitly opts in. Import the auth hooks
* from here (`@/lib/auth`) instead of `@clerk/tanstack-react-start` so the
* bypass reaches every call site.
*/
export const DEV_AUTH = env.VITE_ENABLE_DEV_AUTH === "true";
export const DEV_USER_ID = "dev-user";

Comment thread apps/web/src/lib/auth.ts
Comment on lines +35 to +46
const devUseUser = (() => ({
isLoaded: true,
isSignedIn: true,
user: {
id: DEV_USER_ID,
fullName: "Dev User",
firstName: "Dev",
lastName: "User",
primaryEmailAddress: { emailAddress: "dev@localhost" },
imageUrl: "",
},
})) as unknown as typeof clerkUseUser;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

devUseUser stub is missing Clerk User instance methods — crashes in princeton-connect-row.tsx under dev auth.

princeton-connect-row.tsx was updated by this PR to import useUser from @/lib/auth, so in VITE_ENABLE_DEV_AUTH=true mode it receives the devUseUser stub. The component calls three methods on the returned user object that the stub does not implement:

  • user.createEmailAddress({ email })line 41 — called via the reverification wrapper when the user clicks "Send Code"
  • user.reload()lines 56 and 91 — called after email creation and after verification
  • user.emailAddresseslines 58 and 83 — iterated to find the newly created address

The as unknown as typeof clerkUseUser cast silences TypeScript, so the gap isn't caught at compile time. At runtime, clicking the Princeton connect button throws TypeError: user.createEmailAddress is not a function.

Fix: Add no-op stubs for the missing methods in devUseUser. For example:

user: {
  id: DEV_USER_ID,
  fullName: "Dev User",
  firstName: "Dev",
  lastName: "User",
  primaryEmailAddress: { emailAddress: "dev@localhost" },
  imageUrl: "",
  emailAddresses: [],
  createEmailAddress: async () => { throw new Error("Not supported in dev mode"); },
  reload: async () => {},
},

(The Princeton email flow is inherently non-functional without real Clerk, so throwing on createEmailAddress with a clear message is more developer-friendly than a silent TypeError.)

…ied live)

Stood the stack up against a real Convex dev deployment with ENABLE_DEV_AUTH and
fixed two gaps that only surface at runtime:

- start.ts registered Clerk's requestMiddleware globally, which throws "no
  secret key provided" on every request in loginless mode → skip it in dev-auth.
- The dev identity handed ConvexProviderWithClerk / the SSR http client a fake
  "dev-auth" token, which Convex rejects as a malformed JWT (InvalidAuthHeader).
  Return null instead — unauthenticated calls resolve to the dev user via the
  backend getIdentity bypass.

With these, the app renders fully loginless (workspaces, chats, the agent
transcript, share dialog, command palette) — confirmed by screenshots.
Comment thread apps/web/src/lib/auth.ts
// useReverification wraps an action that may need step-up auth; in dev, pass through.
const devUseReverification = (<T>(fn: T) =>
fn) as unknown as typeof clerkUseReverification;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apps/web/src/components/sandbox-result.tsx was not migrated to import from this module. It still imports useAuth directly from @clerk/clerk-react:

import { useAuth } from "@clerk/clerk-react";
import {
AlertTriangle,

In dev mode ClerkProvider is not mounted (see __root.tsx fetchClerkAuth), so calling Clerk's useAuth() directly will throw a React context error if GitHubAuthRequiredError renders — e.g., when a sandbox run returns error_code === "github_auth_required". SandboxResult is rendered in both the basic chat and workspaces routes via ChatMessagesmessage-blocks.tsx.

The fix is a one-line import swap in sandbox-result.tsx:

Suggested change
import { useAuth } from "@/lib/auth";

Comment on lines +99 to 100
return {"sub": DEV_USER_ID}
if not request.headers.get("authorization", "").startswith("Bearer "):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verify_token_optional's documented contract is to return None for anonymous callers (no bearer token) — the /follow endpoint relies on this sentinel:

# packages/fastapi/app/routes/chat.py
if user is None and not token:
    raise HTTPException(status_code=403, detail="Not authorized")

Because this early return fires before the Authorization header is inspected, user is never None when enable_dev_auth=True — even for a request with no bearer token at all. Two consequences:

  1. The share-token access path in /follow becomes unreachable and untestable with dev auth on.
  2. Any request (with or without a valid share token) is silently granted the full dev-user identity.

Since the intent in dev mode is to simulate a logged-in user, not to bypass access control entirely, consider returning None when no Authorization header is present even with dev auth on — so that the share-token flow remains exercisable locally:

if not request.headers.get("authorization", "").startswith("Bearer "):
    return None
if settings.enable_dev_auth:
    return {"sub": DEV_USER_ID}

The Files/Terminal/Git/Agents sandbox panels imported useAuth from
@clerk/clerk-react directly (a different package than the rest of the app's
@clerk/tanstack-react-start), so the earlier wrapper swap missed them. Opening
the panel in loginless dev mode crashed with "useAuth can only be used within
<ClerkProvider>". Point all 8 at @/lib/auth like every other hook call site.
Found by driving a real agent session with dev-auth and opening the Agents panel.
@claude

claude Bot commented Jun 21, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant