Add team auth mode (backend, inert until AUTH_MODE=team)
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled

Introduces a fourth AUTH_MODE, `team`: Better Auth email/password with the
existing organization/member/role/invitation stack, but none of the hosted
SaaS coupling (no Autumn billing, Turnstile, Loops email, Google social
login, onboarding chat, PostHog, disposable-email block, dub referrals).

- auth-mode.ts: add `team`; add isTeamAuthMode / isSessionAuthMode /
  isSessionClientAuthMode ("is there a login session?" vs isHostedAuthMode's
  "is this the billed product?").
- auth.ts: createAuth() builds a valid instance for `team` — verification
  off, self-serve signup disabled, no captcha/Loops/social. hasTeamAuthConfig
  (BETTER_AUTH_URL + BETTER_AUTH_SECRET only) + hasSessionAuthConfig.
- ensure-user: resolve.ts routes `team` through resolveHostedContext;
  requireHostedSession + selfHostedOAuth callback accept any session mode.
- api/auth/$.ts: mount the Better Auth handler for `team` too.
- Client: route guards, sidebar account menu / sign-out, settings
  Organization tab, invitation accept, and error cards switch from
  isHostedClientAuthMode to isSessionClientAuthMode where they mean "has a
  session". Sign-in goes straight to the email form (no Google button);
  sign-up shows an invite-only notice.
- selfhost-preflight: validate `team` (requires BETTER_AUTH_URL +
  BETTER_AUTH_SECRET >= 32 chars).
- .env.example: document `team`.

