File size: 8,969 Bytes
95b671e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | import { db, schema } from "@superlog/db";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, organization } from "better-auth/plugins";
import { autumn } from "autumn-js/better-auth";
import { eq } from "drizzle-orm";
import {
orgInvitationEmailBody,
passwordResetEmailBody,
sendEmail,
verificationEmailBody,
} from "./email.js";
// Better Auth server config. Mounted at /api/auth/* by apps/api/src/index.ts.
//
// Schema mapping: we reuse our existing `users`, `orgs`, `org_members` tables
// instead of letting Better Auth own parallel ones. The drizzle adapter
// accepts a `schema` map keyed by the model names BA expects. The
// organization plugin's `member.organizationId` and `invitation.organizationId`
// columns live on the `orgId` TS property in our schema, so we map those
// explicitly via the plugin's `schema.X.fields` config.
//
// Identity is UUID end-to-end: `advanced.database.generateId` makes BA emit
// UUIDs for new rows so foreign keys to our existing uuid columns line up.
const WEB_ORIGIN = process.env.WEB_ORIGIN ?? "http://localhost:5173";
const API_ORIGIN = process.env.BETTER_AUTH_URL ?? "http://localhost:4100";
// Derive the parent domain that web and api share so the OAuth state cookie
// is scoped to `.<eTLD+1>` instead of pinned to the api host. For prod that's
// `.superlog.sh`; for localhost / .superlog.localhost it's `undefined`
// because browsers won't honour Domain= on a TLD or localhost.
function sharedCookieDomain(): string | undefined {
try {
const apiHost = new URL(API_ORIGIN).hostname;
const webHost = new URL(WEB_ORIGIN).hostname;
if (apiHost === webHost) return undefined; // same host, no cross-sub needed
if (apiHost === "localhost" || webHost === "localhost") return undefined;
if (apiHost.endsWith(".localhost") || webHost.endsWith(".localhost")) return undefined;
// Trim to the longest common dotted suffix shared by both hosts. For
// api.superlog.sh / superlog.sh that yields "superlog.sh".
const a = apiHost.split(".");
const b = webHost.split(".");
const parts: string[] = [];
while (a.length && b.length && a[a.length - 1] === b[b.length - 1]) {
parts.unshift(a.pop() as string);
b.pop();
}
if (parts.length < 2) return undefined; // needs at least eTLD+1
return `.${parts.join(".")}`;
} catch {
return undefined;
}
}
const COOKIE_DOMAIN = sharedCookieDomain();
// Autumn billing plugin. Maps each org (a Better Auth "organization") to an
// Autumn customer, auto-created on first sign-in, so billing is per-org. Only
// enabled when AUTUMN_SECRET_KEY is present, so local dev / worktrees without
// billing config keep working — same opt-in pattern as the social providers.
const autumnPlugins = process.env.AUTUMN_SECRET_KEY
? [autumn({ customerScope: "organization" })]
: [];
// Fail loud and early if the signing secret is missing. Better Auth doesn't
// throw on undefined `secret` — depending on the build it falls back to a
// fresh random value per process start, which silently invalidates every
// session on every deploy. Match the previous CLERK_SECRET_KEY guard so
// missing config surfaces as a startup error, not a slow-burn outage.
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
if (!BETTER_AUTH_SECRET) throw new Error("BETTER_AUTH_SECRET is required");
// Both providers stay disabled when their client IDs are missing — Better
// Auth's `socialProviders` map only enables what's present, so dropping the
// keys here is enough to hide the buttons in dev without breaking sign-up.
const googleProvider =
process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}
: undefined;
const githubProvider =
process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
? {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
// GitHub returns email: null in /user when the address is set to
// "Keep my email addresses private". user:email scope lets Better Auth
// fall back to /user/emails for the verified primary. Has no effect on
// GitHub Apps (which derive scopes from the App's User permissions) —
// for those, enable the "Email addresses" user permission on the App.
scope: ["user:email", "read:user"],
}
: undefined;
// Picks the org to make active for a session. Prefers an explicit override
// (set via the org plugin's setActive API) then falls back to the user's
// first membership. Returns null if the user has no memberships — that's an
// error state callers can decide how to handle.
async function pickActiveOrgId(userId: string): Promise<string | null> {
const membership = await db.query.orgMembers.findFirst({
where: eq(schema.orgMembers.userId, userId),
});
return membership?.orgId ?? null;
}
export const auth = betterAuth({
baseURL: API_ORIGIN,
secret: BETTER_AUTH_SECRET,
trustedOrigins: [WEB_ORIGIN],
database: drizzleAdapter(db, {
provider: "pg",
schema: {
user: schema.users,
session: schema.sessions,
account: schema.accounts,
verification: schema.verifications,
organization: schema.orgs,
member: schema.orgMembers,
invitation: schema.invitations,
},
}),
advanced: {
database: {
generateId: () => crypto.randomUUID(),
},
// Web and API often live on different subdomains of the same parent
// domain (e.g. `superlog.sh` + `api.superlog.sh`). The OAuth state
// cookie has to round-trip from BA's `/api/auth/sign-in/social` response
// through the provider and back to `/api/auth/callback/<provider>`; if
// the cookie is pinned to the API host instead of the parent domain it
// can fail to ride along on some browsers and BA returns
// `State mismatch: verification not found`. When the hosts share an
// eTLD+1, scope to the parent. localhost / *.localhost are skipped
// because browsers reject Domain= on those.
...(COOKIE_DOMAIN
? {
crossSubDomainCookies: { enabled: true, domain: COOKIE_DOMAIN },
defaultCookieAttributes: { sameSite: "lax" as const, secure: true },
}
: {}),
},
emailAndPassword: {
enabled: true,
// Sign-ups still land logged-in immediately to keep dev/worktree flows
// fast — the verification email is still sent (see `emailVerification`
// below) so the user can confirm at their leisure. Flip this to true once
// we want to gate /api/* access on a verified address.
requireEmailVerification: false,
sendResetPassword: async ({ user, url }) => {
const body = passwordResetEmailBody(url);
await sendEmail({ to: user.email, subject: "Reset your Superlog password", ...body });
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
const body = verificationEmailBody(url);
await sendEmail({ to: user.email, subject: "Confirm your email for Superlog", ...body });
},
},
socialProviders: {
...(googleProvider ? { google: googleProvider } : {}),
...(githubProvider ? { github: githubProvider } : {}),
},
plugins: [
organization({
schema: {
// Our `org_members` table uses `orgId` (TS) / `org_id` (SQL) instead
// of Better Auth's expected `organizationId`. Same for `invitations`.
member: {
fields: { organizationId: "orgId" },
},
invitation: {
fields: { organizationId: "orgId" },
},
},
sendInvitationEmail: async ({ id, email, role, organization, inviter }) => {
const url = `${WEB_ORIGIN}/accept-invitation?id=${encodeURIComponent(id)}`;
const body = orgInvitationEmailBody({
url,
orgName: organization.name,
inviterEmail: inviter.user.email,
inviterName: inviter.user.name,
role,
});
await sendEmail({
to: email,
subject: `You're invited to ${organization.name} on Superlog`,
...body,
});
},
}),
admin(),
...autumnPlugins,
],
databaseHooks: {
session: {
create: {
before: async (session) => {
// Seed the active org so the first request after sign-in already
// has org context. If the user has no memberships yet (e.g. on a
// bootstrap race) leave it null; callers handle that.
if (session.activeOrganizationId) return { data: session };
const activeOrganizationId = await pickActiveOrgId(session.userId);
return { data: { ...session, activeOrganizationId } };
},
},
},
},
});
export type Auth = typeof auth;
|