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>
72 lines
2.2 KiB
TypeScript
72 lines
2.2 KiB
TypeScript
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";
|
|
import { AppError } from "@/server/lib/errors";
|
|
import type { EnsuredUserContext } from "./types";
|
|
|
|
async function requireHostedSession(headers: Headers) {
|
|
if (!hasSessionAuthConfig()) {
|
|
throw new AppError(
|
|
"AUTH_CONFIG_MISSING",
|
|
"Missing Better Auth configuration",
|
|
);
|
|
}
|
|
|
|
const session = await getAuth().api.getSession({ headers });
|
|
|
|
if (!session?.user?.id || !session.user.email) {
|
|
throw new AppError("UNAUTHENTICATED");
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
export async function resolveHostedContext(
|
|
headers: Headers,
|
|
): Promise<EnsuredUserContext> {
|
|
const session = await requireHostedSession(headers);
|
|
const activeOrganizationId = getActiveOrganizationId(session);
|
|
|
|
if (activeOrganizationId) {
|
|
// The session's activeOrganizationId is only a hint (it can outlive a
|
|
// membership: removal, org deletion, cookie cache). The member row is the
|
|
// authorization fact and also carries the caller's role.
|
|
const membership = await AuthRepository.getMembership(
|
|
session.user.id,
|
|
activeOrganizationId,
|
|
);
|
|
|
|
if (membership) {
|
|
return {
|
|
userId: session.user.id,
|
|
userEmail: session.user.email,
|
|
emailVerified: session.user.emailVerified ?? false,
|
|
organizationId: activeOrganizationId,
|
|
role: membership.role,
|
|
};
|
|
}
|
|
}
|
|
|
|
// No active org, or a stale one: re-resolve from live memberships (creating
|
|
// a default workspace only when the user has none) and repoint the session.
|
|
const authApi = getAuth().api;
|
|
const resolved = await resolveActiveHostedOrganization(
|
|
session.user.id,
|
|
(body) => authApi.createOrganization({ body }),
|
|
);
|
|
|
|
await authApi.setActiveOrganization({
|
|
headers,
|
|
body: { organizationId: resolved.organizationId },
|
|
});
|
|
|
|
return {
|
|
userId: session.user.id,
|
|
userEmail: session.user.email,
|
|
emailVerified: session.user.emailVerified ?? false,
|
|
organizationId: resolved.organizationId,
|
|
role: resolved.role,
|
|
};
|
|
}
|