metatroncubeswdev c47b032f1a
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
Add team auth mode (backend, inert until AUTH_MODE=team)
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>
2026-09-09 00:51:20 -04:00

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,
};
}