diff --git a/.github/workflows/be-auth.yml b/.github/workflows/be-auth.yml index 4583b56be..7e6b40f87 100644 --- a/.github/workflows/be-auth.yml +++ b/.github/workflows/be-auth.yml @@ -5,12 +5,14 @@ on: branches: [ main ] paths: - 'apps/backend/auth/**' + - 'packages/auth/**' - 'docker-compose.setup.yml' - '.github/workflows/be-auth.yml' pull_request: branches: [ main ] paths: - 'apps/backend/auth/**' + - 'packages/auth/**' - 'docker-compose.setup.yml' - '.github/workflows/be-auth.yml' workflow_dispatch: @@ -34,6 +36,10 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Run @reloop/auth export isolation tests + run: bun run test + working-directory: packages/auth + - name: Run auth characterization tests run: bun run test working-directory: apps/backend/auth diff --git a/apps/backend/auth/src/auth.config.ts b/apps/backend/auth/src/auth.config.ts index 0cf542117..4db44a09a 100644 --- a/apps/backend/auth/src/auth.config.ts +++ b/apps/backend/auth/src/auth.config.ts @@ -1,22 +1,14 @@ +import { authServerConfig } from "@reloop/auth/server/config"; + +/** + * Auth service process config. + * Shared Better Auth / env keys come from {@link authServerConfig} + * (single source of truth). Service-only fields live here. + */ export const authConfig = { + ...authServerConfig, port: Number(process.env.PORT || "8000"), - PG_URL: - process.env.PG_URL || "postgresql://reloop:reloop123@localhost:5432/reloop", - REDIS_URL: process.env.REDIS_URL || "redis://:reloop123@localhost:6379", - BASE_URL: process.env.BASE_URL || "https://local.reloop.sh", - NODE_ENV: process.env.NODE_ENV || "development", NODE_TLS_REJECT_UNAUTHORIZED: process.env.NODE_TLS_REJECT_UNAUTHORIZED || "0", - BETTER_AUTH_SECRET: - process.env.BETTER_AUTH_SECRET || "tENkVU4GrhckuRw4Bcfh93EWgXOFcszn", - GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, - GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, - GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, - GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, - DEFAULT_OTP: process.env.DEFAULT_OTP, - DISABLE_SIGNUP: process.env.DISABLE_SIGNUP, - /** When true, new accounts require a valid platform signup invite. */ - REQUIRE_SIGNUP_INVITE: process.env.REQUIRE_SIGNUP_INVITE === "true", - NATS_URL: process.env.NATS_URL || "nats://localhost:4222", OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "", OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS || "", }; diff --git a/apps/backend/auth/src/landing.ts b/apps/backend/auth/src/landing.ts index 22a4fc9c3..f29ccce46 100644 --- a/apps/backend/auth/src/landing.ts +++ b/apps/backend/auth/src/landing.ts @@ -1,6 +1,6 @@ +import { authRedis as redis } from "@reloop/auth/server"; import { db } from "@reloop/db/client"; import { Elysia } from "elysia"; -import { redis } from "./lib/redis"; export const landing = new Elysia() .get("/", async () => { diff --git a/apps/backend/auth/src/lib/auth.ts b/apps/backend/auth/src/lib/auth.ts index 8c7541843..22a64e72a 100644 --- a/apps/backend/auth/src/lib/auth.ts +++ b/apps/backend/auth/src/lib/auth.ts @@ -1,452 +1,4 @@ -import { apiKey } from "@better-auth/api-key"; -import { ac, orgRoles } from "@reloop/auth/permissions"; -import { BusEvent, bus } from "@reloop/bus"; -import { db } from "@reloop/db/client"; -import * as schema from "@reloop/db/schema"; -import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { APIError, createAuthMiddleware } from "better-auth/api"; -import { - admin, - bearer, - emailOTP, - jwt, - lastLoginMethod, - openAPI, - organization, -} from "better-auth/plugins"; -import { eq } from "drizzle-orm"; -import { log } from "evlog"; -import { authConfig } from "../auth.config"; -import { - DEFAULT_USER_ROLE, - PLATFORM_ADMIN_ROLE, - platformAc, - platformRoles, -} from "./platform-roles"; -import { redis } from "./redis"; -import { - canCreateAccount, - findValidSignupInvite, - getSignupInviteCodeFromRequest, - markSignupInviteUsed, - normalizeEmail, - userExistsByEmail, -} from "./signup-invite"; - -export const auth = betterAuth({ - database: drizzleAdapter(db, { - provider: "pg", - schema: schema, - }), - user: { - additionalFields: { - activeOrganizationId: { - type: "string", - required: false, - input: true, - }, - mode: { - type: "string", - required: false, - input: true, - defaultValue: "dev", - }, - }, - }, - secondaryStorage: { - get: async (key) => { - return await redis.get(key); - }, - set: async (key, value, ttl) => { - if (ttl) await redis.set(key, value, ttl); - else await redis.set(key, value); - }, - delete: async (key) => { - await redis.delete(key); - }, - }, - databaseHooks: { - user: { - create: { - before: async (user, context) => { - if (!authConfig.REQUIRE_SIGNUP_INVITE) return; - - const email = normalizeEmail(user.email); - const code = context?.headers - ? getSignupInviteCodeFromRequest(context.headers) - : null; - let access = await canCreateAccount({ email, code }); - if (!access.allowed && code) { - access = await canCreateAccount({ email }); - } - - if (!access.allowed) { - throw new APIError("FORBIDDEN", { - message: "A valid signup invite is required to create an account", - }); - } - - if (access.signupInvite) { - await redis.set( - `signup_invite:pending:${email}`, - access.signupInvite.id, - 60 * 30, - ); - } - }, - after: async (user) => { - if (!authConfig.REQUIRE_SIGNUP_INVITE) return; - - const email = normalizeEmail(user.email); - const inviteId = await redis.get( - `signup_invite:pending:${email}`, - ); - if (inviteId) { - await markSignupInviteUsed({ - inviteId, - userId: user.id, - }); - await redis.delete(`signup_invite:pending:${email}`); - return; - } - - const invite = await findValidSignupInvite({ email }); - if (invite) { - await markSignupInviteUsed({ - inviteId: invite.id, - userId: user.id, - }); - } - }, - }, - }, - }, - hooks: { - after: createAuthMiddleware(async (ctx) => { - const { path, context } = ctx; - log.info({ message: String(ctx.path) }); - // 🔐 User registered - if (path === "/sign-up/email-otp") { - const newSession = context.newSession; - if (newSession) { - log.info({ - ...{ data: newSession.user }, - message: "🔐 User registered:", - }); - await bus.publish( - BusEvent.USER_CREATED, - { - id: newSession.user.id, - email: newSession.user.email, - name: newSession.user.name || undefined, - }, - { msgId: `user_created:${newSession.user.email}` }, - ); - } - } - - // 🔓 User signed in - if ( - path === "/sign-in/email-otp" || - path === "/callback/google" || - path === "/callback/github" - ) { - const data = context.newSession; - if (data) { - const { session, user } = data; - log.info({ ...{ data: user.email }, message: "🔓 User signed in:" }); - // Use a 1-minute bucket for sign-in deduplication - const bucket = Math.floor(Date.now() / 60000); - await bus.publish( - BusEvent.SIGNIN_DETECTED, - { - email: user.email, - fullName: user.name || "User", - browser: session.userAgent || "Unknown Browser", - os: "Unknown OS", - ip: session.ipAddress || "0.0.0.0", - location: "Unknown Location", - }, - { msgId: `signin_detected:${user.email}:${bucket}` }, - ); - } - } - }), - }, - basePath: "/api/auth/v1", - telemetry: { enabled: false }, - emailAndPassword: { - enabled: true, - autoSignIn: true, - disableSignUp: authConfig.DISABLE_SIGNUP === "true", - }, - socialProviders: { - google: { - clientId: authConfig.GOOGLE_CLIENT_ID as string, - clientSecret: authConfig.GOOGLE_CLIENT_SECRET as string, - }, - github: { - clientId: authConfig.GITHUB_CLIENT_ID as string, - clientSecret: authConfig.GITHUB_CLIENT_SECRET as string, - }, - }, - secret: authConfig.BETTER_AUTH_SECRET, - session: { - expiresIn: 60 * 60 * 24 * 7, - updateAge: 60 * 60 * 24, - }, - trustedOrigins: ["*"], - plugins: [ - jwt(), - bearer(), - admin({ - defaultRole: DEFAULT_USER_ROLE, - adminRoles: [PLATFORM_ADMIN_ROLE], - ac: platformAc, - roles: platformRoles, - }), - apiKey({ defaultPrefix: "rl" }), - lastLoginMethod({ - cookieName: "better-auth.last_used_login_method", - maxAge: 60 * 60 * 24 * 30, - customResolveMethod: (ctx) => { - if (ctx.path.includes("/oauth/callback/google")) { - return "google"; - } - if (ctx.path.includes("/oauth/callback/github")) { - return "github"; - } - if (ctx.path.includes("/sign-up/email")) { - return "email"; - } - if (ctx.path.includes("/sign-in/email")) { - return "email"; - } - return null; - }, - }), - emailOTP({ - expiresIn: 60 * 15, - allowedAttempts: 3, - async sendVerificationOTP({ email, otp, type }) { - if ( - authConfig.REQUIRE_SIGNUP_INVITE && - (type === "sign-in" || type === "email-verification") - ) { - const exists = await userExistsByEmail(email); - if (!exists) { - const access = await canCreateAccount({ email }); - if (!access.allowed) { - throw new APIError("FORBIDDEN", { - message: - "A valid signup invite is required to create an account", - }); - } - } - } - - log.info("server", `Sending OTP (${type}) to: ${email} (OTP: ${otp})`); - if (authConfig.DEFAULT_OTP && authConfig.NODE_ENV !== "development") - return; - try { - await bus.publish( - BusEvent.OTP_REQUESTED, - { email, otp, type }, - { msgId: `otp_requested:${email}:${otp}` }, - ); - log.info("server", `OTP bus event published for ${email} (${type})`); - } catch (error) { - log.error({ - ...{ data: error }, - message: "Failed to publish OTP event:", - }); - // If we are not using a default OTP, we must throw to notify the user - if (!authConfig.DEFAULT_OTP) { - throw new Error("Failed to send OTP email"); - } - } - }, - generateOTP() { - if (authConfig.DEFAULT_OTP) return authConfig.DEFAULT_OTP; - return Math.floor(100000 + Math.random() * 900000).toString(); - }, - }), - organization({ - ac, - roles: orgRoles, - additionalFields: { - organization: { - billingEmail: { - type: "string", - required: false, - input: true, - }, - billingName: { - type: "string", - required: false, - input: true, - }, - externalCustomerId: { - type: "string", - required: false, - input: true, - unique: true, - }, - status: { - type: "string", - required: false, - input: true, - defaultValue: "active", - }, - }, - }, - async sendInvitationEmail(data) { - const inviteLink = `${authConfig.BASE_URL}/dashboard/signup?inviteId=${data.id}`; - log.info({ - ...{ - email: data.email, - organization: data.organization.name, - }, - message: "📧 Organization invitation email requested:", - }); - - // Log invite URL in development for easy testing - if (authConfig.NODE_ENV === "development") { - log.info("server", `🔗 Invite URL (DEV): ${inviteLink}`); - } - - const isResend = - Date.now() - new Date(data.invitation.createdAt).getTime() > 5000; - - try { - await bus.publish( - BusEvent.INVITE_CREATED, - { - email: data.email, - organizationName: data.organization.name, - inviteLink, - inviterName: - data.inviter.user.name || - data.inviter.user.email.split("@")[0] || - "Someone", - inviterEmail: data.inviter.user.email, - isResend, - }, - { msgId: `invite_created:${data.id}:${Date.now()}` }, - ); - log.info( - "server", - `✅ Organization invite bus event published for ${data.email} (resend: ${isResend})`, - ); - } catch (error) { - log.error( - "server", - `❌ Failed to publish organization invite event:${error}`, - ); - } - }, - organizationHooks: { - afterAcceptInvitation: async ({ member, user, organization }) => { - try { - await bus.publish( - BusEvent.ORGANIZATION_JOINED, - { - organizationId: organization.id, - orgName: organization.name, - userId: user.id, - userEmail: user.email, - memberName: user.name || user.email, - role: member.role, - inviterName: "Admin", - }, - { msgId: `org_joined:${organization.id}:${user.id}` }, - ); - log.info( - "server", - `✅ Organization joined bus event published for ${user.email}`, - ); - - // Set active organization for the user - await db - .update(schema.user) - .set({ activeOrganizationId: organization.id }) - .where(eq(schema.user.id, user.id)); - log.info( - "server", - `✅ Active organization set to ${organization.id} for user ${user.id}`, - ); - } catch (error) { - log.error({ - ...{ data: error }, - message: - "❌ Failed to publish organization joined event or update user:", - }); - } - }, - }, - }), - openAPI({ - path: "/docs", - }), - ], - advanced: { - cookiePrefix: "reloop", - ipAddress: { - ipAddressHeaders: ["x-client-ip", "x-forwarded-for"], - }, - }, -}); - -let _schema: ReturnType | null = null; - -const getSchema = async () => { - if (!_schema) { - _schema = auth.api.generateOpenAPISchema(); - } - return _schema; -}; - -export const OpenAPI = { - getPaths: async (prefix = "/api/auth/v1") => { - try { - const { paths } = await getSchema(); - const reference: Record = {}; - - for (const path of Object.keys(paths)) { - const pathData = paths[path]; - if (!pathData) continue; - - const key = prefix + path; - reference[key] = { ...pathData }; - - for (const method of Object.keys(pathData)) { - const operation = reference[key][method]; - if (operation && typeof operation === "object") { - operation.tags = ["Better Auth"]; - } - } - } - - return reference; - } catch (error) { - log.error({ - ...{ data: error }, - message: "Failed to generate OpenAPI paths:", - }); - return {}; - } - }, - components: async () => { - try { - const { components } = await getSchema(); - return components; - } catch (error) { - log.error({ - ...{ data: error }, - message: "Failed to generate OpenAPI components:", - }); - return {}; - } - }, -} as const; +/** + * Thin re-export: the single Better Auth runtime lives in `@reloop/auth/server`. + */ +export { auth, OpenAPI } from "@reloop/auth/server"; diff --git a/apps/backend/auth/src/lib/platform-roles.ts b/apps/backend/auth/src/lib/platform-roles.ts deleted file mode 100644 index 0af2ab3b8..000000000 --- a/apps/backend/auth/src/lib/platform-roles.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createAccessControl } from "better-auth/plugins/access"; -import { - adminAc, - defaultStatements, - userAc, -} from "better-auth/plugins/admin/access"; - -/** Platform operator role for Reloop employees (Console / Admin API). */ -export const PLATFORM_ADMIN_ROLE = "super-admin" as const; - -/** Default Better Auth user role (non-operator). */ -export const DEFAULT_USER_ROLE = "user" as const; - -/** Access control for Better Auth's platform admin plugin (not org roles). */ -export const platformAc = createAccessControl(defaultStatements); - -export const platformRoles = { - [PLATFORM_ADMIN_ROLE]: platformAc.newRole({ - ...adminAc.statements, - }), - [DEFAULT_USER_ROLE]: platformAc.newRole({ - ...userAc.statements, - }), -}; diff --git a/apps/backend/auth/src/lib/redis.ts b/apps/backend/auth/src/lib/redis.ts deleted file mode 100644 index 790799175..000000000 --- a/apps/backend/auth/src/lib/redis.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { RedisCache } from "@reloop/cache/redis-client"; -import { authConfig } from "../auth.config"; -export const redis = new RedisCache("auth", 30 * 60, authConfig.REDIS_URL); diff --git a/apps/backend/auth/src/routes/signup-invite.route.ts b/apps/backend/auth/src/routes/signup-invite.route.ts index 8f2a6bc4a..524be6781 100644 --- a/apps/backend/auth/src/routes/signup-invite.route.ts +++ b/apps/backend/auth/src/routes/signup-invite.route.ts @@ -1,7 +1,5 @@ -import { Elysia, t } from "elysia"; -import { authConfig } from "../auth.config"; -import { auth } from "../lib/auth"; import { + auth, countPeerSignupInvitesUsed, createPeerSignupInvite, findSignupInviteByCode, @@ -11,7 +9,9 @@ import { revokePeerSignupInvite, SIGNUP_INVITE_COOKIE, SIGNUP_INVITE_COOKIE_MAX_AGE, -} from "../lib/signup-invite"; +} from "@reloop/auth/server"; +import { Elysia, t } from "elysia"; +import { authConfig } from "../auth.config"; async function requireSession(request: Request) { return auth.api.getSession({ headers: request.headers }); diff --git a/bun.lock b/bun.lock index 6965eca0c..9bae67873 100644 --- a/bun.lock +++ b/bun.lock @@ -847,13 +847,21 @@ "version": "0.1.0", "dependencies": { "@better-auth/api-key": "catalog:", + "@reloop/bus": "workspace:*", + "@reloop/cache": "workspace:*", + "@reloop/db": "workspace:*", "better-auth": "catalog:", + "drizzle-orm": "catalog:db", + "evlog": "catalog:backend", }, "devDependencies": { "@reloop/tsconfig": "workspace:*", "@types/node": "catalog:", "typescript": "catalog:", }, + "peerDependencies": { + "react": ">=18", + }, }, "packages/bus": { "name": "@reloop/bus", diff --git a/packages/auth/package.json b/packages/auth/package.json index 64f93b698..88ff83cba 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -5,6 +5,8 @@ "exports": { "./client": "./src/client.ts", "./server": "./src/server.ts", + "./server/config": "./src/server/config.ts", + "./types": "./src/types.ts", "./roles": "./src/roles.ts", "./permissions": "./src/permissions.ts", "./platform-permissions": "./src/platform-permissions.ts" @@ -13,15 +15,24 @@ "build": "tsc", "clean": "git clean -xdf .cache .turbo dist node_modules", "dev": "tsc", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "bun test test/" }, "dependencies": { + "@better-auth/api-key": "catalog:", + "@reloop/bus": "workspace:*", + "@reloop/cache": "workspace:*", + "@reloop/db": "workspace:*", "better-auth": "catalog:", - "@better-auth/api-key": "catalog:" + "drizzle-orm": "catalog:db", + "evlog": "catalog:backend" }, "devDependencies": { "@reloop/tsconfig": "workspace:*", "@types/node": "catalog:", "typescript": "catalog:" + }, + "peerDependencies": { + "react": ">=18" } } diff --git a/packages/auth/src/client.ts b/packages/auth/src/client.ts index bd84e095c..0fffb94dc 100644 --- a/packages/auth/src/client.ts +++ b/packages/auth/src/client.ts @@ -10,7 +10,7 @@ import { import { createAuthClient } from "better-auth/react"; import { ac, orgRoles } from "./permissions"; import { platformAc, platformRoles } from "./platform-permissions"; -import type { AuthInstance } from "./server"; +import type { AuthInstance } from "./types"; const baseURL = process.env.NEXT_PUBLIC_URL || diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index 546273165..798ac60f7 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -1,54 +1,5 @@ -import { apiKey } from "@better-auth/api-key"; -import { betterAuth } from "better-auth"; -import { admin, bearer, jwt, openAPI, organization } from "better-auth/plugins"; -import { ac, orgRoles } from "./permissions"; -import { platformAc, platformRoles } from "./platform-permissions"; -import { DEFAULT_USER_ROLE, PLATFORM_ADMIN_ROLE } from "./roles"; - -export const auth = betterAuth({ - baseURL: process.env.BASE_URL || "https://local.reloop.sh", - user: { - additionalFields: { - activeOrganizationId: { - type: "string", - required: false, - input: true, - }, - mode: { - type: "string", - required: false, - input: true, - defaultValue: "dev", - }, - }, - }, - basePath: "/api/auth/v1", - advanced: { - cookiePrefix: "reloop", - }, - plugins: [ - jwt(), - bearer(), - admin({ - defaultRole: DEFAULT_USER_ROLE, - adminRoles: [PLATFORM_ADMIN_ROLE], - ac: platformAc, - roles: platformRoles, - }), - apiKey({ defaultPrefix: "rl" }), - organization({ - ac, - roles: orgRoles, - sendInvitationEmail: async () => {}, - }), - openAPI({ path: "/docs" }), - ], -}); - -export type AuthInstance = typeof auth; -export type User = typeof auth.$Infer.Session.user; -export type Session = typeof auth.$Infer.Session & { - user: User & { - activeOrganizationId: string; - }; -}; +/** + * Package root re-export for `@reloop/auth/server`. + * Implementation lives under `./server/` so server-only modules stay grouped. + */ +export * from "./server/index"; diff --git a/packages/auth/src/server/auth.ts b/packages/auth/src/server/auth.ts new file mode 100644 index 000000000..e7b64e5be --- /dev/null +++ b/packages/auth/src/server/auth.ts @@ -0,0 +1,460 @@ +import { apiKey } from "@better-auth/api-key"; +import { BusEvent, bus } from "@reloop/bus"; +import { db } from "@reloop/db/client"; +import * as schema from "@reloop/db/schema"; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { APIError, createAuthMiddleware } from "better-auth/api"; +import { + admin, + bearer, + emailOTP, + jwt, + lastLoginMethod, + openAPI, + organization, +} from "better-auth/plugins"; +import { eq } from "drizzle-orm"; +import { log } from "evlog"; +import { ac, orgRoles } from "../permissions"; +import { platformAc, platformRoles } from "../platform-permissions"; +import { DEFAULT_USER_ROLE, PLATFORM_ADMIN_ROLE } from "../roles"; +import { authServerConfig } from "./config"; +import { redis } from "./redis"; +import { + canCreateAccount, + findValidSignupInvite, + getSignupInviteCodeFromRequest, + markSignupInviteUsed, + normalizeEmail, + userExistsByEmail, +} from "./signup-invite"; + +/** + * The single runtime Better Auth instance for Reloop. + * Auth service mounts `auth.handler`; client/types derive from this config only. + */ +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", + schema: schema, + }), + user: { + additionalFields: { + activeOrganizationId: { + type: "string", + required: false, + input: true, + }, + mode: { + type: "string", + required: false, + input: true, + defaultValue: "dev", + }, + }, + }, + secondaryStorage: { + get: async (key) => { + return await redis.get(key); + }, + set: async (key, value, ttl) => { + if (ttl) await redis.set(key, value, ttl); + else await redis.set(key, value); + }, + delete: async (key) => { + await redis.delete(key); + }, + }, + databaseHooks: { + user: { + create: { + before: async (user, context) => { + if (!authServerConfig.REQUIRE_SIGNUP_INVITE) return; + + const email = normalizeEmail(user.email); + const code = context?.headers + ? getSignupInviteCodeFromRequest(context.headers) + : null; + let access = await canCreateAccount({ email, code }); + if (!access.allowed && code) { + access = await canCreateAccount({ email }); + } + + if (!access.allowed) { + throw new APIError("FORBIDDEN", { + message: "A valid signup invite is required to create an account", + }); + } + + if (access.signupInvite) { + await redis.set( + `signup_invite:pending:${email}`, + access.signupInvite.id, + 60 * 30, + ); + } + }, + after: async (user) => { + if (!authServerConfig.REQUIRE_SIGNUP_INVITE) return; + + const email = normalizeEmail(user.email); + const inviteId = await redis.get( + `signup_invite:pending:${email}`, + ); + if (inviteId) { + await markSignupInviteUsed({ + inviteId, + userId: user.id, + }); + await redis.delete(`signup_invite:pending:${email}`); + return; + } + + const invite = await findValidSignupInvite({ email }); + if (invite) { + await markSignupInviteUsed({ + inviteId: invite.id, + userId: user.id, + }); + } + }, + }, + }, + }, + hooks: { + after: createAuthMiddleware(async (ctx) => { + const { path, context } = ctx; + log.info({ message: String(ctx.path) }); + // 🔐 User registered + if (path === "/sign-up/email-otp") { + const newSession = context.newSession; + if (newSession) { + log.info({ + ...{ data: newSession.user }, + message: "🔐 User registered:", + }); + await bus.publish( + BusEvent.USER_CREATED, + { + id: newSession.user.id, + email: newSession.user.email, + name: newSession.user.name || undefined, + }, + { msgId: `user_created:${newSession.user.email}` }, + ); + } + } + + // 🔓 User signed in + if ( + path === "/sign-in/email-otp" || + path === "/callback/google" || + path === "/callback/github" + ) { + const data = context.newSession; + if (data) { + const { session, user } = data; + log.info({ ...{ data: user.email }, message: "🔓 User signed in:" }); + // Use a 1-minute bucket for sign-in deduplication + const bucket = Math.floor(Date.now() / 60000); + await bus.publish( + BusEvent.SIGNIN_DETECTED, + { + email: user.email, + fullName: user.name || "User", + browser: session.userAgent || "Unknown Browser", + os: "Unknown OS", + ip: session.ipAddress || "0.0.0.0", + location: "Unknown Location", + }, + { msgId: `signin_detected:${user.email}:${bucket}` }, + ); + } + } + }), + }, + basePath: "/api/auth/v1", + telemetry: { enabled: false }, + emailAndPassword: { + enabled: true, + autoSignIn: true, + disableSignUp: authServerConfig.DISABLE_SIGNUP === "true", + }, + socialProviders: { + google: { + clientId: authServerConfig.GOOGLE_CLIENT_ID as string, + clientSecret: authServerConfig.GOOGLE_CLIENT_SECRET as string, + }, + github: { + clientId: authServerConfig.GITHUB_CLIENT_ID as string, + clientSecret: authServerConfig.GITHUB_CLIENT_SECRET as string, + }, + }, + secret: authServerConfig.BETTER_AUTH_SECRET, + session: { + expiresIn: 60 * 60 * 24 * 7, + updateAge: 60 * 60 * 24, + }, + trustedOrigins: ["*"], + plugins: [ + jwt(), + bearer(), + admin({ + defaultRole: DEFAULT_USER_ROLE, + adminRoles: [PLATFORM_ADMIN_ROLE], + ac: platformAc, + roles: platformRoles, + }), + apiKey({ defaultPrefix: "rl" }), + lastLoginMethod({ + cookieName: "better-auth.last_used_login_method", + maxAge: 60 * 60 * 24 * 30, + customResolveMethod: (ctx) => { + if (ctx.path.includes("/oauth/callback/google")) { + return "google"; + } + if (ctx.path.includes("/oauth/callback/github")) { + return "github"; + } + if (ctx.path.includes("/sign-up/email")) { + return "email"; + } + if (ctx.path.includes("/sign-in/email")) { + return "email"; + } + return null; + }, + }), + emailOTP({ + expiresIn: 60 * 15, + allowedAttempts: 3, + async sendVerificationOTP({ email, otp, type }) { + if ( + authServerConfig.REQUIRE_SIGNUP_INVITE && + (type === "sign-in" || type === "email-verification") + ) { + const exists = await userExistsByEmail(email); + if (!exists) { + const access = await canCreateAccount({ email }); + if (!access.allowed) { + throw new APIError("FORBIDDEN", { + message: + "A valid signup invite is required to create an account", + }); + } + } + } + + log.info("server", `Sending OTP (${type}) to: ${email} (OTP: ${otp})`); + if ( + authServerConfig.DEFAULT_OTP && + authServerConfig.NODE_ENV !== "development" + ) + return; + try { + await bus.publish( + BusEvent.OTP_REQUESTED, + { email, otp, type }, + { msgId: `otp_requested:${email}:${otp}` }, + ); + log.info("server", `OTP bus event published for ${email} (${type})`); + } catch (error) { + log.error({ + ...{ data: error }, + message: "Failed to publish OTP event:", + }); + // If we are not using a default OTP, we must throw to notify the user + if (!authServerConfig.DEFAULT_OTP) { + throw new Error("Failed to send OTP email"); + } + } + }, + generateOTP() { + if (authServerConfig.DEFAULT_OTP) return authServerConfig.DEFAULT_OTP; + return Math.floor(100000 + Math.random() * 900000).toString(); + }, + }), + organization({ + ac, + roles: orgRoles, + additionalFields: { + organization: { + billingEmail: { + type: "string", + required: false, + input: true, + }, + billingName: { + type: "string", + required: false, + input: true, + }, + externalCustomerId: { + type: "string", + required: false, + input: true, + unique: true, + }, + status: { + type: "string", + required: false, + input: true, + defaultValue: "active", + }, + }, + }, + async sendInvitationEmail(data) { + const inviteLink = `${authServerConfig.BASE_URL}/dashboard/signup?inviteId=${data.id}`; + log.info({ + ...{ + email: data.email, + organization: data.organization.name, + }, + message: "📧 Organization invitation email requested:", + }); + + // Log invite URL in development for easy testing + if (authServerConfig.NODE_ENV === "development") { + log.info("server", `🔗 Invite URL (DEV): ${inviteLink}`); + } + + const isResend = + Date.now() - new Date(data.invitation.createdAt).getTime() > 5000; + + try { + await bus.publish( + BusEvent.INVITE_CREATED, + { + email: data.email, + organizationName: data.organization.name, + inviteLink, + inviterName: + data.inviter.user.name || + data.inviter.user.email.split("@")[0] || + "Someone", + inviterEmail: data.inviter.user.email, + isResend, + }, + { msgId: `invite_created:${data.id}:${Date.now()}` }, + ); + log.info( + "server", + `✅ Organization invite bus event published for ${data.email} (resend: ${isResend})`, + ); + } catch (error) { + log.error( + "server", + `❌ Failed to publish organization invite event:${error}`, + ); + } + }, + organizationHooks: { + afterAcceptInvitation: async ({ member, user, organization }) => { + try { + await bus.publish( + BusEvent.ORGANIZATION_JOINED, + { + organizationId: organization.id, + orgName: organization.name, + userId: user.id, + userEmail: user.email, + memberName: user.name || user.email, + role: member.role, + inviterName: "Admin", + }, + { msgId: `org_joined:${organization.id}:${user.id}` }, + ); + log.info( + "server", + `✅ Organization joined bus event published for ${user.email}`, + ); + + // Set active organization for the user + await db + .update(schema.user) + .set({ activeOrganizationId: organization.id }) + .where(eq(schema.user.id, user.id)); + log.info( + "server", + `✅ Active organization set to ${organization.id} for user ${user.id}`, + ); + } catch (error) { + log.error({ + ...{ data: error }, + message: + "❌ Failed to publish organization joined event or update user:", + }); + } + }, + }, + }), + openAPI({ + path: "/docs", + }), + ], + advanced: { + cookiePrefix: "reloop", + ipAddress: { + ipAddressHeaders: ["x-client-ip", "x-forwarded-for"], + }, + }, +}); + +let _schema: ReturnType | null = null; + +const getSchema = async () => { + if (!_schema) { + _schema = auth.api.generateOpenAPISchema(); + } + return _schema; +}; + +type OpenAPIPathItem = Record & { + [method: string]: { tags?: string[] } | unknown; +}; + +export const OpenAPI = { + getPaths: async (prefix = "/api/auth/v1") => { + try { + const { paths } = await getSchema(); + const reference: Record = {}; + + for (const path of Object.keys(paths)) { + const pathData = paths[path]; + if (!pathData) continue; + + const key = prefix + path; + const item: OpenAPIPathItem = { ...pathData }; + reference[key] = item; + + for (const method of Object.keys(pathData)) { + const operation = item[method]; + if (operation && typeof operation === "object") { + (operation as { tags?: string[] }).tags = ["Better Auth"]; + } + } + } + + return reference; + } catch (error) { + log.error({ + ...{ data: error }, + message: "Failed to generate OpenAPI paths:", + }); + return {}; + } + }, + components: async () => { + try { + const { components } = await getSchema(); + return components; + } catch (error) { + log.error({ + ...{ data: error }, + message: "Failed to generate OpenAPI components:", + }); + return {}; + } + }, +} as const; diff --git a/packages/auth/src/server/config.ts b/packages/auth/src/server/config.ts new file mode 100644 index 000000000..735d934c3 --- /dev/null +++ b/packages/auth/src/server/config.ts @@ -0,0 +1,26 @@ +/** + * Env-driven config for the single runtime Better Auth instance. + * Single source of truth for shared auth env keys — service processes may + * spread this and add process-only fields (port, OTEL, etc.). + * + * Lightweight export: `@reloop/auth/server/config` (no betterAuth / DB load). + * Reads process.env at module load (set env before importing). + */ +export const authServerConfig = { + PG_URL: + process.env.PG_URL || "postgresql://reloop:reloop123@localhost:5432/reloop", + REDIS_URL: process.env.REDIS_URL || "redis://:reloop123@localhost:6379", + BASE_URL: process.env.BASE_URL || "https://local.reloop.sh", + NODE_ENV: process.env.NODE_ENV || "development", + BETTER_AUTH_SECRET: + process.env.BETTER_AUTH_SECRET || "tENkVU4GrhckuRw4Bcfh93EWgXOFcszn", + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, + GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, + GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, + DEFAULT_OTP: process.env.DEFAULT_OTP, + DISABLE_SIGNUP: process.env.DISABLE_SIGNUP, + /** When true, new accounts require a valid platform signup invite. */ + REQUIRE_SIGNUP_INVITE: process.env.REQUIRE_SIGNUP_INVITE === "true", + NATS_URL: process.env.NATS_URL || "nats://localhost:4222", +}; diff --git a/packages/auth/src/server/index.ts b/packages/auth/src/server/index.ts new file mode 100644 index 000000000..e3c5bff1f --- /dev/null +++ b/packages/auth/src/server/index.ts @@ -0,0 +1,10 @@ +/** + * Server-only surface for `@reloop/auth`. + * + * Contains the single runtime Better Auth instance and helpers that depend on + * database / Redis / bus. Do not import this path from browser bundles. + */ +export { auth, OpenAPI } from "./auth"; +export { authServerConfig } from "./config"; +export { redis as authRedis } from "./redis"; +export * from "./signup-invite"; diff --git a/packages/auth/src/server/redis.ts b/packages/auth/src/server/redis.ts new file mode 100644 index 000000000..562caabd9 --- /dev/null +++ b/packages/auth/src/server/redis.ts @@ -0,0 +1,8 @@ +import { RedisCache } from "@reloop/cache/redis-client"; +import { authServerConfig } from "./config"; + +export const redis = new RedisCache( + "auth", + 30 * 60, + authServerConfig.REDIS_URL, +); diff --git a/apps/backend/auth/src/lib/signup-invite.ts b/packages/auth/src/server/signup-invite.ts similarity index 95% rename from apps/backend/auth/src/lib/signup-invite.ts rename to packages/auth/src/server/signup-invite.ts index ade389672..0e910277a 100644 --- a/apps/backend/auth/src/lib/signup-invite.ts +++ b/packages/auth/src/server/signup-invite.ts @@ -3,7 +3,7 @@ import { db } from "@reloop/db/client"; import { invitation, signupInvite, user } from "@reloop/db/schema"; import { and, count, desc, eq, gt, inArray, ne, sql } from "drizzle-orm"; import { createError } from "evlog"; -import { authConfig } from "../auth.config"; +import { authServerConfig } from "./config"; export const SIGNUP_INVITE_COOKIE = "reloop.signup_invite"; export const SIGNUP_INVITE_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days @@ -173,7 +173,7 @@ export async function listPeerSignupInvites(userId: string) { return items.map((item) => ({ ...item, - inviteLink: signupInviteLink(authConfig.BASE_URL, item.code), + inviteLink: signupInviteLink(authServerConfig.BASE_URL, item.code), })); } @@ -261,7 +261,7 @@ export async function createPeerSignupInvite(opts: { }); } - const inviteLink = signupInviteLink(authConfig.BASE_URL, created.code); + const inviteLink = signupInviteLink(authServerConfig.BASE_URL, created.code); await bus.publish( BusEvent.SIGNUP_INVITE_CREATED, @@ -326,8 +326,17 @@ export async function revokePeerSignupInvite(opts: { ) .returning(); + if (!updated) { + throw createError({ + status: 500, + message: "Failed to revoke invite", + why: "Update returned no row", + fix: "Retry the request", + }); + } + return { - id: updated!.id, - status: updated!.status, + id: updated.id, + status: updated.status, }; } diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts new file mode 100644 index 000000000..d88da37bc --- /dev/null +++ b/packages/auth/src/types.ts @@ -0,0 +1,17 @@ +/** + * Type-only surface derived from the single runtime Better Auth instance. + * + * Imports types from `./server/auth` (the instance module only), not the + * server barrel — so typecheck does not pull signup-invite / redis re-exports. + * Value imports of this module must not pull server runtime deps into clients; + * use `import type` (enforced by export-isolation tests). + */ +import type { auth } from "./server/auth"; + +export type AuthInstance = typeof auth; +export type User = typeof auth.$Infer.Session.user; +export type Session = typeof auth.$Infer.Session & { + user: User & { + activeOrganizationId: string; + }; +}; diff --git a/packages/auth/test/export-isolation.test.ts b/packages/auth/test/export-isolation.test.ts new file mode 100644 index 000000000..70dadd371 --- /dev/null +++ b/packages/auth/test/export-isolation.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; + +const PKG_SRC = join(import.meta.dir, "..", "src"); + +/** Relative paths under `src/` that form the client + types surface. */ +const CLIENT_SURFACE_ENTRYPOINTS = ["client.ts", "types.ts"] as const; + +/** + * Modules that may appear on the client/types import graph. + * Anything under `server/` (or the old monoloithic server.ts re-export tree + * excluding type-only edges) is forbidden as a *value* import. + */ +const FORBIDDEN_VALUE_IMPORT_SUBSTRINGS = [ + "@reloop/db", + "@reloop/bus", + "@reloop/cache", + "drizzle-orm", + "better-auth/adapters", + "evlog", +] as const; + +function resolveLocal(fromFile: string, spec: string): string | null { + if (!spec.startsWith(".")) return null; + const base = join(dirname(fromFile), spec); + const candidates = [ + base, + `${base}.ts`, + `${base}.tsx`, + join(base, "index.ts"), + ]; + for (const c of candidates) { + try { + if (statSync(c).isFile()) return c; + } catch { + // try next + } + } + return null; +} + +function isTypeOnlyImportLine(line: string): boolean { + const trimmed = line.trim(); + return ( + trimmed.startsWith("import type ") || + /^import\s+type\s*\{/.test(trimmed) || + /^export\s+type\s/.test(trimmed) + ); +} + +function collectValueImports(entryRel: string): { + localFiles: Set; + packageSpecs: Set; +} { + const localFiles = new Set(); + const packageSpecs = new Set(); + const queue = [join(PKG_SRC, entryRel)]; + + while (queue.length > 0) { + const file = queue.pop(); + if (!file || localFiles.has(file)) continue; + // Stay inside the package src tree. + if (!file.startsWith(PKG_SRC)) continue; + localFiles.add(file); + + const source = readFileSync(file, "utf8"); + const lines = source.split("\n"); + + for (const line of lines) { + if (isTypeOnlyImportLine(line)) continue; + // Skip `import type { X } from "..."` already handled; also skip + // inline type-only named imports: `import { type Foo } from` is rare. + + for (const match of line.matchAll( + /from\s+["'](\.[^"']+)["']|import\s+["'](\.[^"']+)["']/g, + )) { + const spec = match[1] ?? match[2]; + if (!spec) continue; + // Whole-line type-only already skipped; if the line is + // `import type { ... } from` we continue above. + if (line.includes("import type") || line.includes("export type")) { + continue; + } + const resolved = resolveLocal(file, spec); + if (resolved) queue.push(resolved); + } + + for (const match of line.matchAll( + /from\s+["'](@?[^"']+)["']|import\s+["'](@?[^"']+)["']/g, + )) { + const spec = match[1] ?? match[2]; + if (!spec || spec.startsWith(".")) continue; + if (line.includes("import type") || line.includes("export type")) { + continue; + } + packageSpecs.add(spec); + } + } + } + + return { localFiles, packageSpecs }; +} + +describe("export isolation (client / types)", () => { + test("client and types value-import graphs exclude server runtime deps", () => { + const allLocal = new Set(); + const allPackages = new Set(); + + for (const entry of CLIENT_SURFACE_ENTRYPOINTS) { + const { localFiles, packageSpecs } = collectValueImports(entry); + for (const f of localFiles) allLocal.add(f); + for (const p of packageSpecs) allPackages.add(p); + } + + const localRels = [...allLocal].map((f) => relative(PKG_SRC, f)); + + // No value-imported file under src/server/ + const serverFiles = localRels.filter( + (r) => + r === "server.ts" || + r.startsWith(`server${"/"}`) || + r.startsWith("server\\"), + ); + expect(serverFiles).toEqual([]); + + // No forbidden package value imports + const forbidden = [...allPackages].filter((spec) => + FORBIDDEN_VALUE_IMPORT_SUBSTRINGS.some( + (frag) => spec === frag || spec.startsWith(`${frag}/`), + ), + ); + expect(forbidden).toEqual([]); + }); + + test("types.ts only type-imports the auth instance module (not the server barrel)", () => { + const typesSrc = readFileSync(join(PKG_SRC, "types.ts"), "utf8"); + // Must not value-import server. + expect(typesSrc).not.toMatch(/^import\s+(?!type\b)/m); + // Prefer the instance module over the barrel so typecheck does not + // pull re-exported signup-invite / redis helpers. + expect(typesSrc).toMatch(/import\s+type\s+\{[^}]*auth[^}]*\}\s+from\s+["']\.\/server\/auth["']/); + expect(typesSrc).not.toMatch(/from\s+["']\.\/server["']/); + }); + + test("server export path exists and re-exports the runtime instance", () => { + const serverEntry = readFileSync(join(PKG_SRC, "server.ts"), "utf8"); + expect(serverEntry).toContain("./server"); + // Runtime module must construct betterAuth (single source of truth). + const authImpl = readFileSync(join(PKG_SRC, "server", "auth.ts"), "utf8"); + expect(authImpl).toContain("betterAuth("); + // Exactly one betterAuth( call in the package src tree (excluding tests). + const betterAuthCalls: string[] = []; + function walk(dir: string) { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) { + walk(p); + continue; + } + if (!p.endsWith(".ts")) continue; + const text = readFileSync(p, "utf8"); + const matches = text.match(/betterAuth\s*\(/g); + if (matches) { + for (const _ of matches) betterAuthCalls.push(relative(PKG_SRC, p)); + } + } + } + walk(PKG_SRC); + expect(betterAuthCalls).toEqual(["server/auth.ts"]); + }); +});