Ships inert: AUTH_MODE stays local_noauth. tsc / oxlint / knip clean;
test suite unchanged (1164 pass, 1 pre-existing Windows-CRLF failure in
samSkills.test.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-09-09 00:51:20 -04:00
parent b489f78129
commit c47b032f1a
21 changed files with 229 additions and 68 deletions

View File

@ -18,11 +18,17 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# - cloudflare_access: validate Cloudflare Access JWTs (recommended for deploys) # - cloudflare_access: validate Cloudflare Access JWTs (recommended for deploys)
# - local_noauth: local trusted mode with injected admin user (admin@localhost) # - local_noauth: local trusted mode with injected admin user (admin@localhost)
# - hosted: Better Auth email/password + organization mode # - team: Better Auth email/password, invite-only. One shared workspace; the
# owner provisions accounts. No billing, captcha, or transactional email.
# - hosted: the full multi-tenant SaaS (billing, Google login, email verification)
# #
# Defaults to cloudflare_access when unset. # Defaults to cloudflare_access when unset.
# AUTH_MODE=cloudflare_access # AUTH_MODE=cloudflare_access
# Required when AUTH_MODE=team (set both in the build env AND the runtime).
# BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters
# BETTER_AUTH_URL=https://seo.example.com
# Required when AUTH_MODE=cloudflare_access. See docs/SELF_HOSTING_CLOUDFLARE.md. # Required when AUTH_MODE=cloudflare_access. See docs/SELF_HOSTING_CLOUDFLARE.md.
# TEAM_DOMAIN=https://your-team.cloudflareaccess.com # TEAM_DOMAIN=https://your-team.cloudflareaccess.com
# POLICY_AUD=your-cloudflare-access-aud-tag # POLICY_AUD=your-cloudflare-access-aud-tag

View File

@ -1,5 +1,8 @@
import { ShieldAlert } from "lucide-react"; import { ShieldAlert } from "lucide-react";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import {
isHostedClientAuthMode,
isSessionClientAuthMode,
} from "@/lib/auth-mode";
const CLOUDFLARE_SETUP_GUIDE_URL = const CLOUDFLARE_SETUP_GUIDE_URL =
"https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_CLOUDFLARE.md#2-configure-authentication-and-secrets"; "https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_CLOUDFLARE.md#2-configure-authentication-and-secrets";
@ -14,6 +17,7 @@ export function AuthConfigErrorCard({
onRetry, onRetry,
}: AuthConfigErrorCardProps) { }: AuthConfigErrorCardProps) {
const isHostedMode = isHostedClientAuthMode(); const isHostedMode = isHostedClientAuthMode();
const isTeamMode = isSessionClientAuthMode() && !isHostedMode;
return ( return (
<div className="card w-full max-w-2xl bg-base-100 border border-base-300 shadow-xl"> <div className="card w-full max-w-2xl bg-base-100 border border-base-300 shadow-xl">
@ -27,7 +31,15 @@ export function AuthConfigErrorCard({
<span>{message}</span> <span>{message}</span>
</div> </div>
{isHostedMode ? ( {isTeamMode ? (
<p className="text-sm text-base-content/70">
Team mode requires <code className="mx-1">BETTER_AUTH_SECRET</code>
(32+ characters) and <code className="mx-1">
BETTER_AUTH_URL
</code>{" "}
on the deployment.
</p>
) : isHostedMode ? (
<p className="text-sm text-base-content/70"> <p className="text-sm text-base-content/70">
Hosted mode requires{" "} Hosted mode requires{" "}
<code className="mx-1">BETTER_AUTH_SECRET</code> <code className="mx-1">BETTER_AUTH_SECRET</code>

View File

@ -25,7 +25,10 @@ import { SamSidebarPanel } from "@/client/features/sam/SamSidebarPanel";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { closeDropdown } from "@/client/lib/dropdown"; import { closeDropdown } from "@/client/lib/dropdown";
import { signOutAndRedirect, useSession } from "@/lib/auth-client"; import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import {
isHostedClientAuthMode,
isSessionClientAuthMode,
} from "@/lib/auth-mode";
import { BILLING_ROUTE } from "@/shared/billing"; import { BILLING_ROUTE } from "@/shared/billing";
interface SidebarProps { interface SidebarProps {
@ -237,12 +240,13 @@ function SidebarViewTab({
function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) { function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
const { data: session } = useSession(); const { data: session } = useSession();
const isHostedMode = isHostedClientAuthMode(); const isHostedMode = isHostedClientAuthMode();
const isSessionMode = isSessionClientAuthMode();
const email = session?.user?.email; const email = session?.user?.email;
const [isSwitching, setIsSwitching] = useState(false); const [isSwitching, setIsSwitching] = useState(false);
const orgContextQuery = useQuery({ const orgContextQuery = useQuery({
...organizationContextQueryOptions(), ...organizationContextQueryOptions(),
enabled: isHostedMode && Boolean(email), enabled: isSessionMode && Boolean(email),
}); });
const organizations = orgContextQuery.data?.organizations ?? []; const organizations = orgContextQuery.data?.organizations ?? [];
const activeOrganizationId = orgContextQuery.data?.organizationId; const activeOrganizationId = orgContextQuery.data?.organizationId;
@ -338,7 +342,7 @@ function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
</li> </li>
) : null} ) : null}
<ThemePreferenceMenuItems /> <ThemePreferenceMenuItems />
{isHostedMode ? ( {isSessionMode ? (
<> <>
<li <li
aria-hidden aria-hidden

View File

@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { getSignInHref, getSignInHrefForLocation } from "@/lib/auth-redirect"; import { getSignInHref, getSignInHrefForLocation } from "@/lib/auth-redirect";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isSessionClientAuthMode } from "@/lib/auth-mode";
type UnauthenticatedErrorCardProps = { type UnauthenticatedErrorCardProps = {
message: string; message: string;
@ -11,21 +11,21 @@ export function UnauthenticatedErrorCard({
message, message,
onRetry, onRetry,
}: UnauthenticatedErrorCardProps) { }: UnauthenticatedErrorCardProps) {
const isHostedMode = isHostedClientAuthMode(); const isSessionMode = isSessionClientAuthMode();
const signInHref = const signInHref =
typeof window === "undefined" typeof window === "undefined"
? getSignInHref("/") ? getSignInHref("/")
: getSignInHrefForLocation(window.location); : getSignInHrefForLocation(window.location);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined" || !isHostedMode) { if (typeof window === "undefined" || !isSessionMode) {
return; return;
} }
window.location.replace(signInHref); window.location.replace(signInHref);
}, [isHostedMode, signInHref]); }, [isSessionMode, signInHref]);
if (isHostedMode) { if (isSessionMode) {
return null; return null;
} }

View File

@ -1,9 +1,14 @@
import { Link } from "@tanstack/react-router";
import { z } from "zod"; import { z } from "zod";
import { import {
getCurrentAuthRedirect, getCurrentAuthRedirect,
getOAuthSignedQuery, getOAuthSignedQuery,
getSignInSearch,
} from "@/lib/auth-redirect"; } from "@/lib/auth-redirect";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import {
isHostedClientAuthMode,
isSessionClientAuthMode,
} from "@/lib/auth-mode";
export const authRedirectSearchSchema = z.object({ export const authRedirectSearchSchema = z.object({
redirect: z.string().optional(), redirect: z.string().optional(),
@ -16,11 +21,15 @@ export function useAuthPageState(redirect: string | undefined) {
? getOAuthSignedQuery(window.location.search) ? getOAuthSignedQuery(window.location.search)
: null; : null;
const isHostedMode = isHostedClientAuthMode(); const isHostedMode = isHostedClientAuthMode();
// `team` mode: email/password sign-in is live, but Google social login and
// self-serve signup are not.
const isSessionMode = isSessionClientAuthMode();
return { return {
redirectTo, redirectTo,
oauthQuery, oauthQuery,
isHostedMode, isHostedMode,
isSessionMode,
}; };
} }
@ -120,6 +129,25 @@ export function AuthPageCard({
); );
} }
// `team` mode has no self-serve signup — the workspace owner provisions
// accounts. Shown in place of the sign-up form.
export function InviteOnlyCard({ redirectTo }: { redirectTo: string }) {
return (
<AuthPageCard
title="Ask your admin for access"
helperText="This workspace is invite-only. Your workspace owner creates accounts and shares the sign-in details."
>
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="btn btn-primary w-full"
>
Back to sign in
</Link>
</AuthPageCard>
);
}
export function AuthPageShell({ children }: { children: React.ReactNode }) { export function AuthPageShell({ children }: { children: React.ReactNode }) {
return ( return (
// `h-[100dvh]` + `overflow-y-auto` makes this a scroll container, and the // `h-[100dvh]` + `overflow-y-auto` makes this a scroll container, and the

View File

@ -4,6 +4,7 @@ import { useSession } from "@/lib/auth-client";
import { import {
isEmailVerificationBypassed, isEmailVerificationBypassed,
isHostedClientAuthMode, isHostedClientAuthMode,
isSessionClientAuthMode,
} from "@/lib/auth-mode"; } from "@/lib/auth-mode";
import { import {
getCurrentAuthRedirectFromHref, getCurrentAuthRedirectFromHref,
@ -14,12 +15,17 @@ import {
export function useHostedAuthRouteGuard() { export function useHostedAuthRouteGuard() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: session, isPending } = useSession(); const { data: session, isPending } = useSession();
// `hosted` and `team` both require a Better Auth session; only `hosted` has
// an email-verification step (team has no transactional email).
const isSessionMode = isSessionClientAuthMode();
const isHostedMode = isHostedClientAuthMode(); const isHostedMode = isHostedClientAuthMode();
const emailVerified = const emailVerified =
session?.user?.emailVerified === true || isEmailVerificationBypassed(); !isHostedMode ||
session?.user?.emailVerified === true ||
isEmailVerificationBypassed();
useEffect(() => { useEffect(() => {
if (isPending || !isHostedMode) { if (isPending || !isSessionMode) {
return; return;
} }
@ -43,7 +49,7 @@ export function useHostedAuthRouteGuard() {
} }
}, [ }, [
isPending, isPending,
isHostedMode, isSessionMode,
emailVerified, emailVerified,
session?.user?.email, session?.user?.email,
session?.user?.id, session?.user?.id,
@ -55,6 +61,7 @@ export function useHostedAuthRouteGuard() {
return { return {
isHostedMode, isHostedMode,
canRenderAuthenticatedContent: !isHostedMode || hasVerifiedHostedSession, isSessionMode,
canRenderAuthenticatedContent: !isSessionMode || hasVerifiedHostedSession,
}; };
} }

4
src/env.d.ts vendored
View File

@ -24,7 +24,7 @@ declare namespace Cloudflare {
// oxlint-disable-next-line typescript-eslint/consistent-type-imports // oxlint-disable-next-line typescript-eslint/consistent-type-imports
AUDIT_ENGINE: Service<typeof import("./audit-worker").default>; AUDIT_ENGINE: Service<typeof import("./audit-worker").default>;
AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted"; AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted" | "team";
BYPASS_EMAIL_VERIFICATION?: string; BYPASS_EMAIL_VERIFICATION?: string;
TEAM_DOMAIN?: string; TEAM_DOMAIN?: string;
POLICY_AUD?: string; POLICY_AUD?: string;
@ -66,7 +66,7 @@ declare namespace Cloudflare {
} }
interface ImportMetaEnv { interface ImportMetaEnv {
readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted"; readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted" | "team";
readonly DATABASE_PROVIDER?: "d1" | "postgres"; readonly DATABASE_PROVIDER?: "d1" | "postgres";
readonly BYPASS_EMAIL_VERIFICATION?: string; readonly BYPASS_EMAIL_VERIFICATION?: string;
readonly POSTHOG_PUBLIC_KEY?: string; readonly POSTHOG_PUBLIC_KEY?: string;

View File

@ -4,6 +4,7 @@ export const AUTH_MODES = [
"cloudflare_access", "cloudflare_access",
"local_noauth", "local_noauth",
"hosted", "hosted",
"team",
] as const; ] as const;
type AuthMode = (typeof AUTH_MODES)[number]; type AuthMode = (typeof AUTH_MODES)[number];
@ -42,6 +43,23 @@ export function isHostedClientAuthMode() {
return isHostedAuthMode(import.meta.env.AUTH_MODE); return isHostedAuthMode(import.meta.env.AUTH_MODE);
} }
export function isTeamAuthMode(value: string | null | undefined) {
return getAuthMode(value) === "team";
}
// "Is there a Better Auth login session?" — true for both the paid hosted SaaS
// and the internal `team` mode. Use this (not isHostedAuthMode) wherever the
// question is "does this request carry a real user session?" rather than "is
// this the billed multi-tenant product?".
export function isSessionAuthMode(value: string | null | undefined) {
const mode = getAuthMode(value);
return mode === "hosted" || mode === "team";
}
export function isSessionClientAuthMode() {
return isSessionAuthMode(import.meta.env.AUTH_MODE);
}
export function isEmailVerificationBypassed() { export function isEmailVerificationBypassed() {
// Local-dev escape hatch (BYPASS_EMAIL_VERIFICATION=true). The server skips // Local-dev escape hatch (BYPASS_EMAIL_VERIFICATION=true). The server skips
// verification and never marks users emailVerified, so the client must treat // verification and never marks users emailVerified, so the client must treat

View File

@ -11,7 +11,11 @@ import { pgDb } from "@/db/pg/client";
import * as pgSchema from "@/db/pg/schema"; import * as pgSchema from "@/db/pg/schema";
import { getDatabaseProvider } from "@/db/provider"; import { getDatabaseProvider } from "@/db/provider";
import { z } from "zod"; import { z } from "zod";
import { isHostedAuthMode } from "@/lib/auth-mode"; import {
isHostedAuthMode,
isSessionAuthMode,
isTeamAuthMode,
} from "@/lib/auth-mode";
import { createApiKeyPlugin } from "@/lib/auth-api-key"; import { createApiKeyPlugin } from "@/lib/auth-api-key";
import { createBaseAuthConfig } from "@/lib/auth-config"; import { createBaseAuthConfig } from "@/lib/auth-config";
import { import {
@ -43,12 +47,12 @@ function createAuth() {
// Hosted needs the real configured URL (cookies, callbacks, /api/auth routes // Hosted needs the real configured URL (cookies, callbacks, /api/auth routes
// all use it). Self-hosted only builds this instance to mint/refresh Search // all use it). Self-hosted only builds this instance to mint/refresh Search
// Console tokens, which never read baseURL — so a placeholder is fine there. // Console tokens, which never read baseURL — so a placeholder is fine there.
const baseUrl = isHostedAuthMode(env.AUTH_MODE) const baseUrl = isSessionAuthMode(env.AUTH_MODE)
? getHostedBaseUrl() ? getHostedBaseUrl()
: "http://localhost"; : "http://localhost";
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true"; const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
const baseAuthConfig = createBaseAuthConfig( const baseAuthConfig = createBaseAuthConfig(
isHostedAuthMode(env.AUTH_MODE) isSessionAuthMode(env.AUTH_MODE)
? { ? {
organization: { organization: {
// No sendInvitationEmail here on purpose: better-auth swallows a // No sendInvitationEmail here on purpose: better-auth swallows a
@ -161,19 +165,29 @@ function createAuth() {
...baseAuthConfig, ...baseAuthConfig,
emailAndPassword: { emailAndPassword: {
...baseAuthConfig.emailAndPassword, ...baseAuthConfig.emailAndPassword,
requireEmailVerification: !bypassEmail, // `team` mode has no transactional email, so no verification and no
// self-serve signup — the workspace owner provisions accounts.
requireEmailVerification: isHostedAuthMode(env.AUTH_MODE)
? !bypassEmail
: false,
disableSignUp: isTeamAuthMode(env.AUTH_MODE)
? true
: (baseAuthConfig.emailAndPassword.disableSignUp ?? false),
resetPasswordTokenExpiresIn: 60 * 60, resetPasswordTokenExpiresIn: 60 * 60,
revokeSessionsOnPasswordReset: true, revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, url }) => { sendResetPassword: async ({ user, url }) => {
// Reset-by-email is hosted-only (Loops). In `team` mode the owner
// resets a member's password from the admin screen instead.
if (!isHostedAuthMode(env.AUTH_MODE)) return;
await sendHostedPasswordResetEmail({ await sendHostedPasswordResetEmail({
email: user.email, email: user.email,
resetUrl: url, resetUrl: url,
}); });
}, },
}, },
emailVerification: bypassEmail emailVerification:
? undefined isHostedAuthMode(env.AUTH_MODE) && !bypassEmail
: { ? {
sendOnSignUp: true, sendOnSignUp: true,
autoSignInAfterVerification: true, autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => { sendVerificationEmail: async ({ user, url }) => {
@ -182,7 +196,8 @@ function createAuth() {
confirmationUrl: url, confirmationUrl: url,
}); });
}, },
}, }
: undefined,
socialProviders: getSocialProviders(), socialProviders: getSocialProviders(),
// Where OAuth redirect-flow failures land when Better Auth can't honor a // Where OAuth redirect-flow failures land when Better Auth can't honor a
// per-flow errorCallbackURL (Google-side errors like a canceled consent // per-flow errorCallbackURL (Google-side errors like a canceled consent
@ -226,8 +241,8 @@ function createAuth() {
return { data: user }; return { data: user };
}, },
after: async (user, ctx) => { after: async (user, ctx) => {
await syncHostedSignupContact(user);
if (isHostedAuthMode(env.AUTH_MODE)) { if (isHostedAuthMode(env.AUTH_MODE)) {
await syncHostedSignupContact(user);
await captureDubReferralSignup(user.id, ctx?.request); await captureDubReferralSignup(user.id, ctx?.request);
} }
}, },
@ -377,7 +392,7 @@ function hasHostedAuthEmailConfig() {
}); });
} }
export function hasHostedAuthConfig() { function hasHostedAuthConfig() {
try { try {
getHostedBaseUrl(); getHostedBaseUrl();
getHostedSecret(); getHostedSecret();
@ -392,6 +407,26 @@ export function hasHostedAuthConfig() {
} }
} }
// `team` mode needs only a base URL and a signing secret — no Google, Turnstile,
// or transactional email.
function hasTeamAuthConfig() {
try {
getHostedBaseUrl();
getHostedSecret();
return true;
} catch {
return false;
}
}
// The config gate for whichever session-based mode is active. Non-session modes
// (cloudflare_access, local_noauth) never call this.
export function hasSessionAuthConfig() {
return isHostedAuthMode(env.AUTH_MODE)
? hasHostedAuthConfig()
: hasTeamAuthConfig();
}
export function getAuth() { export function getAuth() {
if (authInstance) { if (authInstance) {
return authInstance; return authInstance;

View File

@ -58,6 +58,37 @@ function checkAuthMode(env: EnvRecord, items: PreflightItem[]): void {
return; return;
} }
if (mode === "team") {
const missing = ["BETTER_AUTH_URL", "BETTER_AUTH_SECRET"].filter(
(name) => !get(env, name),
);
const secret = get(env, "BETTER_AUTH_SECRET");
if (missing.length) {
items.push({
key: "auth",
name: "AUTH_MODE",
level: "fail",
message: `team mode requires ${missing.join(", ")}.`,
});
} else if (secret && secret.length < MIN_BETTER_AUTH_SECRET_LENGTH) {
items.push({
key: "auth",
name: "BETTER_AUTH_SECRET",
level: "fail",
message: `team mode requires BETTER_AUTH_SECRET to be at least ${MIN_BETTER_AUTH_SECRET_LENGTH} characters (it signs session cookies).`,
});
} else {
items.push({
key: "auth",
name: "AUTH_MODE",
level: "ok",
message:
"team — Better Auth email/password, invite-only. Accounts are provisioned by the workspace owner.",
});
}
return;
}
if (mode === "hosted") { if (mode === "hosted") {
const missing = [ const missing = [
"BETTER_AUTH_URL", "BETTER_AUTH_URL",

View File

@ -1,4 +1,4 @@
import { getAuth, hasHostedAuthConfig } from "@/lib/auth"; import { getAuth, hasSessionAuthConfig } from "@/lib/auth";
import { getActiveOrganizationId } from "@/lib/auth-session"; import { getActiveOrganizationId } from "@/lib/auth-session";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository"; import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { resolveActiveHostedOrganization } from "@/server/auth/default-hosted-organization"; import { resolveActiveHostedOrganization } from "@/server/auth/default-hosted-organization";
@ -6,10 +6,10 @@ import { AppError } from "@/server/lib/errors";
import type { EnsuredUserContext } from "./types"; import type { EnsuredUserContext } from "./types";
async function requireHostedSession(headers: Headers) { async function requireHostedSession(headers: Headers) {
if (!hasHostedAuthConfig()) { if (!hasSessionAuthConfig()) {
throw new AppError( throw new AppError(
"AUTH_CONFIG_MISSING", "AUTH_CONFIG_MISSING",
"Missing Better Auth hosted configuration", "Missing Better Auth configuration",
); );
} }

View File

@ -1,5 +1,5 @@
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode"; import { getAuthMode, isSessionAuthMode } from "@/lib/auth-mode";
import { resolveCloudflareAccessContext } from "./cloudflareAccess"; import { resolveCloudflareAccessContext } from "./cloudflareAccess";
import { resolveLocalNoAuthContext } from "./delegated"; import { resolveLocalNoAuthContext } from "./delegated";
import { resolveHostedContext } from "./hosted"; import { resolveHostedContext } from "./hosted";
@ -15,7 +15,10 @@ export async function resolveUserContextFromHeaders(
if (authMode === "local_noauth") { if (authMode === "local_noauth") {
return resolveLocalNoAuthContext(); return resolveLocalNoAuthContext();
} }
if (isHostedAuthMode(authMode)) { if (isSessionAuthMode(authMode)) {
// `hosted` and `team` both resolve a Better Auth session; they differ only
// in config requirements (checked in resolveHostedContext) and which
// SaaS-only features are wired around them.
return resolveHostedContext(headers); return resolveHostedContext(headers);
} }
return resolveCloudflareAccessContext(headers); return resolveCloudflareAccessContext(headers);

View File

@ -1,5 +1,5 @@
import { createFileRoute, Link, Outlet } from "@tanstack/react-router"; import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isSessionClientAuthMode } from "@/lib/auth-mode";
export const Route = createFileRoute("/_app/settings")({ export const Route = createFileRoute("/_app/settings")({
component: SettingsLayout, component: SettingsLayout,
@ -12,7 +12,7 @@ function SettingsLayout() {
const tabs = [ const tabs = [
{ to: "/settings" as const, label: "Personal", exact: true }, { to: "/settings" as const, label: "Personal", exact: true },
// Self-host has no memberships — the organization tab would 404. // Self-host has no memberships — the organization tab would 404.
...(isHostedClientAuthMode() ...(isSessionClientAuthMode()
? [{ to: "/settings/organization" as const, label: "Organization" }] ? [{ to: "/settings/organization" as const, label: "Organization" }]
: []), : []),
]; ];

View File

@ -1,12 +1,12 @@
import { createFileRoute, notFound } from "@tanstack/react-router"; import { createFileRoute, notFound } from "@tanstack/react-router";
import { TeamSettings } from "@/client/features/team/TeamSettings"; import { TeamSettings } from "@/client/features/team/TeamSettings";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isSessionClientAuthMode } from "@/lib/auth-mode";
export const Route = createFileRoute("/_app/settings/organization")({ export const Route = createFileRoute("/_app/settings/organization")({
// Self-host has no memberships or invitations — the better-auth HTTP // Self-host has no memberships or invitations — the better-auth HTTP
// surface isn't even mounted there. // surface isn't even mounted there.
beforeLoad: () => { beforeLoad: () => {
if (!isHostedClientAuthMode()) { if (!isSessionClientAuthMode()) {
throw notFound(); throw notFound();
} }
}, },

View File

@ -26,11 +26,11 @@ export const Route = createFileRoute("/_auth/sign-in")({
function SignInPage() { function SignInPage() {
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = useNavigate(); const navigate = useNavigate();
const { redirectTo, oauthQuery, isHostedMode } = useAuthPageState( const { redirectTo, oauthQuery, isHostedMode, isSessionMode } =
search.redirect, useAuthPageState(search.redirect);
);
const authCallbackURL = redirectTo; const authCallbackURL = redirectTo;
const [showEmailForm, setShowEmailForm] = useState(false); // `team` mode has no Google button, so go straight to the email/password form.
const [showEmailForm, setShowEmailForm] = useState(!isHostedMode);
const [isStartingGoogle, setIsStartingGoogle] = useState(false); const [isStartingGoogle, setIsStartingGoogle] = useState(false);
const [socialError, setSocialError] = useState<string | null>(null); const [socialError, setSocialError] = useState<string | null>(null);
@ -155,7 +155,7 @@ function SignInPage() {
<> <>
<AuthMethodChooser <AuthMethodChooser
googleLabel="Continue with Google" googleLabel="Continue with Google"
disabled={!isHostedMode} disabled={!isSessionMode}
isBusy={isStartingGoogle} isBusy={isStartingGoogle}
onContinueWithGoogle={() => { onContinueWithGoogle={() => {
void handleContinueWithGoogle(); void handleContinueWithGoogle();
@ -190,7 +190,7 @@ function SignInPage() {
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)} onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email" autoComplete="email"
disabled={!isHostedMode} disabled={!isSessionMode}
required required
/> />
{error ? ( {error ? (
@ -214,7 +214,7 @@ function SignInPage() {
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)} onChange={(event) => field.handleChange(event.target.value)}
autoComplete="current-password" autoComplete="current-password"
disabled={!isHostedMode} disabled={!isSessionMode}
required required
/> />
{error ? ( {error ? (
@ -240,7 +240,7 @@ function SignInPage() {
) : null} ) : null}
<button <button
className="btn btn-soft w-full" className="btn btn-soft w-full"
disabled={!isHostedMode || isSubmitting} disabled={!isSessionMode || isSubmitting}
> >
{isSubmitting ? "Signing in..." : "Sign in"} {isSubmitting ? "Signing in..." : "Sign in"}
</button> </button>

View File

@ -5,6 +5,7 @@ import {
AuthPageCard, AuthPageCard,
AuthMethodChooser, AuthMethodChooser,
authRedirectSearchSchema, authRedirectSearchSchema,
InviteOnlyCard,
useAuthPageState, useAuthPageState,
} from "@/client/features/auth/AuthPage"; } from "@/client/features/auth/AuthPage";
import { import {
@ -51,7 +52,9 @@ export const Route = createFileRoute("/_auth/sign-up")({
function SignUpPage() { function SignUpPage() {
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = useNavigate(); const navigate = useNavigate();
const { redirectTo, isHostedMode } = useAuthPageState(search.redirect); const { redirectTo, isHostedMode, isSessionMode } = useAuthPageState(
search.redirect,
);
const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo; const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo;
const [showEmailForm, setShowEmailForm] = useState(false); const [showEmailForm, setShowEmailForm] = useState(false);
const google = useGoogleSignUp({ redirectTo, postSignupRedirect }); const google = useGoogleSignUp({ redirectTo, postSignupRedirect });
@ -148,6 +151,12 @@ function SignUpPage() {
}, },
}); });
// `team` mode: accounts are provisioned by the workspace owner, not
// self-serve. (Non-session modes never reach this route's guard.)
if (isSessionMode && !isHostedMode) {
return <InviteOnlyCard redirectTo={redirectTo} />;
}
return ( return (
<AuthPageCard <AuthPageCard
title="Create your account" title="Create your account"

View File

@ -5,7 +5,7 @@ import {
authRedirectSearchSchema, authRedirectSearchSchema,
} from "@/client/features/auth/AuthPage"; } from "@/client/features/auth/AuthPage";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isSessionClientAuthMode } from "@/lib/auth-mode";
import { getCurrentAuthRedirect } from "@/lib/auth-redirect"; import { getCurrentAuthRedirect } from "@/lib/auth-redirect";
export const Route = createFileRoute("/_auth")({ export const Route = createFileRoute("/_auth")({
@ -17,7 +17,7 @@ function AuthPageLayout() {
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: session, isPending } = useSession(); const { data: session, isPending } = useSession();
const isHostedMode = isHostedClientAuthMode(); const isSessionMode = isSessionClientAuthMode();
const redirectTo = getCurrentAuthRedirect(search.redirect); const redirectTo = getCurrentAuthRedirect(search.redirect);
useEffect(() => { useEffect(() => {
@ -31,7 +31,7 @@ function AuthPageLayout() {
void navigate({ href: redirectTo, replace: true }); void navigate({ href: redirectTo, replace: true });
}, [navigate, redirectTo, session?.user?.id]); }, [navigate, redirectTo, session?.user?.id]);
if (isHostedMode && (isPending || session?.user?.id)) { if (isSessionMode && (isPending || session?.user?.id)) {
return null; return null;
} }

View File

@ -9,7 +9,7 @@ export const Route = createFileRoute("/_authenticated")({
function AuthenticatedShellLayout() { function AuthenticatedShellLayout() {
const authGate = useHostedAuthRouteGuard(); const authGate = useHostedAuthRouteGuard();
if (!authGate.isHostedMode || !authGate.canRenderAuthenticatedContent) { if (!authGate.isSessionMode || !authGate.canRenderAuthenticatedContent) {
return null; return null;
} }

View File

@ -4,11 +4,11 @@ import { useState } from "react";
import { AuthPageCard, AuthPageShell } from "@/client/features/auth/AuthPage"; import { AuthPageCard, AuthPageShell } from "@/client/features/auth/AuthPage";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { authClient, signOutAndRedirect, useSession } from "@/lib/auth-client"; import { authClient, signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isSessionClientAuthMode } from "@/lib/auth-mode";
export const Route = createFileRoute("/accept-invitation/$id")({ export const Route = createFileRoute("/accept-invitation/$id")({
beforeLoad: () => { beforeLoad: () => {
if (!isHostedClientAuthMode()) { if (!isSessionClientAuthMode()) {
throw notFound(); throw notFound();
} }
}, },

View File

@ -1,17 +1,17 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { getAuth, hasHostedAuthConfig } from "@/lib/auth"; import { getAuth, hasSessionAuthConfig } from "@/lib/auth";
import { isHostedAuthMode } from "@/lib/auth-mode"; import { isSessionAuthMode } from "@/lib/auth-mode";
async function handleAuthRequest(request: Request) { async function handleAuthRequest(request: Request) {
if (!isHostedAuthMode(env.AUTH_MODE)) { if (!isSessionAuthMode(env.AUTH_MODE)) {
return new Response("Not found", { return new Response("Not found", {
status: 404, status: 404,
}); });
} }
if (!hasHostedAuthConfig()) { if (!hasSessionAuthConfig()) {
return new Response("Missing Better Auth hosted configuration", { return new Response("Missing Better Auth configuration", {
status: 500, status: 500,
}); });
} }

View File

@ -6,9 +6,14 @@ import { z } from "zod";
import { db } from "@/db"; import { db } from "@/db";
import { account } from "@/db/schema"; import { account } from "@/db/schema";
import { getAuth } from "@/lib/auth"; import { getAuth } from "@/lib/auth";
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode"; import {
getAuthMode,
isHostedAuthMode,
isSessionAuthMode,
} from "@/lib/auth-mode";
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess"; import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated"; import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { responseForAppError } from "@/server/lib/http-errors"; import { responseForAppError } from "@/server/lib/http-errors";
import { getPublicOrigin } from "@/server/mcp/public-origin"; import { getPublicOrigin } from "@/server/mcp/public-origin";
@ -385,10 +390,13 @@ export async function handleSelfHostedGoogleOAuthCallbackRequest(
try { try {
const authMode = getAuthMode(env.AUTH_MODE); const authMode = getAuthMode(env.AUTH_MODE);
if (isHostedAuthMode(authMode)) { if (isHostedAuthMode(authMode)) {
// Hosted uses Better Auth's genericOAuth provider, not this hand-rolled
// flow.
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
} }
const context = const context = isSessionAuthMode(authMode)
authMode === "local_noauth" ? await resolveHostedContext(request.headers)
: authMode === "local_noauth"
? await resolveLocalNoAuthContext() ? await resolveLocalNoAuthContext()
: await resolveCloudflareAccessContext(request.headers); : await resolveCloudflareAccessContext(request.headers);
return await handleSelfHostedGoogleOAuthCallback({ return await handleSelfHostedGoogleOAuthCallback({