diff --git a/.env.example b/.env.example
index 15612ff..892ef2d 100644
--- a/.env.example
+++ b/.env.example
@@ -18,11 +18,17 @@
# -----------------------------------------------------------------------------
# - cloudflare_access: validate Cloudflare Access JWTs (recommended for deploys)
# - 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.
# 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.
# TEAM_DOMAIN=https://your-team.cloudflareaccess.com
# POLICY_AUD=your-cloudflare-access-aud-tag
diff --git a/src/client/components/AuthConfigErrorCard.tsx b/src/client/components/AuthConfigErrorCard.tsx
index 47b8bc5..1c13998 100644
--- a/src/client/components/AuthConfigErrorCard.tsx
+++ b/src/client/components/AuthConfigErrorCard.tsx
@@ -1,5 +1,8 @@
import { ShieldAlert } from "lucide-react";
-import { isHostedClientAuthMode } from "@/lib/auth-mode";
+import {
+ isHostedClientAuthMode,
+ isSessionClientAuthMode,
+} from "@/lib/auth-mode";
const CLOUDFLARE_SETUP_GUIDE_URL =
"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,
}: AuthConfigErrorCardProps) {
const isHostedMode = isHostedClientAuthMode();
+ const isTeamMode = isSessionClientAuthMode() && !isHostedMode;
return (
@@ -27,7 +31,15 @@ export function AuthConfigErrorCard({
{message}
- {isHostedMode ? (
+ {isTeamMode ? (
+
+ Team mode requires BETTER_AUTH_SECRET
+ (32+ characters) and
+ BETTER_AUTH_URL
+ {" "}
+ on the deployment.
+
+ ) : isHostedMode ? (
Hosted mode requires{" "}
BETTER_AUTH_SECRET
diff --git a/src/client/components/Sidebar.tsx b/src/client/components/Sidebar.tsx
index 849434f..25f090b 100644
--- a/src/client/components/Sidebar.tsx
+++ b/src/client/components/Sidebar.tsx
@@ -25,7 +25,10 @@ import { SamSidebarPanel } from "@/client/features/sam/SamSidebarPanel";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { closeDropdown } from "@/client/lib/dropdown";
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";
interface SidebarProps {
@@ -237,12 +240,13 @@ function SidebarViewTab({
function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
const { data: session } = useSession();
const isHostedMode = isHostedClientAuthMode();
+ const isSessionMode = isSessionClientAuthMode();
const email = session?.user?.email;
const [isSwitching, setIsSwitching] = useState(false);
const orgContextQuery = useQuery({
...organizationContextQueryOptions(),
- enabled: isHostedMode && Boolean(email),
+ enabled: isSessionMode && Boolean(email),
});
const organizations = orgContextQuery.data?.organizations ?? [];
const activeOrganizationId = orgContextQuery.data?.organizationId;
@@ -338,7 +342,7 @@ function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
) : null}
- {isHostedMode ? (
+ {isSessionMode ? (
<>
{
- if (typeof window === "undefined" || !isHostedMode) {
+ if (typeof window === "undefined" || !isSessionMode) {
return;
}
window.location.replace(signInHref);
- }, [isHostedMode, signInHref]);
+ }, [isSessionMode, signInHref]);
- if (isHostedMode) {
+ if (isSessionMode) {
return null;
}
diff --git a/src/client/features/auth/AuthPage.tsx b/src/client/features/auth/AuthPage.tsx
index 7f3078e..95e6a0b 100644
--- a/src/client/features/auth/AuthPage.tsx
+++ b/src/client/features/auth/AuthPage.tsx
@@ -1,9 +1,14 @@
+import { Link } from "@tanstack/react-router";
import { z } from "zod";
import {
getCurrentAuthRedirect,
getOAuthSignedQuery,
+ getSignInSearch,
} from "@/lib/auth-redirect";
-import { isHostedClientAuthMode } from "@/lib/auth-mode";
+import {
+ isHostedClientAuthMode,
+ isSessionClientAuthMode,
+} from "@/lib/auth-mode";
export const authRedirectSearchSchema = z.object({
redirect: z.string().optional(),
@@ -16,11 +21,15 @@ export function useAuthPageState(redirect: string | undefined) {
? getOAuthSignedQuery(window.location.search)
: null;
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 {
redirectTo,
oauthQuery,
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 (
+
+
+ Back to sign in
+
+
+ );
+}
+
export function AuthPageShell({ children }: { children: React.ReactNode }) {
return (
// `h-[100dvh]` + `overflow-y-auto` makes this a scroll container, and the
diff --git a/src/client/features/auth/useHostedAuthRouteGuard.ts b/src/client/features/auth/useHostedAuthRouteGuard.ts
index a203ad7..869b732 100644
--- a/src/client/features/auth/useHostedAuthRouteGuard.ts
+++ b/src/client/features/auth/useHostedAuthRouteGuard.ts
@@ -4,6 +4,7 @@ import { useSession } from "@/lib/auth-client";
import {
isEmailVerificationBypassed,
isHostedClientAuthMode,
+ isSessionClientAuthMode,
} from "@/lib/auth-mode";
import {
getCurrentAuthRedirectFromHref,
@@ -14,12 +15,17 @@ import {
export function useHostedAuthRouteGuard() {
const navigate = useNavigate();
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 emailVerified =
- session?.user?.emailVerified === true || isEmailVerificationBypassed();
+ !isHostedMode ||
+ session?.user?.emailVerified === true ||
+ isEmailVerificationBypassed();
useEffect(() => {
- if (isPending || !isHostedMode) {
+ if (isPending || !isSessionMode) {
return;
}
@@ -43,7 +49,7 @@ export function useHostedAuthRouteGuard() {
}
}, [
isPending,
- isHostedMode,
+ isSessionMode,
emailVerified,
session?.user?.email,
session?.user?.id,
@@ -55,6 +61,7 @@ export function useHostedAuthRouteGuard() {
return {
isHostedMode,
- canRenderAuthenticatedContent: !isHostedMode || hasVerifiedHostedSession,
+ isSessionMode,
+ canRenderAuthenticatedContent: !isSessionMode || hasVerifiedHostedSession,
};
}
diff --git a/src/env.d.ts b/src/env.d.ts
index db7d478..788c269 100644
--- a/src/env.d.ts
+++ b/src/env.d.ts
@@ -24,7 +24,7 @@ declare namespace Cloudflare {
// oxlint-disable-next-line typescript-eslint/consistent-type-imports
AUDIT_ENGINE: Service;
- AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
+ AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted" | "team";
BYPASS_EMAIL_VERIFICATION?: string;
TEAM_DOMAIN?: string;
POLICY_AUD?: string;
@@ -66,7 +66,7 @@ declare namespace Cloudflare {
}
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 BYPASS_EMAIL_VERIFICATION?: string;
readonly POSTHOG_PUBLIC_KEY?: string;
diff --git a/src/lib/auth-mode.ts b/src/lib/auth-mode.ts
index 30797f2..571ad3b 100644
--- a/src/lib/auth-mode.ts
+++ b/src/lib/auth-mode.ts
@@ -4,6 +4,7 @@ export const AUTH_MODES = [
"cloudflare_access",
"local_noauth",
"hosted",
+ "team",
] as const;
type AuthMode = (typeof AUTH_MODES)[number];
@@ -42,6 +43,23 @@ export function isHostedClientAuthMode() {
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() {
// Local-dev escape hatch (BYPASS_EMAIL_VERIFICATION=true). The server skips
// verification and never marks users emailVerified, so the client must treat
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index d237fba..bd2a495 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -11,7 +11,11 @@ import { pgDb } from "@/db/pg/client";
import * as pgSchema from "@/db/pg/schema";
import { getDatabaseProvider } from "@/db/provider";
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 { createBaseAuthConfig } from "@/lib/auth-config";
import {
@@ -43,12 +47,12 @@ function createAuth() {
// Hosted needs the real configured URL (cookies, callbacks, /api/auth routes
// 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.
- const baseUrl = isHostedAuthMode(env.AUTH_MODE)
+ const baseUrl = isSessionAuthMode(env.AUTH_MODE)
? getHostedBaseUrl()
: "http://localhost";
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
const baseAuthConfig = createBaseAuthConfig(
- isHostedAuthMode(env.AUTH_MODE)
+ isSessionAuthMode(env.AUTH_MODE)
? {
organization: {
// No sendInvitationEmail here on purpose: better-auth swallows a
@@ -161,28 +165,39 @@ function createAuth() {
...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,
revokeSessionsOnPasswordReset: true,
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({
email: user.email,
resetUrl: url,
});
},
},
- emailVerification: bypassEmail
- ? undefined
- : {
- sendOnSignUp: true,
- autoSignInAfterVerification: true,
- sendVerificationEmail: async ({ user, url }) => {
- await sendHostedVerificationEmail({
- email: user.email,
- confirmationUrl: url,
- });
- },
- },
+ emailVerification:
+ isHostedAuthMode(env.AUTH_MODE) && !bypassEmail
+ ? {
+ sendOnSignUp: true,
+ autoSignInAfterVerification: true,
+ sendVerificationEmail: async ({ user, url }) => {
+ await sendHostedVerificationEmail({
+ email: user.email,
+ confirmationUrl: url,
+ });
+ },
+ }
+ : undefined,
socialProviders: getSocialProviders(),
// Where OAuth redirect-flow failures land when Better Auth can't honor a
// per-flow errorCallbackURL (Google-side errors like a canceled consent
@@ -226,8 +241,8 @@ function createAuth() {
return { data: user };
},
after: async (user, ctx) => {
- await syncHostedSignupContact(user);
if (isHostedAuthMode(env.AUTH_MODE)) {
+ await syncHostedSignupContact(user);
await captureDubReferralSignup(user.id, ctx?.request);
}
},
@@ -377,7 +392,7 @@ function hasHostedAuthEmailConfig() {
});
}
-export function hasHostedAuthConfig() {
+function hasHostedAuthConfig() {
try {
getHostedBaseUrl();
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() {
if (authInstance) {
return authInstance;
diff --git a/src/lib/selfhost-preflight.ts b/src/lib/selfhost-preflight.ts
index eb0555e..e20dd7d 100644
--- a/src/lib/selfhost-preflight.ts
+++ b/src/lib/selfhost-preflight.ts
@@ -58,6 +58,37 @@ function checkAuthMode(env: EnvRecord, items: PreflightItem[]): void {
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") {
const missing = [
"BETTER_AUTH_URL",
diff --git a/src/middleware/ensure-user/hosted.ts b/src/middleware/ensure-user/hosted.ts
index 0dfd25f..d3e4001 100644
--- a/src/middleware/ensure-user/hosted.ts
+++ b/src/middleware/ensure-user/hosted.ts
@@ -1,4 +1,4 @@
-import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
+import { getAuth, hasSessionAuthConfig } from "@/lib/auth";
import { getActiveOrganizationId } from "@/lib/auth-session";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { resolveActiveHostedOrganization } from "@/server/auth/default-hosted-organization";
@@ -6,10 +6,10 @@ import { AppError } from "@/server/lib/errors";
import type { EnsuredUserContext } from "./types";
async function requireHostedSession(headers: Headers) {
- if (!hasHostedAuthConfig()) {
+ if (!hasSessionAuthConfig()) {
throw new AppError(
"AUTH_CONFIG_MISSING",
- "Missing Better Auth hosted configuration",
+ "Missing Better Auth configuration",
);
}
diff --git a/src/middleware/ensure-user/resolve.ts b/src/middleware/ensure-user/resolve.ts
index 81eefd4..99974d8 100644
--- a/src/middleware/ensure-user/resolve.ts
+++ b/src/middleware/ensure-user/resolve.ts
@@ -1,5 +1,5 @@
import { env } from "cloudflare:workers";
-import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
+import { getAuthMode, isSessionAuthMode } from "@/lib/auth-mode";
import { resolveCloudflareAccessContext } from "./cloudflareAccess";
import { resolveLocalNoAuthContext } from "./delegated";
import { resolveHostedContext } from "./hosted";
@@ -15,7 +15,10 @@ export async function resolveUserContextFromHeaders(
if (authMode === "local_noauth") {
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 resolveCloudflareAccessContext(headers);
diff --git a/src/routes/_app/settings.tsx b/src/routes/_app/settings.tsx
index 33fa486..01b9632 100644
--- a/src/routes/_app/settings.tsx
+++ b/src/routes/_app/settings.tsx
@@ -1,5 +1,5 @@
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")({
component: SettingsLayout,
@@ -12,7 +12,7 @@ function SettingsLayout() {
const tabs = [
{ to: "/settings" as const, label: "Personal", exact: true },
// Self-host has no memberships — the organization tab would 404.
- ...(isHostedClientAuthMode()
+ ...(isSessionClientAuthMode()
? [{ to: "/settings/organization" as const, label: "Organization" }]
: []),
];
diff --git a/src/routes/_app/settings/organization.tsx b/src/routes/_app/settings/organization.tsx
index ea7430a..56577dc 100644
--- a/src/routes/_app/settings/organization.tsx
+++ b/src/routes/_app/settings/organization.tsx
@@ -1,12 +1,12 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
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")({
// Self-host has no memberships or invitations — the better-auth HTTP
// surface isn't even mounted there.
beforeLoad: () => {
- if (!isHostedClientAuthMode()) {
+ if (!isSessionClientAuthMode()) {
throw notFound();
}
},
diff --git a/src/routes/_auth.sign-in.tsx b/src/routes/_auth.sign-in.tsx
index 26b3439..22600bc 100644
--- a/src/routes/_auth.sign-in.tsx
+++ b/src/routes/_auth.sign-in.tsx
@@ -26,11 +26,11 @@ export const Route = createFileRoute("/_auth/sign-in")({
function SignInPage() {
const search = Route.useSearch();
const navigate = useNavigate();
- const { redirectTo, oauthQuery, isHostedMode } = useAuthPageState(
- search.redirect,
- );
+ const { redirectTo, oauthQuery, isHostedMode, isSessionMode } =
+ useAuthPageState(search.redirect);
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 [socialError, setSocialError] = useState(null);
@@ -155,7 +155,7 @@ function SignInPage() {
<>
{
void handleContinueWithGoogle();
@@ -190,7 +190,7 @@ function SignInPage() {
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email"
- disabled={!isHostedMode}
+ disabled={!isSessionMode}
required
/>
{error ? (
@@ -214,7 +214,7 @@ function SignInPage() {
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="current-password"
- disabled={!isHostedMode}
+ disabled={!isSessionMode}
required
/>
{error ? (
@@ -240,7 +240,7 @@ function SignInPage() {
) : null}
diff --git a/src/routes/_auth.sign-up.tsx b/src/routes/_auth.sign-up.tsx
index a448037..1915dd4 100644
--- a/src/routes/_auth.sign-up.tsx
+++ b/src/routes/_auth.sign-up.tsx
@@ -5,6 +5,7 @@ import {
AuthPageCard,
AuthMethodChooser,
authRedirectSearchSchema,
+ InviteOnlyCard,
useAuthPageState,
} from "@/client/features/auth/AuthPage";
import {
@@ -51,7 +52,9 @@ export const Route = createFileRoute("/_auth/sign-up")({
function SignUpPage() {
const search = Route.useSearch();
const navigate = useNavigate();
- const { redirectTo, isHostedMode } = useAuthPageState(search.redirect);
+ const { redirectTo, isHostedMode, isSessionMode } = useAuthPageState(
+ search.redirect,
+ );
const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo;
const [showEmailForm, setShowEmailForm] = useState(false);
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 ;
+ }
+
return (
{
@@ -31,7 +31,7 @@ function AuthPageLayout() {
void navigate({ href: redirectTo, replace: true });
}, [navigate, redirectTo, session?.user?.id]);
- if (isHostedMode && (isPending || session?.user?.id)) {
+ if (isSessionMode && (isPending || session?.user?.id)) {
return null;
}
diff --git a/src/routes/_authenticated.tsx b/src/routes/_authenticated.tsx
index 32b8bb1..333351b 100644
--- a/src/routes/_authenticated.tsx
+++ b/src/routes/_authenticated.tsx
@@ -9,7 +9,7 @@ export const Route = createFileRoute("/_authenticated")({
function AuthenticatedShellLayout() {
const authGate = useHostedAuthRouteGuard();
- if (!authGate.isHostedMode || !authGate.canRenderAuthenticatedContent) {
+ if (!authGate.isSessionMode || !authGate.canRenderAuthenticatedContent) {
return null;
}
diff --git a/src/routes/accept-invitation.$id.tsx b/src/routes/accept-invitation.$id.tsx
index f3e758e..9a748ee 100644
--- a/src/routes/accept-invitation.$id.tsx
+++ b/src/routes/accept-invitation.$id.tsx
@@ -4,11 +4,11 @@ import { useState } from "react";
import { AuthPageCard, AuthPageShell } from "@/client/features/auth/AuthPage";
import { captureClientEvent } from "@/client/lib/posthog";
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")({
beforeLoad: () => {
- if (!isHostedClientAuthMode()) {
+ if (!isSessionClientAuthMode()) {
throw notFound();
}
},
diff --git a/src/routes/api/auth/$.ts b/src/routes/api/auth/$.ts
index 0e87161..d593c72 100644
--- a/src/routes/api/auth/$.ts
+++ b/src/routes/api/auth/$.ts
@@ -1,17 +1,17 @@
import { createFileRoute } from "@tanstack/react-router";
import { env } from "cloudflare:workers";
-import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
-import { isHostedAuthMode } from "@/lib/auth-mode";
+import { getAuth, hasSessionAuthConfig } from "@/lib/auth";
+import { isSessionAuthMode } from "@/lib/auth-mode";
async function handleAuthRequest(request: Request) {
- if (!isHostedAuthMode(env.AUTH_MODE)) {
+ if (!isSessionAuthMode(env.AUTH_MODE)) {
return new Response("Not found", {
status: 404,
});
}
- if (!hasHostedAuthConfig()) {
- return new Response("Missing Better Auth hosted configuration", {
+ if (!hasSessionAuthConfig()) {
+ return new Response("Missing Better Auth configuration", {
status: 500,
});
}
diff --git a/src/server/features/google/selfHostedOAuth.ts b/src/server/features/google/selfHostedOAuth.ts
index 817e288..16f87a8 100644
--- a/src/server/features/google/selfHostedOAuth.ts
+++ b/src/server/features/google/selfHostedOAuth.ts
@@ -6,9 +6,14 @@ import { z } from "zod";
import { db } from "@/db";
import { account } from "@/db/schema";
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 { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
+import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
import { AppError } from "@/server/lib/errors";
import { responseForAppError } from "@/server/lib/http-errors";
import { getPublicOrigin } from "@/server/mcp/public-origin";
@@ -385,10 +390,13 @@ export async function handleSelfHostedGoogleOAuthCallbackRequest(
try {
const authMode = getAuthMode(env.AUTH_MODE);
if (isHostedAuthMode(authMode)) {
+ // Hosted uses Better Auth's genericOAuth provider, not this hand-rolled
+ // flow.
return new Response("Not found", { status: 404 });
}
- const context =
- authMode === "local_noauth"
+ const context = isSessionAuthMode(authMode)
+ ? await resolveHostedContext(request.headers)
+ : authMode === "local_noauth"
? await resolveLocalNoAuthContext()
: await resolveCloudflareAccessContext(request.headers);
return await handleSelfHostedGoogleOAuthCallback({