diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index d139110935..37493600a5 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -520,11 +520,15 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' + import type { startInstance } from './start.ts' + declare module '@tanstack/react-start' { interface Register { ssr: true + router: Awaited> + config: Awaited> } } diff --git a/apps/host-cloudflare/package.json b/apps/host-cloudflare/package.json index b693834278..2189c3aa00 100644 --- a/apps/host-cloudflare/package.json +++ b/apps/host-cloudflare/package.json @@ -37,6 +37,7 @@ "@jitl/quickjs-wasmfile-release-sync": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-router": "catalog:", + "better-auth": "^1.6.11", "drizzle-orm": "catalog:", "effect": "catalog:", "jose": "^5.9.6", diff --git a/apps/host-cloudflare/src/app.ts b/apps/host-cloudflare/src/app.ts index 8c3eebf789..87a73902a0 100644 --- a/apps/host-cloudflare/src/app.ts +++ b/apps/host-cloudflare/src/app.ts @@ -1,12 +1,24 @@ -import { Effect } from "effect"; +import { Effect, Layer } from "effect"; import { HttpEffect, HttpRouter } from "effect/unstable/http"; -import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; +import { + dbProviderLayer, + ExecutorApp, + textFailureStrategy, + accountProviderMiddlewareLayer, + BetterAuth, + betterAuthIdentityLayer, + betterAuthAccountProvider, + makeBetterAuthAdminApiLayer, + makeBetterAuthSystemApiLayer, + type BetterAuthHandle, +} from "@executor-js/api/server"; import { loadConfig, type CloudflareEnv } from "./config"; import { makeCloudflarePlugins } from "./plugins"; import { createD1ExecutorDb } from "./db/d1"; import { cloudflareAccessIdentityLayer } from "./auth/cloudflare-access"; +import { buildD1BetterAuth } from "./auth/builtin-auth"; import { CloudflareCodeExecutorProvider, makeCloudflareHostConfig, @@ -44,9 +56,26 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { // Open and idempotently bring up the D1 schema once. This is the long-lived // handle the per-request scoped executor reads through the DbProvider seam. const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS); - const identityLayer = cloudflareAccessIdentityLayer(config); - const mcpAgentHandler = makeCloudflareMcpAgentHandler(config); - const approvalHandler = makeCloudflareApprovalHandler(config, env); + + const isBuiltin = config.authMode === "builtin"; + let betterAuth: BetterAuthHandle | null = null; + let identityLayer; + let accountMiddleware; + + if (isBuiltin) { + betterAuth = await buildD1BetterAuth(env.DB, config); + const betterAuthLayer = Layer.succeed(BetterAuth)(betterAuth); + identityLayer = betterAuthIdentityLayer.pipe(Layer.provide(betterAuthLayer)); + accountMiddleware = accountProviderMiddlewareLayer( + betterAuthAccountProvider.pipe(Layer.provide(betterAuthLayer)), + ); + } else { + identityLayer = cloudflareAccessIdentityLayer(config); + accountMiddleware = cloudflareAccountMiddleware(config); + } + + const mcpAgentHandler = makeCloudflareMcpAgentHandler(config, betterAuth, identityLayer); + const approvalHandler = makeCloudflareApprovalHandler(config, env, betterAuth); const { appLayer, toWebHandler } = ExecutorApp.make({ plugins, @@ -62,14 +91,38 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { // The account API (`/api/account/*`) backs the shared multiplayer shell's // auth context; `me` reflects the Access principal. Members/keys are // Access-managed, so the rest of the surface is stubbed. - account: cloudflareAccountMiddleware(config), + account: accountMiddleware, }, extensions: { routes: [ // Browser approval of paused MCP executions: the console resume page // reads paused detail (GET) and records the decision (POST .../resume), - // Access-gated, routed to the owning session's Durable Object. + // Access/BetterAuth-gated, routed to the owning session's Durable Object. HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(approvalHandler)), + ...(isBuiltin && betterAuth + ? [ + HttpRouter.add( + "GET", + "/api/auth/cli-login", + HttpEffect.fromWebHandler( + async () => + new Response( + JSON.stringify({ + provider: "better-auth", + deviceAuthorizationEndpoint: `${config.webBaseUrl}/api/auth/device/code`, + tokenEndpoint: `${config.webBaseUrl}/api/auth/device/token`, + clientId: "executor-cli", + requestFormat: "json", + }), + { headers: { "content-type": "application/json" } }, + ), + ), + ), + HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(betterAuth.handler)), + makeBetterAuthAdminApiLayer({ betterAuth, mountPrefix: "/api" }), + makeBetterAuthSystemApiLayer({ betterAuth, mountPrefix: "/api" }), + ] + : []), ], }, config: { mountPrefix: "/api", failure: textFailureStrategy }, diff --git a/apps/host-cloudflare/src/auth/builtin-auth.ts b/apps/host-cloudflare/src/auth/builtin-auth.ts new file mode 100644 index 0000000000..99677c0ac2 --- /dev/null +++ b/apps/host-cloudflare/src/auth/builtin-auth.ts @@ -0,0 +1,87 @@ +import { betterAuth } from "better-auth"; + +import { + makeBetterAuthSharedOptions, + seedOrgAndAdmin, + ensureInviteCodeTable, + findRedeemableCode, + consumeInviteCode, + type BetterAuthInstance, + type BetterAuthDbClient, + type SignupGate, + type BetterAuthHandle, +} from "@executor-js/api/server"; + +import type { CloudflareConfig } from "../config"; + +export const d1ClientAdapter = (db: D1Database): BetterAuthDbClient => ({ + execute: async (sql, args) => { + const stmt = db.prepare(sql).bind(...(args ?? [])); + const result = await stmt.all(); + return { + rows: result.results ?? [], + rowsAffected: result.meta?.changes ?? 0, + }; + }, +}); + +export const buildD1BetterAuth = async ( + db: D1Database, + config: CloudflareConfig, +): Promise => { + const dbClient = d1ClientAdapter(db); + + let auth: BetterAuthInstance | null = null; + const orgRef = { id: "" }; + const gate: SignupGate = { + get organizationId() { + return orgRef.id; + }, + getAuth: () => auth, + findRedeemableCode: (code) => findRedeemableCode(dbClient, code), + consumeInviteCode: (code, by) => consumeInviteCode(dbClient, code, by), + }; + + const sharedOptions = makeBetterAuthSharedOptions( + () => orgRef.id, + { + authSecret: config.betterAuthSecret!, + webBaseUrl: config.webBaseUrl!, + }, + gate, + ); + + const authOptions = { + ...sharedOptions, + database: db, + }; + + const authInstance = betterAuth(authOptions); + auth = authInstance as any; + await (await authInstance.$context).runMigrations(); + await ensureInviteCodeTable(dbClient); + + const seedConfig = { + orgSlug: config.organizationSlug, + organizationName: config.organizationName, + bootstrapAdminEmail: config.bootstrapAdminEmail, + bootstrapAdminPassword: config.bootstrapAdminPassword, + bootstrapAdminName: config.bootstrapAdminName, + }; + + const { organizationId, organizationName } = await seedOrgAndAdmin( + authInstance as any, + dbClient, + seedConfig, + ); + orgRef.id = organizationId; + + return { + auth: authInstance as any, + organizationId, + organizationName, + organizationSlug: config.organizationSlug, + handler: authInstance.handler, + dbClient, + }; +}; diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts index 8d4a75151c..010a33bc8d 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts @@ -4,6 +4,7 @@ import type { CloudflareConfig } from "../config"; import { principalFromAccessClaims } from "./cloudflare-access"; const config: CloudflareConfig = { + authMode: "access", accessTeamDomain: "team.cloudflareaccess.com", accessAud: "aud-tag", accessNameClaim: "name", diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index c397c4ef87..03f95a71b8 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -56,9 +56,21 @@ export interface CloudflareEnv { * behind Access, or the instance is wide open. */ readonly ENABLE_DEV_AUTH?: string; + /** Better Auth variables */ + readonly AUTH_MODE?: string; + readonly BETTER_AUTH_SECRET?: string; + readonly AUTH_SECRET?: string; + readonly EXECUTOR_BOOTSTRAP_ADMIN_EMAIL?: string; + readonly EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD?: string; + readonly EXECUTOR_BOOTSTRAP_ADMIN_NAME?: string; } export interface CloudflareConfig { + readonly authMode: "access" | "builtin"; + readonly betterAuthSecret?: string; + readonly bootstrapAdminEmail?: string; + readonly bootstrapAdminPassword?: string; + readonly bootstrapAdminName?: string; readonly accessTeamDomain: string; readonly accessAud: string; readonly accessNameClaim: string; @@ -137,14 +149,30 @@ export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => { "EXECUTOR_SECRET_KEY must be set (wrangler secret put EXECUTOR_SECRET_KEY) — it encrypts stored secrets at rest in D1", ); } - const enableDevAuth = env.ENABLE_DEV_AUTH === "true"; - const accessTeamDomain = normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN); - const accessAud = (env.ACCESS_AUD ?? "").trim(); - const missingAccessVars = missingCloudflareAccessVars(env); - if (missingAccessVars.length > 0) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: production must fail closed without a valid Access verifier - throw new Error(cloudflareAccessConfigErrorMessage(missingAccessVars)); + const rawAuthMode = (env.AUTH_MODE ?? "access").toLowerCase(); + const authMode: "access" | "builtin" = rawAuthMode === "builtin" ? "builtin" : "access"; + + const betterAuthSecret = (env.BETTER_AUTH_SECRET ?? env.AUTH_SECRET)?.trim(); + if (authMode === "builtin" && (!betterAuthSecret || betterAuthSecret.length < 32)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Better Auth requires a secure secret to boot + throw new Error( + "BETTER_AUTH_SECRET (or AUTH_SECRET) must be set and be at least 32 characters long when AUTH_MODE=builtin", + ); } + + const enableDevAuth = authMode === "access" && env.ENABLE_DEV_AUTH === "true"; + const accessTeamDomain = + authMode === "access" ? normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN) : ""; + const accessAud = authMode === "access" ? (env.ACCESS_AUD ?? "").trim() : ""; + + if (authMode === "access") { + const missingAccessVars = missingCloudflareAccessVars(env); + if (missingAccessVars.length > 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: production must fail closed without a valid Access verifier + throw new Error(cloudflareAccessConfigErrorMessage(missingAccessVars)); + } + } + const webBaseUrl = resolvePublicOrigin({ explicit: env.VITE_PUBLIC_SITE_URL, env: {} }); if (!webBaseUrl && !enableDevAuth && !warnedNoCloudflareOrigin) { warnedNoCloudflareOrigin = true; @@ -156,6 +184,11 @@ export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => { ); } return { + authMode, + betterAuthSecret, + bootstrapAdminEmail: env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, + bootstrapAdminPassword: env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, + bootstrapAdminName: env.EXECUTOR_BOOTSTRAP_ADMIN_NAME, accessTeamDomain, accessAud, accessNameClaim: env.ACCESS_NAME_CLAIM ?? "name", diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index a870277401..e9fde67b04 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -1,4 +1,4 @@ -import { Effect, Predicate } from "effect"; +import { Effect, Predicate, Layer } from "effect"; import { McpAuthProvider, @@ -16,6 +16,12 @@ import { } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; +import { + BetterAuth, + betterAuthMcpAuth, + type BetterAuthHandle, + type IdentityProvider, +} from "@executor-js/api/server"; import type { CloudflareConfig, CloudflareEnv } from "../config"; import { cloudflareAccessMcpAuth } from "./auth"; @@ -62,12 +68,26 @@ const renderAuthError = ( return jsonRpcResponse(503, -32001, outcome.message); }; -const authenticate = (request: Request, config: CloudflareConfig) => +const authenticate = ( + request: Request, + config: CloudflareConfig, + betterAuth: BetterAuthHandle | null, + identityLayer: Layer.Layer | null, +) => Effect.gen(function* () { const auth = yield* McpAuthProvider; const outcome = yield* auth.authenticate(request); return { auth, outcome }; - }).pipe(Effect.provide(cloudflareAccessMcpAuth(config))); + }).pipe( + Effect.provide( + config.authMode === "builtin" && betterAuth && identityLayer + ? betterAuthMcpAuth.pipe( + Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), + Layer.provide(identityLayer), + ) + : cloudflareAccessMcpAuth(config), + ), + ); const propsForPrincipal = ( request: Request, @@ -92,7 +112,11 @@ const propsForPrincipal = ( }; }); -export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { +export const makeCloudflareMcpAgentHandler = ( + config: CloudflareConfig, + betterAuth: BetterAuthHandle | null, + identityLayer: Layer.Layer | null, +) => { const serve = McpSessionDO.serve("/mcp", { binding: "MCP_SESSION", transport: "streamable-http", @@ -102,7 +126,9 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { if (request.method === "OPTIONS") return corsPreflightResponse(); const sessionId = request.headers.get("mcp-session-id"); - const { auth, outcome } = await Effect.runPromise(authenticate(request, config)); + const { auth, outcome } = await Effect.runPromise( + authenticate(request, config, betterAuth, identityLayer), + ); if (!Predicate.isTagged(outcome, "Authenticated")) { if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { await Effect.runPromise( diff --git a/apps/host-cloudflare/src/mcp/index.ts b/apps/host-cloudflare/src/mcp/index.ts index 1de283c205..a2c063fbe2 100644 --- a/apps/host-cloudflare/src/mcp/index.ts +++ b/apps/host-cloudflare/src/mcp/index.ts @@ -1,8 +1,10 @@ import { Effect } from "effect"; import { decodeResumeResponse } from "@executor-js/host-mcp/browser-approval"; +import type { Principal } from "@executor-js/host-mcp"; import type { McpApprovalOwner } from "@executor-js/cloudflare/mcp/agent-durable-object"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; +import type { BetterAuthHandle } from "@executor-js/api/server"; import type { CloudflareConfig, CloudflareEnv } from "../config"; import { makeAccessVerifier } from "../auth/cloudflare-access"; @@ -20,12 +22,46 @@ const jsonResponse = (value: unknown, status: number): Response => export const makeCloudflareApprovalHandler = ( config: CloudflareConfig, env: CloudflareEnv, + betterAuth: BetterAuthHandle | null, ): ((request: Request) => Promise) => { const { verify } = makeAccessVerifier(config); const stubFor = (sessionId: string) => mcpSessionStub(env.MCP_SESSION, sessionId); + const getPrincipal = (request: Request): Promise => { + if (config.authMode === "builtin" && betterAuth) { + return Effect.runPromise( + Effect.tryPromise({ + try: () => betterAuth.auth.api.getSession({ headers: request.headers }), + catch: () => null, + }).pipe( + Effect.map((resolved) => { + if (!resolved) return null; + const roles = (((resolved.user as any).role ?? "user") as string) + .split(",") + .map((role) => role.trim()) + .filter((role) => role.length > 0); + return { + accountId: resolved.user.id, + organizationId: + (resolved.session as any).activeOrganizationId ?? betterAuth.organizationId, + organizationName: betterAuth.organizationName, + email: resolved.user.email, + name: resolved.user.name ?? null, + avatarUrl: resolved.user.image ?? null, + roles, + } as Principal; + }), + Effect.orElseSucceed(() => null), + ), + ); + } + return Effect.runPromise(verify(request)).then((principal) => + principal ? (principal as Principal) : null, + ); + }; + return async (request) => { - const principal = await Effect.runPromise(verify(request)); + const principal = await getPrincipal(request); if (!principal) return jsonResponse({ error: "Unauthorized" }, 401); const owner: McpApprovalOwner = { accountId: principal.accountId, diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 738a358827..78be8fad34 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -550,5 +550,129 @@ describe("cloudflare host configuration errors", () => { "Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN and ACCESS_AUD before serving requests.\n", ); } + }, 30_000); +}); + +describe("cloudflare host e2e with built-in auth (AUTH_MODE=builtin)", () => { + let worker: Unstable_DevWorker; + + beforeAll(async () => { + ensureStaticAssets(); + + worker = await unstable_dev(resolve(dir, "worker.ts"), { + config: resolve(dir, "../wrangler.jsonc"), + ip: "127.0.0.1", + local: true, + persist: false, + experimental: { disableExperimentalWarning: true }, + vars: { + EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", + AUTH_MODE: "builtin", + BETTER_AUTH_SECRET: "test-secret-0123456789-abcdefghijklmnop-qrstuv", + }, + }); + }, 120_000); + + afterAll(async () => { + await worker?.stop(); }); + + it("manages the entire first-run setup, signup restrictions, and invite code flow", async () => { + // 1. Initial setup-status check + const statusBefore = await worker.fetch("/api/setup-status"); + expect(statusBefore.status).toBe(200); + const bodyBefore = (await statusBefore.json()) as { needsSetup: boolean }; + expect(bodyBefore.needsSetup).toBe(true); + + // 2. First-run owner signup + const signUpOwner = await worker.fetch("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "admin@test.local", + password: "admin-password-123", + name: "Admin Owner", + }), + }); + expect(signUpOwner.status).toBe(200); + const ownerToken = signUpOwner.headers.get("set-auth-token"); + const ownerCookie = signUpOwner.headers.get("set-cookie"); + const ownerHeaders = { + ...(ownerToken ? { authorization: `Bearer ${ownerToken}` } : {}), + ...(ownerCookie ? { cookie: ownerCookie } : {}), + }; + + // 3. Setup-status check after owner signup + const statusAfter = await worker.fetch("/api/setup-status"); + expect(statusAfter.status).toBe(200); + const bodyAfter = (await statusAfter.json()) as { needsSetup: boolean }; + expect(bodyAfter.needsSetup).toBe(false); + + // 4. Registering a second user without an invite code must be forbidden + const signUpForbidden = await worker.fetch("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "user@test.local", + password: "user-password-123", + name: "Regular User", + }), + }); + expect(signUpForbidden.status).toBe(403); + + // 5. Generate an invite code using the admin API + const createInvite = await worker.fetch("/api/admin/invites", { + method: "POST", + headers: { + "content-type": "application/json", + ...ownerHeaders, + }, + body: JSON.stringify({ role: "member", label: "Test Invite" }), + }); + expect(createInvite.status).toBe(200); + const invite = (await createInvite.json()) as { code: string }; + expect(invite.code).toBeTruthy(); + + // 6. Check that the invite code status endpoint reports it as valid + const inviteStatus = await worker.fetch(`/api/invite-status/${invite.code}`); + expect(inviteStatus.status).toBe(200); + const inviteStatusBody = (await inviteStatus.json()) as { valid: boolean }; + expect(inviteStatusBody.valid).toBe(true); + + // 7. Registering with the invite code must succeed + const signUpUser = await worker.fetch("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "user@test.local", + password: "user-password-123", + name: "Regular User", + inviteCode: invite.code, + }), + }); + expect(signUpUser.status).toBe(200); + const userToken = signUpUser.headers.get("set-auth-token"); + const userCookie = signUpUser.headers.get("set-cookie"); + const userHeaders = { + ...(userToken ? { authorization: `Bearer ${userToken}` } : {}), + ...(userCookie ? { cookie: userCookie } : {}), + }; + + // 8. Re-checking the invite code status must report it as invalid (consumed) + const inviteStatusConsumed = await worker.fetch(`/api/invite-status/${invite.code}`); + expect(inviteStatusConsumed.status).toBe(200); + const inviteStatusConsumedBody = (await inviteStatusConsumed.json()) as { valid: boolean }; + expect(inviteStatusConsumedBody.valid).toBe(false); + + // 9. Retrieve user details using /api/account/me for both accounts + const meOwner = await worker.fetch("/api/account/me", { headers: ownerHeaders }); + expect(meOwner.status).toBe(200); + const meOwnerBody = (await meOwner.json()) as { user: { email: string } }; + expect(meOwnerBody.user.email).toBe("admin@test.local"); + + const meUser = await worker.fetch("/api/account/me", { headers: userHeaders }); + expect(meUser.status).toBe(200); + const meUserBody = (await meUser.json()) as { user: { email: string } }; + expect(meUserBody.user.email).toBe("user@test.local"); + }, 90_000); }); diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index ac9c1b30b7..25baa0880b 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -42,9 +42,12 @@ const accessConfigErrorResponse = (missingVars: readonly string[]): Response => export default { fetch: async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => { - const missingAccessVars = missingCloudflareAccessVars(env); - if (missingAccessVars.length > 0) { - return accessConfigErrorResponse(missingAccessVars); + const authMode = (env.AUTH_MODE ?? "access").toLowerCase(); + if (authMode === "access") { + const missingAccessVars = missingCloudflareAccessVars(env); + if (missingAccessVars.length > 0) { + return accessConfigErrorResponse(missingAccessVars); + } } const serve = await resolveHandler(env); diff --git a/apps/host-selfhost/src/account/better-auth-account-provider.ts b/apps/host-selfhost/src/account/better-auth-account-provider.ts index c7ed75a30e..bd44d28ac2 100644 --- a/apps/host-selfhost/src/account/better-auth-account-provider.ts +++ b/apps/host-selfhost/src/account/better-auth-account-provider.ts @@ -1,207 +1 @@ -import { Effect, Layer } from "effect"; - -import { AccountProvider, type AccountHeaders } from "@executor-js/api/server"; -import { AccountError, AccountUnauthorized } from "@executor-js/api"; - -import { BetterAuth } from "../auth/better-auth"; - -// --------------------------------------------------------------------------- -// Self-host AccountProvider — implements the provider-neutral account surface -// over the Better Auth instance (auth.api.*). The shared AccountHandlers call -// this; cloud provides its own WorkOS-backed implementation of the same shape. -// -// Single-org instance: organization id/name come from the boot-seeded org. -// auth.api.* throws on failure; we map those to the neutral AccountError so the -// UI sees one shape. API keys returned by `list` only expose a masked value; -// the plaintext is returned once, by `create`. -// --------------------------------------------------------------------------- - -const toHeaders = (headers: AccountHeaders): Headers => new Headers(headers); - -const isoOrNull = (value: Date | string | null | undefined): string | null => { - if (!value) return null; - return value instanceof Date ? value.toISOString() : value; -}; - -const iso = (value: Date | string | null | undefined): string => isoOrNull(value) ?? ""; - -// Better Auth exposes only `start` (leading chars) for display once a key is -// stored; render it as a masked token. -const masked = (start: string | null | undefined): string => (start ? `${start}…` : "••••••••"); - -// Narrow a free-form role slug to the Better Auth organization role union -// (defaults to member). Returning literals — not a cast — keeps the types sound. -const orgRole = (slug: string | undefined): "owner" | "admin" | "member" => - slug === "owner" ? "owner" : slug === "admin" ? "admin" : "member"; - -export const betterAuthAccountProvider: Layer.Layer = - Layer.effect(AccountProvider)( - Effect.gen(function* () { - const { auth, organizationId, organizationName, organizationSlug } = yield* BetterAuth; - - const getSession = (headers: AccountHeaders) => - Effect.tryPromise({ - try: () => auth.api.getSession({ headers: toHeaders(headers) }), - catch: () => new AccountError({ message: "Failed to resolve session" }), - }).pipe(Effect.orElseSucceed(() => null)); - - // Run a Better Auth api call, mapping any rejection to a neutral - // AccountError with a stable, user-facing message. - const call = (message: string, run: () => Promise) => - Effect.tryPromise({ try: run, catch: () => new AccountError({ message }) }); - - return AccountProvider.of({ - me: (headers) => - Effect.gen(function* () { - const resolved = yield* getSession(headers); - if (!resolved) return yield* new AccountUnauthorized(); - return { - user: { - id: resolved.user.id, - email: resolved.user.email, - name: resolved.user.name ?? null, - avatarUrl: resolved.user.image ?? null, - }, - organization: { - id: resolved.session.activeOrganizationId ?? organizationId, - name: organizationName, - slug: organizationSlug, - }, - }; - }), - - listApiKeys: (headers) => - call("Failed to list API keys", () => - auth.api.listApiKeys({ headers: toHeaders(headers) }), - ).pipe( - Effect.map((result) => ({ - apiKeys: result.apiKeys.map((key) => ({ - id: key.id, - name: key.name ?? "API key", - obfuscatedValue: masked(key.start), - createdAt: iso(key.createdAt), - updatedAt: iso(key.updatedAt), - lastUsedAt: isoOrNull(key.lastRequest), - })), - })), - ), - - createApiKey: (headers, name) => - call("Failed to create API key", () => - auth.api.createApiKey({ body: { name }, headers: toHeaders(headers) }), - ).pipe( - Effect.map((key) => ({ - id: key.id, - name: key.name ?? name, - obfuscatedValue: masked(key.start), - createdAt: iso(key.createdAt), - updatedAt: iso(key.updatedAt), - lastUsedAt: isoOrNull(key.lastRequest), - value: key.key, - })), - ), - - revokeApiKey: (headers, apiKeyId) => - call("Failed to revoke API key", () => - auth.api.deleteApiKey({ body: { keyId: apiKeyId }, headers: toHeaders(headers) }), - ).pipe(Effect.as({ success: true })), - - // Better Auth has no organization-OWNED key concept: every key it - // issues belongs to the user who created it. Rather than inventing one - // (a shared key filed under whichever admin happened to click the - // button is not an org key — it dies with that member), self-host - // reports no org keys and refuses to mint. Self-host's own `/admin/*` - // plane is gated on an owner/admin SESSION instead, which is the - // credential a single-instance operator already has. - listOrgApiKeys: () => Effect.succeed({ apiKeys: [] }), - - createOrgApiKey: () => - Effect.fail( - new AccountError({ - message: "Organization API keys are not available on self-hosted instances", - }), - ), - - // Nothing to revoke: `listOrgApiKeys` is empty and `createOrgApiKey` - // refuses, so any id reaching here names a key this instance never - // issued. Refusing (rather than succeeding vacuously) keeps the console - // from reporting a revoke that did not happen. - revokeOrgApiKey: () => - Effect.fail( - new AccountError({ - message: "Organization API keys are not available on self-hosted instances", - }), - ), - - listMembers: (headers) => - Effect.gen(function* () { - const resolved = yield* getSession(headers); - const currentUserId = resolved?.user.id; - const result = yield* call("Failed to list members", () => - auth.api.listMembers({ headers: toHeaders(headers) }), - ).pipe( - Effect.catchTag("AccountError", () => Effect.succeed({ members: [], total: 0 })), - ); - const members = result.members.map((member) => ({ - id: member.id, - userId: member.userId, - email: member.user?.email ?? "", - name: member.user?.name ?? null, - avatarUrl: member.user?.image ?? null, - role: member.role, - status: "active", - lastActiveAt: null, - isCurrentUser: member.userId === currentUserId, - })); - return { - members, - seats: { used: members.length, granted: members.length, unlimited: true }, - }; - }), - - // Better Auth's organization plugin ships fixed roles; expose the common - // set so the invite/role UI has options on a single-team instance. - listRoles: () => - Effect.succeed({ - roles: [ - { slug: "owner", name: "Owner" }, - { slug: "admin", name: "Admin" }, - { slug: "member", name: "Member" }, - ], - }), - - inviteMember: (headers, body) => - call("Failed to invite member", () => - auth.api.createInvitation({ - // Narrow the free-form slug to the org plugin's role union (no cast). - body: { email: body.email, role: orgRole(body.roleSlug) }, - headers: toHeaders(headers), - }), - ).pipe(Effect.map((invite) => ({ id: invite.id, email: invite.email }))), - - removeMember: (headers, membershipId) => - call("Failed to remove member", () => - auth.api.removeMember({ - body: { memberIdOrEmail: membershipId }, - headers: toHeaders(headers), - }), - ).pipe(Effect.as({ success: true })), - - updateMemberRole: (headers, membershipId, roleSlug) => - call("Failed to update member role", () => - auth.api.updateMemberRole({ - body: { memberId: membershipId, role: roleSlug }, - headers: toHeaders(headers), - }), - ).pipe(Effect.as({ success: true })), - - updateOrgName: (headers, name) => - call("Failed to update organization name", () => - auth.api.updateOrganization({ - body: { data: { name }, organizationId }, - headers: toHeaders(headers), - }), - ).pipe(Effect.as({ name })), - }); - }), - ); +export { betterAuthAccountProvider } from "@executor-js/api/server"; diff --git a/apps/host-selfhost/src/admin/admin-users-api.ts b/apps/host-selfhost/src/admin/admin-users-api.ts index 38f767a168..0773ced2aa 100644 --- a/apps/host-selfhost/src/admin/admin-users-api.ts +++ b/apps/host-selfhost/src/admin/admin-users-api.ts @@ -31,6 +31,7 @@ import { listAdminUserConnections, listAdminUsers, listAdminUsersWithConnections, + BetterAuth, makeAdminUsersApiLayer, makePlatformExecutor, normalizeAdminUserEmail, @@ -48,8 +49,8 @@ import { } from "@executor-js/api"; import type { Executor } from "@executor-js/sdk"; -import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; -import { requireInstanceAdmin } from "./require-admin"; +import { type BetterAuthHandle } from "../auth/better-auth"; +import { requireInstanceAdmin } from "@executor-js/api/server"; import { SelfHostDb, SelfHostDbProvider, type SelfHostDbHandle } from "../db/self-host-db"; import { SelfHostHostConfig, SelfHostPluginsProvider } from "../execution"; diff --git a/apps/host-selfhost/src/admin/require-admin.ts b/apps/host-selfhost/src/admin/require-admin.ts deleted file mode 100644 index 4b0fd8b7d2..0000000000 --- a/apps/host-selfhost/src/admin/require-admin.ts +++ /dev/null @@ -1,126 +0,0 @@ -// --------------------------------------------------------------------------- -// THE self-host admin gate. One implementation, shared by every admin plane: -// the invite-code API (`handlers.ts`) and the admin users API -// (`admin-users-api.ts`). Both planes report on, or grant power over, the whole -// instance, so they must agree on exactly who counts as an admin — and there is -// only one way to be wrong here, so there is only one place to be right. -// -// WHY THIS IS NOT `getActiveMember`. -// -// Both planes previously authorized with `auth.api.getActiveMember({ headers -// })`. That endpoint resolves the caller's membership in -// `session.activeOrganizationId` — a field the CALLER controls. Better Auth's -// organization plugin exposes `POST /organization/create` (mounted bare, -// `allowUserToCreateOrganization` defaults to true) and `POST -// /organization/set-active`, so any authenticated member could create an -// organization of their own, become its `owner` (the creator role), have that -// org set active, and then present a session whose active org is one they own. -// `getActiveMember` would answer `role: "owner"` — for THEIR org — and the gate -// would open. Meanwhile the reads underneath are scoped to the instance's -// boot-seeded org, so the escalated caller read the real instance: every user's -// externalId/createdAt/lastSeenAt and their whole connection inventory on one -// plane, and a live `role: "admin"` invite code (durable escalation) on the -// other. -// -// The bug is that the gate and the data disagreed about WHICH organization the -// request was about. This gate removes the disagreement by naming the -// instance's organization explicitly, so the authorization decision is made -// against the same org the reads are scoped to and nothing the caller sends can -// redirect it. -// -// HOW. `getActiveMemberRole` accepts an explicit `organizationId` query, and -// when given one it ignores `session.activeOrganizationId` entirely, looks up -// the caller's membership in THAT org, and refuses with FORBIDDEN when there -// isn't one (better-auth 1.6.12, `plugins/organization/routes/crud-members.mjs` -// — read against the installed build, not the docs). So a caller who owns ten -// organizations of their own still resolves to "not a member of the instance -// org" unless they were actually invited to it. There is no -// `member.organizationId !== organizationId` check to write, because the query -// makes the mismatch unrepresentable rather than detectable. -// -// Defense in depth lives in `auth/better-auth.ts`, which now mounts -// `organization({ allowUserToCreateOrganization: false })` so the first step of -// the escalation is refused too. Either fix alone closes the hole; the gate is -// the load-bearing one, because it is what makes the authorization decision -// correct rather than merely making one route to it harder. -// --------------------------------------------------------------------------- - -import { Effect } from "effect"; - -import { BetterAuth } from "../auth/better-auth"; - -/** - * Why the caller was refused, in the vocabulary both planes share. - * - * The two planes render refusals through their OWN error classes - * (`AdminUnauthorized` / `AdminUsersUnauthorized`, and so on), because those - * are what their respective HttpApi contracts declare. So the gate decides, and - * each caller translates — rather than the gate importing one plane's errors - * and the other plane mapping between two error vocabularies. - */ -export type AdminGateDenial = "unauthorized" | "forbidden"; - -/** What a caller who passed the gate is. `userId` is the Better Auth `user.id` - * — the same id `auth/identity.ts` binds as the accountId, which is what the - * invite plane records as `createdBy`. */ -export interface InstanceAdmin { - readonly userId: string; - readonly role: string; -} - -/** - * Better Auth writes a member's roles as a comma-separated list (its own - * `leaveOrganization` reads them with `role.split(",")`), so a single-role - * string is the common case rather than the contract. Membership in the - * privileged set is therefore tested per role, not by equality on the whole - * field — an `"owner,admin"` value must not read as neither. - */ -const isPrivileged = (role: string): boolean => - role - .split(",") - .map((part) => part.trim()) - .some((part) => part === "owner" || part === "admin"); - -/** - * Authorize the caller as an owner/admin OF THE INSTANCE'S ORGANIZATION. - * - * Fails with `"unauthorized"` when there is no session at all, and - * `"forbidden"` when there is a session that is not an owner/admin member of - * the instance org — including a session belonging to an owner of some OTHER - * organization, which is the escalation this gate exists to refuse. - * - * Both Better Auth calls FAIL CLOSED, and closed means the less-informative - * answer of the two: a `getSession` that throws is treated as "no session" - * (401) and a `getActiveMemberRole` that throws is treated as "not entitled" - * (403), because the endpoint's own way of saying "you are not a member of this - * organization" IS a thrown FORBIDDEN. An infrastructure fault therefore - * refuses the request rather than surfacing as a 500, which is the correct - * trade on a plane where the wrong answer is a disclosure. - * - * TWO CALLS, NOT ONE. The session read is what distinguishes 401 from 403 — the - * role endpoint cannot, since it refuses the anonymous and the unentitled with - * the same status — and it is also where `userId` comes from, since the role - * endpoint answers with a role and nothing else. - */ -export const requireInstanceAdmin = ( - headers: Headers, -): Effect.Effect => - Effect.gen(function* () { - const { auth, organizationId } = yield* BetterAuth; - - const session = yield* Effect.tryPromise(() => auth.api.getSession({ headers })).pipe( - Effect.orElseSucceed(() => null), - ); - if (!session) return yield* Effect.fail("unauthorized"); - - // The whole fix in one argument: the org is named by the INSTANCE, never - // read from the caller's session. - const resolved = yield* Effect.tryPromise(() => - auth.api.getActiveMemberRole({ headers, query: { organizationId } }), - ).pipe(Effect.orElseSucceed(() => null)); - if (!resolved || !isPrivileged(resolved.role)) { - return yield* Effect.fail("forbidden"); - } - - return { userId: session.user.id, role: resolved.role }; - }); diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index a2341702a8..b0bda3f376 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -13,9 +13,8 @@ import { runSqliteDataMigrations } from "@executor-js/sdk"; import { resolveAuthProviders } from "./auth"; import { selfHostDataMigrations } from "./db/data-migrations"; -import { makeSelfHostAdminApiLayer } from "./admin/handlers"; +import { makeBetterAuthAdminApiLayer, makeBetterAuthSystemApiLayer } from "@executor-js/api/server"; import { makeSelfHostAdminUsersApiLayer } from "./admin/admin-users-api"; -import { makeSelfHostSystemApiLayer } from "./system/handlers"; import { selfHostAccountMiddleware } from "./account"; import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "./config"; import { createSelfHostDb, SelfHostDb, SelfHostDbProvider } from "./db/self-host-db"; @@ -126,13 +125,13 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // session-cookie-gated, delegating to the in-process MCP store. HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)), // App-local admin (invite-code) API, served under /api/admin/*. - makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), + makeBetterAuthAdminApiLayer({ betterAuth, mountPrefix: "/api" }), // Tenant-wide admin users API (/api/admin/users*): the owner's view of // who uses this instance and what they've connected. Owner/admin-gated, // same as the invite routes above. makeSelfHostAdminUsersApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), // Public system API: /api/health + /api/setup-status (unauthenticated). - makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), + makeBetterAuthSystemApiLayer({ betterAuth, mountPrefix: "/api" }), // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), ], diff --git a/apps/host-selfhost/src/auth/better-auth.test.ts b/apps/host-selfhost/src/auth/better-auth.test.ts index e84a3131ca..3723cc86a4 100644 --- a/apps/host-selfhost/src/auth/better-auth.test.ts +++ b/apps/host-selfhost/src/auth/better-auth.test.ts @@ -4,8 +4,6 @@ import { join } from "node:path"; import { afterAll, expect, test } from "@effect/vitest"; -import { mintInviteCode } from "../testing/mint-invite"; - // Real Better Auth path: set a secret + bootstrap admin before importing. // Better Auth skips origin checks in test mode by default; this suite exercises // the production check so the trusted-origin cases below cover the real path. @@ -19,6 +17,7 @@ process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com"; process.env.EXECUTOR_TRUSTED_ORIGINS = "http://executor.home.arpa:4788"; const { makeSelfHostApiHandler } = await import("../app"); +const { mintInviteCode } = await import("../testing/mint-invite"); const { handler, dispose } = await makeSelfHostApiHandler(); afterAll(() => dispose()); diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index 57d8eac86e..7d1f6d8ab7 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -1,355 +1,86 @@ -import { betterAuth, type BetterAuthOptions } from "better-auth"; -import { APIError } from "better-auth/api"; -import { admin, bearer, deviceAuthorization, mcp, organization } from "better-auth/plugins"; -import { apiKey } from "@better-auth/api-key"; +import { betterAuth } from "better-auth"; import { type Client } from "@libsql/client"; import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql"; -import { Context } from "effect"; -import { loadConfig } from "../config"; -import { seedOrgAndAdmin } from "./seed"; -import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites"; - -// The self-service signup gate: present only on the live (phase-2) auth -// instance, so the bootstrap seed's `createUser` — which -// runs on the gate-free phase-1 instance — is never blocked. `getAuth` is -// late-bound because the hooks call `auth.api.addMember` AFTER the instance they -// belong to is constructed (the closure resolves it at request time). -interface SignupGate { - readonly client: Client; - readonly organizationId: string; - readonly getAuth: () => Auth | null; -} - -// Only self-service email signups are code-gated. Server/admin-initiated user -// creation (the seed, or a future admin "add user") flows through other paths. -const SIGNUP_PATH = "/sign-up/email"; - -let warnedInsecureTrustedOrigin = false; - -// --------------------------------------------------------------------------- -// Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor -// tables ("one connection, two schema regions"). -// -// Schema-at-boot: passing `{ dialect: new LibsqlDialect({ client }), type: -// "sqlite" }` makes Better Auth's createKyselyAdapter take its `"dialect" in db` -// branch (no native dep, no bun:sqlite); `runMigrations()` creates the auth -// tables idempotently. `makeAuthOptions` is the single source of truth so the -// migrator and runtime instance never drift. -// -// CRITICAL: LibsqlDialect is handed SelfHostDb's EXISTING `@libsql/client` (the -// `{ client }` config branch), NOT a fresh `{ url }` connection. This is the -// crux of the self-host data-loss fix: libSQL connections each manage their own -// `-wal`/`-shm`, and when Better Auth opened a SECOND connection to the same -// file (`{ url }`), its open unlinked SelfHostDb's `-wal`/`-shm` and created new -// ones — orphaning SelfHostDb onto a now-deleted WAL inode. Every executor-core -// write (integrations, connections, tools) then landed in that deleted inode -// and vanished on the next restart, while Better Auth's own writes (on the live -// WAL) survived — the "reconnected account, zero tools" bug, reproducing even -// after the throwaway-bootstrap-instance fix because the LONG-LIVED auth -// connection unlinked it just the same. Sharing one client means one WAL: no -// unlink, and SelfHostDb's foreign_keys/WAL/busy_timeout PRAGMAs now cover auth -// queries too (same connection). `{ client }` sets closeClient=false, so the -// dialect never closes the handle — SelfHostDb owns the file lifecycle and -// closes its client at shutdown. NEVER call .destroy() during normal operation. -// -// We build exactly ONE auth instance, held for the process lifetime. An earlier -// design also built a throwaway "bootstrap" instance (discarded mid-boot); that -// is gone too — the org id is late-bound the same way the signup gate's -// `getAuth` already is, so no second instance is ever needed. -// -// `satisfies BetterAuthOptions` (not a return annotation) keeps the literal -// plugin tuple so `betterAuth` infers the plugin-augmented `auth.api` and -// session/user shapes (activeOrganizationId, role, createUser, ...). -// --------------------------------------------------------------------------- - -const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => { - const config = loadConfig(); - // A `Secure` session cookie is never sent back over plain HTTP, so an HTTP - // alias can sign in and then look signed out on every later request. Drop the - // attribute when ANY trusted origin is HTTP. This is not a new relaxation for - // the common cases: Better Auth already infers `useSecureCookies` from the - // baseURL scheme, so an all-HTTPS instance still gets `true` and the plain - // `http://localhost` default still gets `false`. It only changes the mixed - // case an operator opts into with EXECUTOR_TRUSTED_ORIGINS. - const hasInsecureTrustedOrigin = config.trustedOrigins.some((origin) => - origin.startsWith("http://"), - ); - // Warn only for that mixed case. An HTTP-only instance (local dev, a LAN - // deploy) never had Secure cookies to lose, and warning there would fire on - // every default boot. - const downgradesCanonicalCookies = - hasInsecureTrustedOrigin && config.webBaseUrl.startsWith("https://"); - if (downgradesCanonicalCookies && !warnedInsecureTrustedOrigin) { - warnedInsecureTrustedOrigin = true; - console.warn( - "[executor] EXECUTOR_TRUSTED_ORIGINS contains an http:// origin, so session cookies drop the Secure attribute for every origin — including the https:// canonical URL. Use https:// aliases to keep session cookies transport-secure.", - ); - } - // Always resolved (generated + persisted when no env is set); this guards only - // an explicitly-set env secret that is too weak. - const secret = config.authSecret; - if (secret.length < 32) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a multi-user auth server must not boot with a weak session secret - throw new Error("BETTER_AUTH_SECRET (or AUTH_SECRET), if set, must be at least 32 characters"); - } - return { - // Hand Better Auth the SAME libSQL client SelfHostDb already opened — NOT a - // fresh `{ url }` connection. `{ client }` makes LibsqlDialect adopt the - // existing handle (closeClient=false, so SelfHostDb keeps ownership). One - // connection means one WAL: see the header comment for why a second - // connection is the self-host data-loss bug. - // - // The cast bridges a dependency skew: @libsql/kysely-libsql pins an older - // @libsql/core (0.8) than @libsql/client (0.17), so the two `Client` types - // differ — only in `.sync()` (embedded-replica replication, unused here). - // The dialect calls execute/batch/transaction/close, which are identical - // across both versions, so sharing the 0.17 client is sound at runtime. - database: { - // oxlint-disable-next-line executor/no-double-cast -- boundary: the two @libsql/core versions' Client types are structurally identical for the calls the dialect makes (see above); no schema/decode applies to a native client handle. - dialect: new LibsqlDialect({ client } as unknown as LibsqlDialectConfig), - type: "sqlite" as const, - }, - secret, - // The canonical browser Origin is config.webBaseUrl; explicitly configured - // aliases may also send cookie-authenticated requests. CLI/MCP bearer - // requests carry no Origin and are unaffected. We deliberately do NOT derive - // either value from the request `Host`: matching the ecosystem (Windmill - // `BASE_URL`, n8n `WEBHOOK_URL`), a pinned origin keeps host-header injection - // out of OAuth redirects and links. Additional trusted origins affect only - // Better Auth's request validation; generated links and OAuth callbacks stay - // pinned to config.webBaseUrl. - baseURL: config.webBaseUrl, - trustedOrigins: [...config.trustedOrigins], - advanced: { useSecureCookies: !hasInsecureTrustedOrigin }, - emailAndPassword: { enabled: true }, - // `apiKey` issues long-lived personal keys (the API-keys page). With - // `enableSessionForAPIKeys`, presenting a key resolves to its owner's - // session — so a key works as a Bearer token for the API + MCP endpoint. - // - // `mcp()` adds the MCP OAuth Authorization Server: dynamic client - // registration + authorize + token under /api/auth/mcp/*, the discovery - // docs, and `getMcpSession` (opaque-bearer validation). It WRAPS - // oidcProvider — do NOT also add oidcProvider. The two root well-known docs - // are re-emitted by the shared envelope (MCP clients probe the origin root, - // not the /api/auth basePath). - plugins: [ - // SINGLE-ORG INSTANCE, ENFORCED. `allowUserToCreateOrganization: false` - // closes `POST /api/auth/organization/create` to every session. Left at - // its default (true) it was the first step of a privilege escalation: any - // invited member could create an organization of their own, become its - // `owner` (the creator role), get it set as their active org, and then - // pass an admin gate that resolved the caller's role from - // `session.activeOrganizationId` — reading the whole instance's user - // directory and minting themselves a durable `role: "admin"` invite code. - // The load-bearing fix is `admin/require-admin.ts`, which authorizes - // against THIS instance's org id instead of the session's; this flag is - // defense in depth, and is also just true of the product — self-host - // serves exactly one organization, so a second one is never legitimate. - // - // The bootstrap seed is unaffected: `seed.ts` calls `createOrganization` - // with `userId` and NO session, which better-auth 1.6.12 treats as - // `isSystemAction` and short-circuits past the `canCreateOrg` check - // (`plugins/organization/routes/crud-org.mjs` — verified against the - // installed build, and covered by every node test here, all of which boot - // through that path). - organization({ allowUserToCreateOrganization: false }), - admin(), - apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }), - bearer(), - // RFC 8628 device authorization, the CLI `executor login` flow. Registers - // /device/code + /device/token + the approval endpoints; the issued token - // is an opaque session that `bearer()` (above) accepts as `Authorization: - // Bearer` on the /api/* plane. `validateClient` is left unset, so any - // client_id is accepted (the CLI presents "executor-cli"). `verificationUri` - // is the page the user opens to confirm the code — the self-host app serves - // it at /device (this is also the Better Auth default; pinned for clarity). - deviceAuthorization({ verificationUri: "/device" }), - // `consentPage` makes the MCP authorize flow redirect to a human approval - // screen instead of auto-issuing a code — but ONLY when the request - // carries `prompt=consent`. MCP clients don't send that, so the self-host - // serving layer injects it on every authorize (see resolveAuthProviders' - // force-mcp-consent shim); together they force an approval step for every - // connecting client. The page itself is the SPA route `/mcp-consent`. - // `loginPage` in oidcConfig is required by the type but the mcp() plugin - // overrides it with the top-level one; `consentPage` is what we're after. - mcp({ - loginPage: "/login", - oidcConfig: { loginPage: "/login", consentPage: "/mcp-consent" }, - }), - ], - databaseHooks: { - session: { - create: { - // Single-org instance: pin every session to the one organization, so - // every authenticated user resolves to the org scope. The org id is - // read late (the seed resolves it AFTER this instance is built — see - // buildBetterAuth); no session is created during the seed, so the - // empty initial value is never observed. - before: async (session: Record) => ({ - data: { ...session, activeOrganizationId: getOrganizationId() }, - }), - }, - }, - // The signup gate. First-run: an org with ZERO members is unclaimed, so - // the first signup is admitted ungated and becomes the owner. After that, - // `before` rejects a signup without a valid, unused, unexpired invite code - // and `after` makes the new user a real `member` + burns the code. - ...(gate - ? { - user: { - create: { - before: async (_user, context) => { - if (context?.path !== SIGNUP_PATH) return; - if (await orgHasNoMembers(gate)) return; // first user claims the org - const code = inviteCodeFrom(context); - if (!code) { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a Better Auth create hook rejects a request by throwing APIError - throw new APIError("FORBIDDEN", { - message: "An invite code is required to sign up.", - }); - } - if (!(await findRedeemableCode(gate.client, code))) { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a Better Auth create hook rejects a request by throwing APIError - throw new APIError("FORBIDDEN", { - message: "That invite code is invalid, already used, or expired.", - }); - } - }, - after: async (user, context) => { - if (context?.path !== SIGNUP_PATH) return; - const auth = gate.getAuth(); - if (!auth) return; - // First user into an empty org becomes its owner (no code). - if (await orgHasNoMembers(gate)) { - await auth.api.addMember({ - body: { userId: user.id, role: "owner", organizationId: gate.organizationId }, - }); - return; - } - const code = inviteCodeFrom(context); - if (!code) return; - const redeemable = await findRedeemableCode(gate.client, code); - if (!redeemable) return; - await auth.api.addMember({ - body: { - userId: user.id, - role: redeemable.role, - organizationId: gate.organizationId, - }, - }); - await consumeInviteCode(gate.client, code, { - usedBy: user.id, - usedByEmail: user.email, - }); - }, - }, - }, - } - : {}), - }, - } satisfies BetterAuthOptions; -}; - -// The invite code rides on the signup request body (`{ name, email, password, -// inviteCode }`); Better Auth reads the body loosely, so a non-schema field -// survives to the create hook's endpoint context. -const inviteCodeFrom = (context: { body?: unknown }): string | undefined => { - const body = context.body; - if (body && typeof body === "object" && "inviteCode" in body) { - const code = (body as { inviteCode?: unknown }).inviteCode; - if (typeof code === "string" && code.trim().length > 0) return code; - } - return undefined; -}; - -// Count org members via Better Auth's OWN adapter. Now that auth shares -// SelfHostDb's libSQL client (one connection), this no longer guards against a -// cross-connection snapshot lag — that lag is gone with the second connection. -// It stays the canonical read because the adapter already models the `member` -// table and the count gates the first-run claim; reading through it keeps the -// gate logic next to the writes. -export const countOrgMembers = (auth: Auth, organizationId: string): Promise => - auth.$context.then(({ adapter }) => - adapter.count({ model: "member", where: [{ field: "organizationId", value: organizationId }] }), - ); - -// True when the single org has no members yet — the unclaimed first-run state. -const orgHasNoMembers = async (gate: SignupGate): Promise => { - const auth = gate.getAuth(); - if (!auth) return true; - return (await countOrgMembers(auth, gate.organizationId)) === 0; -}; - -const createAuthInstance = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => - betterAuth(makeAuthOptions(client, getOrganizationId, gate)); - -export type Auth = ReturnType; +import { + makeBetterAuthSharedOptions, + seedOrgAndAdmin, + ensureInviteCodeTable, + findRedeemableCode, + consumeInviteCode, + type BetterAuthInstance, + type BetterAuthDbClient, + type SignupGate, + BetterAuth as SharedBetterAuth, + type BetterAuthHandle as SharedBetterAuthHandle, +} from "@executor-js/api/server"; -export interface BetterAuthHandle { - readonly auth: Auth; - readonly organizationId: string; - readonly organizationName: string; - /** URL slug for org-prefixed console paths (`//policies`). */ - readonly organizationSlug: string; - readonly handler: (request: Request) => Promise; -} +import { loadConfig } from "../config"; -export class BetterAuth extends Context.Service()( - "@executor-js/host-selfhost/BetterAuth", -) {} +export const libSqlClientAdapter = (client: Client): BetterAuthDbClient => ({ + execute: async (sql, args) => { + const result = await client.execute({ sql, args: args ?? [] }); + return { rows: result.rows as any[], rowsAffected: result.rowsAffected }; + }, +}); -/** - * Build the single Better Auth instance: migrate, seed the org+admin, and pin - * the resolved org id into the (late-bound) session hook and signup gate. - * runMigrations and the seed are idempotent, so this is safe on every boot. - * - * One instance, not two: the org id the session-pin and gate need isn't known - * until the seed creates the org, but both read it lazily (a ref, like the - * gate's `getAuth`), so there's no need for a throwaway bootstrap instance — - * and so no second libSQL connection to be GC-closed mid-boot and unlink the - * shared WAL (see the header comment; that was the self-host data-loss bug). - * - * The gate is active during the seed, but its hooks only act on the - * `/sign-up/email` path — the seed's admin `createUser`/`createOrganization` - * pass straight through, exactly as the old gate-free bootstrap instance did. - * - * `client` is SelfHostDb's libSQL connection. Better Auth's LibsqlDialect is - * built on this SAME client (not a fresh `{ url }` one — see the header - * comment's data-loss note), so auth tables and executor tables share one - * connection and one WAL. The seed also uses it directly for its two - * idempotency reads against the auth tables Better Auth just migrated. - */ export const buildBetterAuth = async (client: Client): Promise => { const config = loadConfig(); + const dbClient = libSqlClientAdapter(client); - // The org id is resolved by the seed below, AFTER this instance is built; the - // session-pin hook and the gate read it through these late-bound accessors - // (no session is created during the seed, so the empty initial id is never - // observed). `getAuth` resolves to this very instance, so the gate's `after` - // hook can call `auth.api.addMember` once a code is redeemed. - let auth: Auth | null = null; + let auth: BetterAuthInstance | null = null; const orgRef = { id: "" }; const gate: SignupGate = { - client, get organizationId() { return orgRef.id; }, getAuth: () => auth, + findRedeemableCode: (code) => findRedeemableCode(dbClient, code), + consumeInviteCode: (code, by) => consumeInviteCode(dbClient, code, by), + }; + + const sharedOptions = makeBetterAuthSharedOptions( + () => orgRef.id, + { + authSecret: config.authSecret, + webBaseUrl: config.webBaseUrl, + trustedOrigins: config.trustedOrigins, + }, + gate, + ); + + const authOptions = { + ...sharedOptions, + database: { + // oxlint-disable-next-line executor/no-double-cast -- boundary: version structural compatibility + dialect: new LibsqlDialect({ client } as unknown as LibsqlDialectConfig), + type: "sqlite" as const, + }, }; - auth = createAuthInstance(client, () => orgRef.id, gate); - // `runMigrations()` flows through the LibsqlDialect and is idempotent. - await (await auth.$context).runMigrations(); - await ensureInviteCodeTable(client); - const { organizationId, organizationName } = await seedOrgAndAdmin(auth, client, config); + const authInstance = betterAuth(authOptions); + auth = authInstance as any; + await (await authInstance.$context).runMigrations(); + await ensureInviteCodeTable(dbClient); + const { organizationId, organizationName } = await seedOrgAndAdmin( + authInstance as any, + dbClient, + config, + ); orgRef.id = organizationId; return { - auth, + auth: authInstance as any, organizationId, organizationName, organizationSlug: config.orgSlug, - handler: auth.handler, + handler: authInstance.handler, + dbClient, }; }; + +export type Auth = BetterAuthInstance; +export type BetterAuthHandle = SharedBetterAuthHandle; +export const BetterAuth = SharedBetterAuth; + +export { countOrgMembers } from "@executor-js/api/server"; diff --git a/apps/host-selfhost/src/auth/force-mcp-consent.test.ts b/apps/host-selfhost/src/auth/force-mcp-consent.test.ts deleted file mode 100644 index 1febcf3992..0000000000 --- a/apps/host-selfhost/src/auth/force-mcp-consent.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { - consentRedirectClientId, - promptWithConsent, - withClientName, - withForcedMcpConsent, -} from "./force-mcp-consent"; - -describe("promptWithConsent", () => { - it("adds consent to an empty prompt", () => { - expect(promptWithConsent(null)).toBe("consent"); - expect(promptWithConsent("")).toBe("consent"); - }); - it("preserves other prompt values without duplicating consent", () => { - expect(promptWithConsent("login")).toBe("login consent"); - expect(promptWithConsent("consent")).toBe("consent"); - expect(promptWithConsent("login consent")).toBe("login consent"); - }); -}); - -describe("withForcedMcpConsent", () => { - const authorize = (qs: string) => new Request(`https://host.example/api/auth/mcp/authorize${qs}`); - - it("injects prompt=consent on MCP authorize", () => { - const out = withForcedMcpConsent(authorize("?client_id=abc&response_type=code")); - expect(new URL(out.url).searchParams.get("prompt")).toBe("consent"); - }); - - it("merges with an existing prompt", () => { - const out = withForcedMcpConsent(authorize("?client_id=abc&prompt=login")); - expect(new URL(out.url).searchParams.get("prompt")).toBe("login consent"); - }); - - it("leaves an already-consent request unchanged (same instance)", () => { - const req = authorize("?client_id=abc&prompt=consent"); - expect(withForcedMcpConsent(req)).toBe(req); - }); - - it("never touches non-authorize or non-GET requests", () => { - const other = new Request("https://host.example/api/auth/mcp/token", { method: "POST" }); - expect(withForcedMcpConsent(other)).toBe(other); - const consent = new Request("https://host.example/api/auth/oauth2/consent", { method: "POST" }); - expect(withForcedMcpConsent(consent)).toBe(consent); - }); -}); - -describe("consentRedirectClientId", () => { - it("returns the client id of a consent redirect lacking a name", () => { - expect(consentRedirectClientId("/mcp-consent?consent_code=c&client_id=abc&scope=openid")).toBe( - "abc", - ); - }); - it("returns null when the name is already present, or it isn't a consent redirect", () => { - expect(consentRedirectClientId("/mcp-consent?client_id=abc&client_name=Codex")).toBeNull(); - expect(consentRedirectClientId("/login?client_id=abc")).toBeNull(); - expect(consentRedirectClientId(null)).toBeNull(); - }); -}); - -describe("withClientName", () => { - it("appends client_name to a consent redirect (path+query only)", () => { - expect(withClientName("/mcp-consent?consent_code=c&client_id=abc", "Claude Code")).toBe( - "/mcp-consent?consent_code=c&client_id=abc&client_name=Claude+Code", - ); - }); -}); diff --git a/apps/host-selfhost/src/auth/force-mcp-consent.ts b/apps/host-selfhost/src/auth/force-mcp-consent.ts deleted file mode 100644 index 56afe8a470..0000000000 --- a/apps/host-selfhost/src/auth/force-mcp-consent.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Force a human approval screen on every MCP OAuth connection. -// -// Better Auth's MCP authorize endpoint only shows the consent page when the -// request carries `prompt=consent` (it otherwise auto-issues an authorization -// code — see better-auth/plugins/mcp/authorize). MCP clients don't send that, -// so a connecting client would be granted a token with no human approval. This -// wraps Better Auth's web handler and adds `consent` to the `prompt` of every -// `GET /api/auth/mcp/authorize`, so — paired with `oidcConfig.consentPage` — -// every connect is gated on the `/oauth/consent` approval screen. -// -// Pure + Effect-free; the wrapper is a plain Request -> Request transform so it -// composes with whatever serves the Better Auth handler (prod + vite dev both -// mount the same handler). - -const AUTHORIZE_PATH = "/api/auth/mcp/authorize"; -const CONSENT_PAGE = "/mcp-consent"; - -/** Merge `consent` into a possibly-empty space-separated `prompt` value. */ -export const promptWithConsent = (prompt: string | null): string => { - const set = new Set((prompt ?? "").split(/\s+/).filter((value) => value.length > 0)); - set.add("consent"); - return Array.from(set).join(" "); -}; - -/** - * Return the MCP-authorize request with `prompt=consent` ensured, or the - * original request unchanged when it isn't an MCP authorize call. - */ -export const withForcedMcpConsent = (request: Request): Request => { - if (request.method !== "GET") return request; - const url = new URL(request.url); - if (url.pathname !== AUTHORIZE_PATH) return request; - const prompt = url.searchParams.get("prompt"); - if (prompt && prompt.split(/\s+/).includes("consent")) return request; - url.searchParams.set("prompt", promptWithConsent(prompt)); - return new Request(url, request); -}; - -/** - * If `location` is Better Auth's redirect to the consent page carrying a - * `client_id` but no `client_name`, return that client id (so the caller can - * look up the registered name and enrich the redirect). Otherwise null. - * Better Auth's authorize only puts the opaque `client_id` on the consent - * redirect; the registered name makes the approval screen legible. - */ -export const consentRedirectClientId = (location: string | null): string | null => { - if (!location) return null; - const url = new URL(location, "http://host.internal"); - if (url.pathname !== CONSENT_PAGE) return null; - if (url.searchParams.get("client_name")) return null; - return url.searchParams.get("client_id"); -}; - -/** Append `client_name` to a consent-page redirect URL (path + query only). */ -export const withClientName = (location: string, clientName: string): string => { - const url = new URL(location, "http://host.internal"); - url.searchParams.set("client_name", clientName); - return `${url.pathname}${url.search}`; -}; diff --git a/apps/host-selfhost/src/auth/identity.ts b/apps/host-selfhost/src/auth/identity.ts deleted file mode 100644 index 932e90230c..0000000000 --- a/apps/host-selfhost/src/auth/identity.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Effect, Layer } from "effect"; - -import { IdentityProvider, Unauthorized } from "@executor-js/api/server"; - -import { BetterAuth } from "./better-auth"; - -// --------------------------------------------------------------------------- -// The self-host identity seam — the production implementation of the shared -// `IdentityProvider` from `@executor-js/api/server`, which resolves an incoming -// request to a Principal. WorkOS (cloud) and Better Auth (self-host) are -// interchangeable implementations of the same tag; nothing downstream knows -// which is wired. -// -// - succeeds with a Principal -> authenticated -// - fails Unauthorized -> no/invalid credential (renders 401) -// - fails NoOrganization -> valid credential, no org (renders 403) -// -// `betterAuthIdentityLayer` is the only production provider. The trivial fake -// identities tests inject live in `src/testing/test-app.ts`. -// --------------------------------------------------------------------------- - -const bearerToken = (headers: Headers): string | undefined => { - const authorization = headers.get("authorization"); - if (!authorization) return undefined; - return authorization.toLowerCase().startsWith("bearer ") - ? authorization.slice(7).trim() || undefined - : undefined; -}; - -// --------------------------------------------------------------------------- -// The production IdentityProvider: resolve a request to a Better Auth session -// and map it to a neutral Principal. Three credential shapes resolve here: -// - session cookie (browser SPA) -// - Bearer session token (bearer plugin) -// - Bearer API key — the apiKey plugin reads `x-api-key`, so when the normal -// resolution fails we retry with the Bearer value as x-api-key, which (with -// enableSessionForAPIKeys) mints the owner's session. This is what lets a -// generated API key authenticate the API + MCP endpoint as a Bearer token. -// Single-org instance, so organizationName is the boot-cached org name. -// --------------------------------------------------------------------------- - -export const betterAuthIdentityLayer: Layer.Layer = - Layer.effect(IdentityProvider)( - Effect.gen(function* () { - const { auth, organizationId, organizationName, organizationSlug } = yield* BetterAuth; - return IdentityProvider.of({ - authenticate: (request) => - Effect.gen(function* () { - let resolved = yield* Effect.promise(() => - auth.api.getSession({ headers: request.headers }), - ); - if (!resolved) { - const token = bearerToken(request.headers); - if (token) { - resolved = yield* Effect.tryPromise({ - try: () => auth.api.getSession({ headers: { "x-api-key": token } }), - catch: () => "api-key session lookup failed", - }).pipe(Effect.orElseSucceed(() => null)); - } - } - // No session resolved from any credential shape -> unauthenticated. - // The middleware's failure strategy renders this as a 401. - if (!resolved) return yield* new Unauthorized(); - // Single-org instance: every authenticated user belongs to the one - // seeded org. Cookie/bearer-session logins are pinned to it by the - // session hook; API-key-minted sessions carry no active org, so we - // default to the seeded org rather than rejecting with NoOrganization. - const resolvedOrganizationId = resolved.session.activeOrganizationId ?? organizationId; - return { - kind: "member" as const, - accountId: resolved.user.id, - organizationId: resolvedOrganizationId, - organizationName, - organizationSlug, - email: resolved.user.email, - name: resolved.user.name ?? null, - avatarUrl: resolved.user.image ?? null, - roles: (resolved.user.role ?? "user") - .split(",") - .map((role) => role.trim()) - .filter((role) => role.length > 0), - }; - }), - }); - }), - ); diff --git a/apps/host-selfhost/src/auth/index.ts b/apps/host-selfhost/src/auth/index.ts index bf9a4b5839..f12ef51b44 100644 --- a/apps/host-selfhost/src/auth/index.ts +++ b/apps/host-selfhost/src/auth/index.ts @@ -1,37 +1,25 @@ import { Layer } from "effect"; -import { IdentityProvider } from "@executor-js/api/server"; +import { + IdentityProvider, + BetterAuth as SharedBetterAuth, + betterAuthIdentityLayer, + withForcedMcpConsent, + rewriteInvalidOrigin, + consentRedirectClientId, + withClientName, +} from "@executor-js/api/server"; -import { loadConfig } from "../config"; import type { SelfHostDbHandle } from "../db/self-host-db"; -import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; -import { betterAuthIdentityLayer } from "./identity"; -import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent"; -import { rewriteInvalidOrigin } from "./invalid-origin-help"; +import { loadConfig } from "../config"; +import { buildBetterAuth, type BetterAuthHandle } from "./better-auth"; export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; -export { betterAuthIdentityLayer } from "./identity"; - -// --------------------------------------------------------------------------- -// Resolve the self-host auth providers. -// -// Build the Better Auth instance over the shared libSQL file, expose its -// `IdentityProvider` (cookie/bearer/api-key) and its web handler (mounted at -// /api/auth/*). Returns the live `BetterAuthHandle` so the composition root can -// build the account API and the Better Auth MCP OAuth seam. -// -// This is the one and only production auth path. Tests that need a fake identity -// (single-admin / header-driven) compose `ExecutorApp.make` directly through -// `makeSelfHostTestApp` (src/testing/test-app.ts) rather than passing through -// here, so this resolution is unconditional. -// --------------------------------------------------------------------------- +export { betterAuthIdentityLayer } from "@executor-js/api/server"; export interface ResolvedAuthProviders { - /** The resolved Better Auth `IdentityProvider` seam (cookie/bearer/api-key). */ readonly identityLayer: Layer.Layer; - /** Better Auth's web handler (`/api/auth/*`). */ readonly authHandler: (request: Request) => Promise; - /** The live Better Auth handle (account API + Better Auth MCP OAuth seam). */ readonly betterAuth: BetterAuthHandle; } @@ -39,12 +27,8 @@ export const resolveAuthProviders = async ( dbHandle: SelfHostDbHandle, ): Promise => { const betterAuth = await buildBetterAuth(dbHandle.client); - const betterAuthLayer = Layer.succeed(BetterAuth)(betterAuth); + const betterAuthLayer = Layer.succeed(SharedBetterAuth)(betterAuth); - // The consent redirect from Better Auth's authorize only carries the opaque - // client_id; look the registered client_name up (its adapter sees the - // just-written DCR row) so the approval screen reads "Connect Codex?" not a - // random id. Self-declared at open DCR — a label, not a trust signal. const lookupClientName = async (clientId: string): Promise => { const ctx = await betterAuth.auth.$context; const app = await ctx.adapter.findOne<{ name?: string | null }>({ @@ -54,15 +38,9 @@ export const resolveAuthProviders = async ( return app?.name ?? null; }; - // Force the MCP approval screen: inject `prompt=consent` on every MCP - // authorize so a connecting client is gated on /mcp-consent rather than - // silently granted a token (see ./force-mcp-consent), and enrich the - // resulting consent redirect with the registered client name. const config = loadConfig(); const authHandler = async (request: Request): Promise => { const response = await betterAuth.handler(withForcedMcpConsent(request)); - // Turn Better Auth's bare 403 "Invalid origin" into a setup instruction — - // on a fresh deploy it almost always means the public URL needs configuring. const friendlier = await rewriteInvalidOrigin(request, response, config.webBaseUrl); if (friendlier) return friendlier; if (response.status !== 302) return response; @@ -70,7 +48,6 @@ export const resolveAuthProviders = async ( if (!clientId) return response; const name = await lookupClientName(clientId); if (!name) return response; - // Preserve the rest of the response — notably the signed consent cookie. const headers = new Headers(response.headers); headers.set("location", withClientName(response.headers.get("location")!, name)); return new Response(null, { status: 302, headers }); diff --git a/apps/host-selfhost/src/auth/invalid-origin-help.test.ts b/apps/host-selfhost/src/auth/invalid-origin-help.test.ts deleted file mode 100644 index cdbefa7f34..0000000000 --- a/apps/host-selfhost/src/auth/invalid-origin-help.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { expect, test } from "@effect/vitest"; - -import { invalidOriginHelp, originOf, rewriteInvalidOrigin } from "./invalid-origin-help"; - -const req = (headers: Record) => - new Request("https://svc.internal/api/auth/sign-up/email", { method: "POST", headers }); - -test("originOf prefers Origin, then x-forwarded-host, then host", () => { - expect(originOf(req({ origin: "https://app.example.com" }))).toBe("https://app.example.com"); - expect( - originOf(req({ "x-forwarded-host": "app.example.com", "x-forwarded-proto": "https" })), - ).toBe("https://app.example.com"); - expect(originOf(req({ host: "app.example.com" }))).toBe("https://app.example.com"); -}); - -test("the help message names the URL to set", () => { - const msg = invalidOriginHelp("https://app.example.com", "http://localhost:4788"); - expect(msg).toContain("EXECUTOR_WEB_BASE_URL"); - expect(msg).toContain("EXECUTOR_TRUSTED_ORIGINS"); - expect(msg).toContain("https://app.example.com"); - expect(msg).toContain("http://localhost:4788"); -}); - -test("rewriteInvalidOrigin replaces a 403 'Invalid origin' with the setup message, keeping the code", async () => { - const original = new Response( - JSON.stringify({ code: "INVALID_ORIGIN", message: "Invalid origin" }), - { - status: 403, - headers: { "content-type": "application/json" }, - }, - ); - const rewritten = await rewriteInvalidOrigin( - req({ origin: "https://app.example.com" }), - original, - "http://localhost:4788", - ); - expect(rewritten).not.toBeNull(); - expect(rewritten!.status).toBe(403); - const body = (await rewritten!.json()) as { code: string; message: string }; - expect(body.code).toBe("INVALID_ORIGIN"); - expect(body.message).toContain("EXECUTOR_WEB_BASE_URL"); - expect(body.message).toContain("EXECUTOR_TRUSTED_ORIGINS"); - expect(body.message).toContain("https://app.example.com"); -}); - -test("rewriteInvalidOrigin passes other responses through untouched", async () => { - expect(await rewriteInvalidOrigin(req({}), new Response("ok", { status: 200 }), "x")).toBeNull(); - const otherErr = new Response(JSON.stringify({ message: "An invite code is required" }), { - status: 403, - }); - expect(await rewriteInvalidOrigin(req({}), otherErr, "x")).toBeNull(); -}); diff --git a/apps/host-selfhost/src/auth/invites.ts b/apps/host-selfhost/src/auth/invites.ts index 20fdd23c27..9920198bef 100644 --- a/apps/host-selfhost/src/auth/invites.ts +++ b/apps/host-selfhost/src/auth/invites.ts @@ -1,153 +1,30 @@ -import { randomBytes } from "node:crypto"; +import { type Client } from "@libsql/client"; +import { libSqlClientAdapter } from "./better-auth"; +import { + ensureInviteCodeTable as sharedEnsureInviteCodeTable, + createInviteCode as sharedCreateInviteCode, + listInviteCodes as sharedListInviteCodes, + revokeInviteCode as sharedRevokeInviteCode, + findRedeemableCode as sharedFindRedeemableCode, + consumeInviteCode as sharedConsumeInviteCode, +} from "@executor-js/api/server"; -import type { Client, Row } from "@libsql/client"; +export type { InviteRole, InviteCodeRow, CreateInviteCodeInput } from "@executor-js/api/server"; -// --------------------------------------------------------------------------- -// Invite codes — the join mechanism for a single-tenant instance. -// -// The instance closes open signup (the `user.create` gate in better-auth.ts) -// and lets people in ONLY by redeeming a per-user, single-use code. The code is -// the bearer credential: whoever holds it can self-register (with their own -// name/email/password) and lands as a real `member` of the one org. Unlike -// Better Auth's `invitation` table, a code is NOT bound to an email — the admin -// hands out a link, not an address. -// -// Stored in a raw libSQL table managed here (CREATE TABLE IF NOT EXISTS on -// boot), the same hand-rolled-SQL pattern the org/admin seed uses against the -// shared libSQL file. It is intentionally independent of both the fumadb -// versioned schema and Better Auth's migrator. -// --------------------------------------------------------------------------- +export const ensureInviteCodeTable = (client: Client) => + sharedEnsureInviteCodeTable(libSqlClientAdapter(client)); -export type InviteRole = "admin" | "member"; +export const createInviteCode = (client: Client, input: any) => + sharedCreateInviteCode(libSqlClientAdapter(client), input); -export interface InviteCodeRow { - readonly id: string; - readonly code: string; - readonly role: InviteRole; - readonly label: string | null; - readonly createdBy: string; - readonly createdAt: string; - readonly expiresAt: string | null; - readonly usedBy: string | null; - readonly usedByEmail: string | null; - readonly usedAt: string | null; -} +export const listInviteCodes = (client: Client) => + sharedListInviteCodes(libSqlClientAdapter(client)); -// Unambiguous alphabet (no 0/O/1/I/l) so a code is easy to read and type. -const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +export const revokeInviteCode = (client: Client, id: string) => + sharedRevokeInviteCode(libSqlClientAdapter(client), id); -// 12 chars grouped as XXXX-XXXX-XXXX — easy to read aloud or paste. -const generateCode = (): string => { - const bytes = randomBytes(12); - const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]); - return [chars.slice(0, 4), chars.slice(4, 8), chars.slice(8, 12)] - .map((g) => g.join("")) - .join("-"); -}; +export const findRedeemableCode = (client: Client, code: string) => + sharedFindRedeemableCode(libSqlClientAdapter(client), code); -const toRow = (raw: Row): InviteCodeRow => ({ - id: String(raw.id), - code: String(raw.code), - role: raw.role === "admin" ? "admin" : "member", - label: raw.label == null ? null : String(raw.label), - createdBy: String(raw.created_by), - createdAt: String(raw.created_at), - expiresAt: raw.expires_at == null ? null : String(raw.expires_at), - usedBy: raw.used_by == null ? null : String(raw.used_by), - usedByEmail: raw.used_by_email == null ? null : String(raw.used_by_email), - usedAt: raw.used_at == null ? null : String(raw.used_at), -}); - -export const ensureInviteCodeTable = async (client: Client): Promise => { - await client.execute(` - CREATE TABLE IF NOT EXISTS invite_code ( - id TEXT PRIMARY KEY, - code TEXT NOT NULL UNIQUE, - role TEXT NOT NULL DEFAULT 'member', - label TEXT, - created_by TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT, - used_by TEXT, - used_by_email TEXT, - used_at TEXT - ) - `); -}; - -export interface CreateInviteCodeInput { - readonly createdBy: string; - readonly role?: InviteRole; - readonly label?: string | null; - readonly expiresAt?: string | null; -} - -export const createInviteCode = async ( - client: Client, - input: CreateInviteCodeInput, -): Promise => { - const row: InviteCodeRow = { - id: randomBytes(16).toString("hex"), - code: generateCode(), - role: input.role ?? "member", - label: input.label ?? null, - createdBy: input.createdBy, - createdAt: new Date().toISOString(), - expiresAt: input.expiresAt ?? null, - usedBy: null, - usedByEmail: null, - usedAt: null, - }; - await client.execute({ - sql: `INSERT INTO invite_code (id, code, role, label, created_by, created_at, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - args: [row.id, row.code, row.role, row.label, row.createdBy, row.createdAt, row.expiresAt], - }); - return row; -}; - -// Newest first; the admin page renders pending + used together. -export const listInviteCodes = async (client: Client): Promise => { - const result = await client.execute("SELECT * FROM invite_code ORDER BY created_at DESC"); - return result.rows.map(toRow); -}; - -// Revoke = delete a pending (unused) code. Used codes are kept as an audit row -// (their membership already exists); deleting one would not remove the member. -export const revokeInviteCode = async (client: Client, id: string): Promise => { - await client.execute({ - sql: "DELETE FROM invite_code WHERE id = ? AND used_at IS NULL", - args: [id], - }); -}; - -// A code is redeemable when it exists, is unused, and is unexpired. -export const findRedeemableCode = async ( - client: Client, - code: string, -): Promise => { - const result = await client.execute({ - sql: "SELECT * FROM invite_code WHERE code = ? AND used_at IS NULL", - args: [code.trim().toUpperCase()], - }); - const raw = result.rows[0]; - if (!raw) return null; - const row = toRow(raw); - if (row.expiresAt && Date.parse(row.expiresAt) < Date.now()) return null; - return row; -}; - -// Mark a code consumed. The `used_at IS NULL` guard makes this the single-use -// gate even under a race: rowsAffected === 0 means someone redeemed it first. -export const consumeInviteCode = async ( - client: Client, - code: string, - by: { usedBy: string; usedByEmail: string }, -): Promise => { - const result = await client.execute({ - sql: `UPDATE invite_code SET used_by = ?, used_by_email = ?, used_at = ? - WHERE code = ? AND used_at IS NULL`, - args: [by.usedBy, by.usedByEmail, new Date().toISOString(), code.trim().toUpperCase()], - }); - return result.rowsAffected > 0; -}; +export const consumeInviteCode = (client: Client, code: string, by: any) => + sharedConsumeInviteCode(libSqlClientAdapter(client), code, by); diff --git a/apps/host-selfhost/src/auth/oauth-callback-login.ts b/apps/host-selfhost/src/auth/oauth-callback-login.ts index 557dc15f3f..44b82824ec 100644 --- a/apps/host-selfhost/src/auth/oauth-callback-login.ts +++ b/apps/host-selfhost/src/auth/oauth-callback-login.ts @@ -1,4 +1,4 @@ -import { loginPath } from "./return-to"; +import { loginPath } from "@executor-js/api/server"; export const OAUTH_CALLBACK_PATH = "/api/oauth/callback"; diff --git a/apps/host-selfhost/src/auth/return-to.test.ts b/apps/host-selfhost/src/auth/return-to.test.ts deleted file mode 100644 index 896c5cc771..0000000000 --- a/apps/host-selfhost/src/auth/return-to.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { - isSafeReturnTo, - loginPath, - mcpAuthorizeResumeTarget, - postLoginTarget, - safeReturnTo, -} from "./return-to"; - -describe("isSafeReturnTo", () => { - const safe = [ - "/", - "/tools", - "/integrations/sentry?addAccount=1", - "/api-keys", - "/api/oauth/callback?state=oauth-state&code=provider-code", - ]; - for (const path of safe) { - it(`allows ${path}`, () => { - expect(isSafeReturnTo(path)).toBe(true); - }); - } - - const unsafe = [ - "https://evil.example", - "//evil.example", - "/api/auth/logout", - "/api/oauth/callback/extra?state=oauth-state", - "/api", - "javascript:alert(1)", - "tools", - "", - ]; - for (const path of unsafe) { - it(`rejects ${JSON.stringify(path)}`, () => { - expect(isSafeReturnTo(path)).toBe(false); - }); - } -}); - -describe("safeReturnTo", () => { - it("passes a safe path through", () => { - expect(safeReturnTo("/tools")).toBe("/tools"); - }); - - it("nulls unsafe and absent values", () => { - expect(safeReturnTo("https://evil.example")).toBeNull(); - expect(safeReturnTo(null)).toBeNull(); - expect(safeReturnTo(undefined)).toBeNull(); - }); -}); - -describe("mcpAuthorizeResumeTarget", () => { - // Better Auth bounces an unauthenticated MCP authorize to /login carrying the - // OAuth params; after sign-in we resume by handing them back to the authorize - // endpoint so it issues a code (then the consent shim takes over). - it("rebuilds the authorize URL from a real MCP authorize redirect", () => { - const search = - "?response_type=code&client_id=abc&code_challenge=xyz&code_challenge_method=S256" + - "&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback&state=s1&scope=openid+profile&prompt=consent"; - const target = mcpAuthorizeResumeTarget(search); - expect(target).not.toBeNull(); - expect(target!.startsWith("/api/auth/mcp/authorize?")).toBe(true); - const params = new URLSearchParams(target!.split("?")[1]); - expect(params.get("client_id")).toBe("abc"); - expect(params.get("redirect_uri")).toBe("http://localhost:3118/callback"); - expect(params.get("response_type")).toBe("code"); - }); - - it("ignores searches that are not an authorize request", () => { - expect(mcpAuthorizeResumeTarget("")).toBeNull(); - expect(mcpAuthorizeResumeTarget("?returnTo=%2Ftools")).toBeNull(); - // response_type alone is not enough: client_id and redirect_uri are required. - expect(mcpAuthorizeResumeTarget("?response_type=code&client_id=abc")).toBeNull(); - expect( - mcpAuthorizeResumeTarget("?response_type=token&client_id=abc&redirect_uri=x"), - ).toBeNull(); - }); -}); - -describe("postLoginTarget", () => { - // Self-host renders the login page IN PLACE of the requested route without - // navigating, so the live location is the only record of where the person was - // headed. A connect deep link must survive sign-in on that basis alone. - it("returns to the deep link the person actually opened", () => { - expect(postLoginTarget({ pathname: "/connect/linear", search: "" })).toBe("/connect/linear"); - expect(postLoginTarget({ pathname: "/integrations/sentry", search: "?addAccount=1" })).toBe( - "/integrations/sentry?addAccount=1", - ); - }); - - it("lands on the dashboard when signing in from the bare login page", () => { - // No deep link to resume, and echoing /login back would re-render the form. - expect(postLoginTarget({ pathname: "/login", search: "" })).toBe("/"); - }); - - it("prefers an explicit returnTo over the current location", () => { - expect(postLoginTarget({ pathname: "/login", search: "?returnTo=%2Fconnect%2Flinear" })).toBe( - "/connect/linear", - ); - }); - - it("prefers an MCP authorize resume over everything else", () => { - const target = postLoginTarget({ - pathname: "/login", - search: - "?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback", - }); - expect(target.startsWith("/api/auth/mcp/authorize?")).toBe(true); - }); - - it("never honors an unsafe location", () => { - // `safeReturnTo` vets the echoed path too — an app-plane path is not a - // place to land a freshly signed-in browser. - expect(postLoginTarget({ pathname: "/api/auth/logout", search: "" })).toBe("/"); - }); -}); - -describe("loginPath", () => { - it("omits returnTo for the root", () => { - expect(loginPath("/")).toBe("/login"); - }); - - it("carries OAuth callback resumes URI-encoded", () => { - expect(loginPath("/api/oauth/callback?state=oauth-state&code=provider-code")).toBe( - "/login?returnTo=%2Fapi%2Foauth%2Fcallback%3Fstate%3Doauth-state%26code%3Dprovider-code", - ); - }); -}); diff --git a/apps/host-selfhost/src/auth/return-to.ts b/apps/host-selfhost/src/auth/return-to.ts deleted file mode 100644 index 25e952b204..0000000000 --- a/apps/host-selfhost/src/auth/return-to.ts +++ /dev/null @@ -1,63 +0,0 @@ -const pathPart = (path: string): string => path.split(/[?#]/, 1)[0] ?? ""; - -const isOAuthCallbackReturnTo = (path: string): boolean => pathPart(path) === "/api/oauth/callback"; - -export const isSafeReturnTo = (path: string): boolean => - path.startsWith("/") && - !path.startsWith("//") && - (!/^\/api(\/|$)/.test(path) || isOAuthCallbackReturnTo(path)); - -export const safeReturnTo = (path: string | null | undefined): string | null => - path && isSafeReturnTo(path) ? path : null; - -export const loginPath = (returnTo: string): string => - returnTo === "/" ? "/login" : `/login?returnTo=${encodeURIComponent(returnTo)}`; - -// Better Auth's MCP authorize endpoint redirects an unauthenticated client to -// `loginPage` (/login) carrying the original OAuth request as query params -// (`response_type=code`, `client_id`, `redirect_uri`, `code_challenge`, ...). -// Unlike the integration OAuth callback (which arrives as `returnTo`), there is -// no returnTo here: the params ARE the request. After sign-in the login page -// must hand control back to the authorize endpoint so the now-authenticated -// request issues a code (and, via the consent shim, lands on /mcp-consent). -// Given a location search string, return that resume URL when it carries an MCP -// authorize request, else null. The target is our own same-origin authorize -// endpoint, which validates client_id/redirect_uri, so this is not an open -// redirect. -const MCP_AUTHORIZE_PATH = "/api/auth/mcp/authorize"; - -export const mcpAuthorizeResumeTarget = (search: string): string | null => { - const params = new URLSearchParams(search); - if (params.get("response_type") !== "code") return null; - if (!params.get("client_id") || !params.get("redirect_uri")) return null; - return `${MCP_AUTHORIZE_PATH}?${params.toString()}`; -}; - -const LOGIN_PATH = "/login"; - -/** - * Where the self-host login page sends someone after a successful sign-in, in - * priority order: - * - * 1. an interrupted MCP OAuth authorize (the params ARE the request), - * 2. an explicit safe `returnTo` (e.g. the integration OAuth callback), - * 3. the URL they actually opened, and - * 4. the dashboard. - * - * Step 3 is what makes a deep link like `/connect/linear` survive sign-in here. - * Unlike cloud — which redirects to `/login?returnTo=…` — self-host's gate - * swaps the login page in WITHOUT navigating, so the address bar still holds - * the requested URL and no `returnTo` was ever written. `/login` itself is - * excluded so signing in from the bare login page lands on the dashboard rather - * than looping back to the form. - */ -export const postLoginTarget = (location: { - readonly pathname: string; - readonly search: string; -}): string => - mcpAuthorizeResumeTarget(location.search) ?? - safeReturnTo(new URLSearchParams(location.search).get("returnTo")) ?? - (location.pathname === LOGIN_PATH - ? null - : safeReturnTo(`${location.pathname}${location.search}`)) ?? - "/"; diff --git a/apps/host-selfhost/src/auth/seed.ts b/apps/host-selfhost/src/auth/seed.ts deleted file mode 100644 index dbeeac93a2..0000000000 --- a/apps/host-selfhost/src/auth/seed.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { randomBytes } from "node:crypto"; - -import type { Client } from "@libsql/client"; - -import type { SelfHostConfig } from "../config"; -import type { Auth } from "./better-auth"; - -// --------------------------------------------------------------------------- -// Idempotent first-boot bootstrap: ensure the single organization and a -// bootstrap admin exist. Uses server-side auth.api calls (no session, no CLI) -// and queries the freshly-migrated Better Auth tables directly (through -// SelfHostDb's libSQL client — the SAME file Better Auth migrated, proving the -// cross-connection invariant) to stay idempotent across restarts. Returns the -// resolved org id/name, which the session-pin hook and the AuthProvider's -// org-name cache read. -// --------------------------------------------------------------------------- - -export const seedOrgAndAdmin = async ( - auth: Auth, - client: Client, - config: SelfHostConfig, -): Promise<{ organizationId: string; organizationName: string }> => { - // Idempotent: once the single organization exists, boot is past first-run. - // This instance is SINGLE-org, so adopt whatever organization exists rather - // than looking it up by slug — matching on slug would silently create a - // second org (forking the instance) the first boot after EXECUTOR_ORG_SLUG - // changes. A changed slug is a rename of the one org, applied here. - // oxlint-disable-next-line executor/no-double-cast -- boundary: the SELECT columns are the schema contract for the Better Auth `organization` row read off the libSQL client - const existingOrg = ( - await client.execute({ - sql: "SELECT id, name, slug FROM organization ORDER BY createdAt ASC LIMIT 1", - args: [], - }) - ).rows[0] as unknown as { id: string; name: string; slug: string } | undefined; - if (existingOrg) { - if (existingOrg.slug !== config.orgSlug) { - await client.execute({ - sql: "UPDATE organization SET slug = ? WHERE id = ?", - args: [config.orgSlug, existingOrg.id], - }); - } - return { organizationId: existingOrg.id, organizationName: existingOrg.name }; - } - - // Headless bootstrap: when BOTH admin email and password are set, pre-create - // that admin as the org owner (CI / infra-as-code). Otherwise fall through to - // the turnkey path so the first browser visitor claims the instance. - if (config.bootstrapAdminEmail && config.bootstrapAdminPassword) { - // oxlint-disable-next-line executor/no-double-cast -- boundary: the SELECT column is the schema contract for the Better Auth `user` row read off the libSQL client - const existingUser = ( - await client.execute({ - sql: "SELECT id FROM user WHERE email = ?", - args: [config.bootstrapAdminEmail], - }) - ).rows[0] as unknown as { id: string } | undefined; - let adminId = existingUser?.id; - if (!adminId) { - const created = await auth.api.createUser({ - body: { - email: config.bootstrapAdminEmail, - password: config.bootstrapAdminPassword, - name: config.bootstrapAdminName, - role: "admin", - }, - }); - adminId = created.user.id; - } - // Pass userId so the org is created with no session and the admin becomes - // its owner (creates the membership row). - const org = await auth.api.createOrganization({ - body: { name: config.organizationName, slug: config.orgSlug, userId: adminId }, - }); - if (!org) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: org creation must succeed for a usable instance - throw new Error("Failed to create the bootstrap organization"); - } - return { organizationId: org.id, organizationName: config.organizationName }; - } - - // Turnkey first-run: create the single organization with NO members. The - // first person to open the app signs up ungated and becomes the owner (the - // signup gate enforces this — an org with zero members is unclaimed). - const organizationId = randomBytes(16).toString("hex"); - await client.execute({ - sql: "INSERT INTO organization (id, name, slug, createdAt) VALUES (?, ?, ?, ?)", - args: [organizationId, config.organizationName, config.orgSlug, new Date().toISOString()], - }); - return { organizationId, organizationName: config.organizationName }; -}; diff --git a/apps/host-selfhost/src/mcp/index.ts b/apps/host-selfhost/src/mcp/index.ts index 5992a6193c..6d52e7b85d 100644 --- a/apps/host-selfhost/src/mcp/index.ts +++ b/apps/host-selfhost/src/mcp/index.ts @@ -1,6 +1,6 @@ import { Effect, Layer } from "effect"; -import { IdentityProvider } from "@executor-js/api/server"; +import { IdentityProvider, betterAuthMcpAuth } from "@executor-js/api/server"; import type { McpAuthProvider, McpErrorReporter, @@ -11,14 +11,13 @@ import type { import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; import type { SelfHostDbHandle } from "../db/self-host-db"; import type { SelfHostConfig } from "../config"; -import { selfHostMcpAuth } from "./auth"; import { makeSelfHostMcpSessionStore, selfHostMcpReporter, selfHostMcpSessions, } from "./session-store"; -export { selfHostMcpAuth } from "./auth"; +export { betterAuthMcpAuth as selfHostMcpAuth }; export { makeSelfHostMcpSessionStore, selfHostMcpReporter, @@ -85,12 +84,12 @@ const principalFromSession = ( betterAuth: BetterAuthHandle, ): Principal => ({ accountId: resolved.user.id, - organizationId: resolved.session.activeOrganizationId ?? betterAuth.organizationId, + organizationId: (resolved.session as any).activeOrganizationId ?? betterAuth.organizationId, organizationName: betterAuth.organizationName, email: resolved.user.email, name: resolved.user.name ?? null, avatarUrl: resolved.user.image ?? null, - roles: parseRoles(resolved.user.role ?? null), + roles: parseRoles((resolved.user as any).role ?? null), }); /** @@ -147,7 +146,7 @@ export const makeSelfHostMcpSeams = ( config.webBaseUrl, config.mcpSessionIdleTtlMs, ); - const auth: Layer.Layer = selfHostMcpAuth.pipe( + const auth: Layer.Layer = betterAuthMcpAuth.pipe( Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), ); return { diff --git a/apps/host-selfhost/src/testing/mint-invite.ts b/apps/host-selfhost/src/testing/mint-invite.ts index 2dc90c73ee..282c6aa04d 100644 --- a/apps/host-selfhost/src/testing/mint-invite.ts +++ b/apps/host-selfhost/src/testing/mint-invite.ts @@ -2,8 +2,7 @@ import { Effect, Layer } from "effect"; import { HttpApiClient } from "effect/unstable/httpapi"; import { FetchHttpClient } from "effect/unstable/http"; -import { AdminHttpApi } from "../admin/api"; -import { type InviteRole } from "../auth/invites"; +import { AdminHttpApi, type InviteRole } from "@executor-js/api/server"; // Test helper: mint an invite code through the TYPED admin HttpApi client, the // same surface the web app calls — no raw request building, no direct DB poke. diff --git a/apps/host-selfhost/web/admin-client.tsx b/apps/host-selfhost/web/admin-client.tsx index 82141d8e15..2941c5fd90 100644 --- a/apps/host-selfhost/web/admin-client.tsx +++ b/apps/host-selfhost/web/admin-client.tsx @@ -2,14 +2,13 @@ import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import * as Effect from "effect/Effect"; +import { AdminHttpApi } from "@executor-js/api"; import { reportApiClientInfrastructureCause } from "@executor-js/react/api/client"; import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader, } from "@executor-js/react/api/server-connection"; -import { AdminHttpApi } from "../src/admin/api"; - // --------------------------------------------------------------------------- // Self-host admin atom client — the invite-code surface (/api/admin/*). // diff --git a/apps/host-selfhost/web/login.tsx b/apps/host-selfhost/web/login.tsx index 94ee3411b8..ab1b847e49 100644 --- a/apps/host-selfhost/web/login.tsx +++ b/apps/host-selfhost/web/login.tsx @@ -4,9 +4,9 @@ import { Button } from "@executor-js/react/components/button"; import { Input } from "@executor-js/react/components/input"; import { Label } from "@executor-js/react/components/label"; +import { postLoginTarget } from "@executor-js/api"; import { authClient } from "./auth-client"; import { AuthLayout } from "./auth-layout"; -import { postLoginTarget } from "../src/auth/return-to"; // Self-host login: email + password sign-in via Better Auth. On success we // reload so the shared AuthProvider re-reads /account/me and the AuthGate swaps diff --git a/apps/host-selfhost/web/setup-status.ts b/apps/host-selfhost/web/setup-status.ts index 03576ec8b2..7c43f31560 100644 --- a/apps/host-selfhost/web/setup-status.ts +++ b/apps/host-selfhost/web/setup-status.ts @@ -8,7 +8,7 @@ const retryDelaysMs = [250, 500, 1_000] as const; const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); export class SetupStatusError extends Error { - constructor() { + constructor(public readonly status?: number) { super("Unable to check setup status"); this.name = "SetupStatusError"; } @@ -27,6 +27,10 @@ export const fetchNeedsSetup = async (): Promise => { )) as { needsSetup?: boolean }; return data.needsSetup === true; } + if (response && response.status === 404) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: setup status absent (Access mode) rejects immediately + throw new SetupStatusError(404); + } if (attempt < retryDelaysMs.length - 1) await sleep(retryDelaysMs[attempt]); } // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: pre-Effect setup-status fetch rejects so the auth gate can surface retry failure diff --git a/bun.lock b/bun.lock index 31564f2592..2bd18675da 100644 --- a/bun.lock +++ b/bun.lock @@ -193,6 +193,7 @@ "@jitl/quickjs-wasmfile-release-sync": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-router": "catalog:", + "better-auth": "^1.6.11", "drizzle-orm": "catalog:", "effect": "catalog:", "jose": "^5.9.6", @@ -481,9 +482,11 @@ "name": "@executor-js/api", "version": "1.4.65", "dependencies": { + "@better-auth/api-key": "^1.6.11", "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/sdk": "workspace:*", + "better-auth": "^1.6.11", "effect": "catalog:", }, "devDependencies": { diff --git a/e2e/scenarios/provider-plugins-ui.test.ts b/e2e/scenarios/provider-plugins-ui.test.ts index 81ba31e2c2..f33ce95b7d 100644 --- a/e2e/scenarios/provider-plugins-ui.test.ts +++ b/e2e/scenarios/provider-plugins-ui.test.ts @@ -54,7 +54,9 @@ scenario( { waitUntil: "domcontentloaded" }, ); await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); - await expect.poll(() => page.locator("textarea").inputValue()).toContain("gmail"); + await expect + .poll(() => page.locator("textarea").inputValue(), { timeout: 15_000 }) + .toContain("gmail"); }); await step("A Microsoft service preset opens the OpenAPI add flow", async () => { @@ -64,7 +66,7 @@ scenario( ); await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); await expect - .poll(() => page.locator("textarea").inputValue()) + .poll(() => page.locator("textarea").inputValue(), { timeout: 15_000 }) .toContain("graph-slices/files.yaml"); }); }); diff --git a/packages/core/api/package.json b/packages/core/api/package.json index a85e340d10..10f32cc04a 100644 --- a/packages/core/api/package.json +++ b/packages/core/api/package.json @@ -14,9 +14,11 @@ "test": "vitest run" }, "dependencies": { + "@better-auth/api-key": "^1.6.11", "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/sdk": "workspace:*", + "better-auth": "^1.6.11", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/core/api/src/better-auth/account-provider.ts b/packages/core/api/src/better-auth/account-provider.ts new file mode 100644 index 0000000000..ac33b949cb --- /dev/null +++ b/packages/core/api/src/better-auth/account-provider.ts @@ -0,0 +1,175 @@ +import { Effect, Layer } from "effect"; + +import { AccountProvider, type AccountHeaders } from "../account/service"; +import { AccountError, AccountUnauthorized } from "../account/api"; +import { BetterAuth } from "./identity"; + +const toHeaders = (headers: AccountHeaders): Headers => new Headers(headers); + +const isoOrNull = (value: Date | string | null | undefined): string | null => { + if (!value) return null; + return value instanceof Date ? value.toISOString() : value; +}; + +const iso = (value: Date | string | null | undefined): string => isoOrNull(value) ?? ""; + +const masked = (start: string | null | undefined): string => (start ? `${start}…` : "••••••••"); + +const orgRole = (slug: string | undefined): "owner" | "admin" | "member" => + slug === "owner" ? "owner" : slug === "admin" ? "admin" : "member"; + +export const betterAuthAccountProvider: Layer.Layer = + Layer.effect(AccountProvider)( + Effect.gen(function* () { + const { auth, organizationId, organizationName, organizationSlug } = yield* BetterAuth; + + const getSession = (headers: AccountHeaders) => + Effect.tryPromise({ + try: () => auth.api.getSession({ headers: toHeaders(headers) }), + catch: () => new AccountError({ message: "Failed to resolve session" }), + }).pipe(Effect.orElseSucceed(() => null)); + + const call = (message: string, run: () => Promise) => + Effect.tryPromise({ try: run, catch: () => new AccountError({ message }) }); + + return AccountProvider.of({ + me: (headers) => + Effect.gen(function* () { + const resolved = yield* getSession(headers); + if (!resolved) return yield* new AccountUnauthorized(); + return { + user: { + id: resolved.user.id, + email: resolved.user.email, + name: resolved.user.name ?? null, + avatarUrl: resolved.user.image ?? null, + }, + organization: { + id: (resolved.session as any).activeOrganizationId ?? organizationId, + name: organizationName, + slug: organizationSlug, + }, + }; + }), + + listApiKeys: (headers) => + call("Failed to list API keys", () => + auth.api.listApiKeys({ headers: toHeaders(headers) }), + ).pipe( + Effect.map((result) => ({ + apiKeys: result.apiKeys.map((key) => ({ + id: key.id, + name: key.name ?? "API key", + obfuscatedValue: masked(key.start), + createdAt: iso(key.createdAt), + updatedAt: iso(key.updatedAt), + lastUsedAt: isoOrNull(key.lastRequest), + })), + })), + ), + + createApiKey: (headers, name) => + call("Failed to create API key", () => + auth.api.createApiKey({ body: { name }, headers: toHeaders(headers) }), + ).pipe( + Effect.map((key) => ({ + id: key.id, + name: key.name ?? name, + obfuscatedValue: masked(key.start), + createdAt: iso(key.createdAt), + updatedAt: iso(key.updatedAt), + lastUsedAt: isoOrNull(key.lastRequest), + value: key.key, + })), + ), + + revokeApiKey: (headers, apiKeyId) => + call("Failed to revoke API key", () => + auth.api.deleteApiKey({ body: { keyId: apiKeyId }, headers: toHeaders(headers) }), + ).pipe(Effect.as({ success: true })), + + listOrgApiKeys: () => Effect.succeed({ apiKeys: [] }), + + createOrgApiKey: () => + Effect.fail( + new AccountError({ + message: "Organization API keys are not available on self-hosted instances", + }), + ), + + revokeOrgApiKey: () => + Effect.fail( + new AccountError({ + message: "Organization API keys are not available on self-hosted instances", + }), + ), + + listMembers: (headers) => + Effect.gen(function* () { + const resolved = yield* getSession(headers); + const currentUserId = resolved?.user.id; + const result = yield* call("Failed to list members", () => + auth.api.listMembers({ headers: toHeaders(headers) }), + ).pipe( + Effect.catchTag("AccountError", () => Effect.succeed({ members: [], total: 0 })), + ); + const members = result.members.map((member) => ({ + id: member.id, + userId: member.userId, + email: member.user?.email ?? "", + name: member.user?.name ?? null, + avatarUrl: member.user?.image ?? null, + role: member.role, + status: "active", + lastActiveAt: null, + isCurrentUser: member.userId === currentUserId, + })); + return { + members, + seats: { used: members.length, granted: members.length, unlimited: true }, + }; + }), + + listRoles: () => + Effect.succeed({ + roles: [ + { slug: "owner", name: "Owner" }, + { slug: "admin", name: "Admin" }, + { slug: "member", name: "Member" }, + ], + }), + + inviteMember: (headers, body) => + call("Failed to invite member", () => + auth.api.createInvitation({ + body: { email: body.email, role: orgRole(body.roleSlug) }, + headers: toHeaders(headers), + }), + ).pipe(Effect.map((invite) => ({ id: invite.id, email: invite.email }))), + + removeMember: (headers, membershipId) => + call("Failed to remove member", () => + auth.api.removeMember({ + body: { memberIdOrEmail: membershipId }, + headers: toHeaders(headers), + }), + ).pipe(Effect.as({ success: true })), + + updateMemberRole: (headers, membershipId, roleSlug) => + call("Failed to update member role", () => + auth.api.updateMemberRole({ + body: { memberId: membershipId, role: roleSlug }, + headers: toHeaders(headers), + }), + ).pipe(Effect.as({ success: true })), + + updateOrgName: (headers, name) => + call("Failed to update organization name", () => + auth.api.updateOrganization({ + body: { data: { name }, organizationId }, + headers: toHeaders(headers), + }), + ).pipe(Effect.as({ name })), + }); + }), + ); diff --git a/apps/host-selfhost/src/admin/api.ts b/packages/core/api/src/better-auth/admin-api.ts similarity index 63% rename from apps/host-selfhost/src/admin/api.ts rename to packages/core/api/src/better-auth/admin-api.ts index 449ad2e98d..3b636e0a84 100644 --- a/apps/host-selfhost/src/admin/api.ts +++ b/packages/core/api/src/better-auth/admin-api.ts @@ -1,19 +1,6 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -// --------------------------------------------------------------------------- -// Self-host admin API — the invite-code surface (app-local, self-host only). -// -// Member/role management is the shared, provider-neutral /account/* surface -// (served by the Better Auth AccountProvider, rendered by the shared org page). -// Invite CODES are self-host's join mechanism and have no neutral equivalent — -// cloud joins via WorkOS — so they live in this app-local group, served -// alongside the core API under /api and consumed by a self-host atom client. -// -// Browser-safe: schemas + the HttpApi value only (no server imports), so the -// web client can build a typed AtomHttpApi from it. -// --------------------------------------------------------------------------- - export class AdminError extends Schema.TaggedErrorClass()( "AdminError", { message: Schema.String }, @@ -59,9 +46,6 @@ export const SuccessResponse = Schema.Struct({ const InviteParams = { inviteId: Schema.String }; -// Paths are `/admin/*` (no `/api`): the server mounts this on the same -// `/api`-prefixed router as the core API, and the client prepends the `/api` -// base — symmetric with the account API. export const AdminApi = HttpApiGroup.make("admin") .add( HttpApiEndpoint.get("listInvites", "/admin/invites", { @@ -84,9 +68,4 @@ export const AdminApi = HttpApiGroup.make("admin") }), ); -/** - * Standalone HttpApi wrapping the admin group — used to build the self-host - * `AdminApiClient` atoms in the web app, and mounted server-side as an - * extension route layer. - */ export const AdminHttpApi = HttpApi.make("executor-self-host-admin").add(AdminApi); diff --git a/apps/host-selfhost/src/admin/handlers.ts b/packages/core/api/src/better-auth/admin.ts similarity index 55% rename from apps/host-selfhost/src/admin/handlers.ts rename to packages/core/api/src/better-auth/admin.ts index b41f7a7fd0..4d25b83c96 100644 --- a/apps/host-selfhost/src/admin/handlers.ts +++ b/packages/core/api/src/better-auth/admin.ts @@ -7,37 +7,56 @@ import { AdminForbidden, AdminHttpApi, AdminUnauthorized, - type InviteCode as InviteCodeSchema, -} from "./api"; -import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; -import { requireInstanceAdmin } from "./require-admin"; -import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; + InviteCode as InviteCodeSchema, +} from "./admin-api"; +import { BetterAuth, type BetterAuthHandle } from "./identity"; import { createInviteCode, listInviteCodes, revokeInviteCode, type InviteCodeRow, type InviteRole, -} from "../auth/invites"; +} from "./invites"; -// --------------------------------------------------------------------------- -// Handlers for the self-host admin (invite-code) API. Every Promise-returning -// boundary (Better Auth, the libSQL store) is wrapped in Effect.tryPromise with -// a typed failure — no raw try/catch, no Promise.catch. Each route is gated by -// the SHARED `requireInstanceAdmin`: the caller must be an owner/admin member -// of THIS INSTANCE'S organization, named explicitly rather than taken from the -// caller's session (see require-admin.ts for the escalation that rule refuses — -// this plane mints invite codes, so a bypass here is durable admin). -// --------------------------------------------------------------------------- +export type AdminGateDenial = "unauthorized" | "forbidden"; + +export interface InstanceAdmin { + readonly userId: string; + readonly role: string; +} + +const isPrivileged = (role: string): boolean => + role + .split(",") + .map((part) => part.trim()) + .some((part) => part === "owner" || part === "admin"); + +export const requireInstanceAdmin = ( + headers: Headers, +): Effect.Effect => + Effect.gen(function* () { + const { auth, organizationId } = yield* BetterAuth; + + const session = yield* Effect.tryPromise(() => auth.api.getSession({ headers })).pipe( + Effect.orElseSucceed(() => null), + ); + if (!session) return yield* Effect.fail("unauthorized"); + + const resolved = yield* Effect.tryPromise(() => + auth.api.getActiveMemberRole({ headers, query: { organizationId } }), + ).pipe(Effect.orElseSucceed(() => null)); + if (!resolved || !isPrivileged(resolved.role)) { + return yield* Effect.fail("forbidden"); + } + + return { userId: session.user.id, role: resolved.role }; + }); const requestHeaders = Effect.map( HttpServerRequest.HttpServerRequest.asEffect(), (request): Headers => new Headers({ ...request.headers }), ); -// Resolve + authorize the caller, rendering the shared gate's denial in THIS -// plane's error vocabulary (the HttpApi group declares these, not the users -// plane's). const requireAdmin = (headers: Headers) => requireInstanceAdmin(headers).pipe( Effect.mapError((denial) => @@ -48,7 +67,6 @@ const requireAdmin = (headers: Headers) => const narrowRole = (role: string | undefined): InviteRole => role === "admin" ? "admin" : "member"; -// Drop the internal audit columns (createdBy/usedBy) for the wire shape. const toWire = (row: InviteCodeRow): typeof InviteCodeSchema.Type => ({ id: row.id, code: row.code, @@ -65,9 +83,9 @@ export const AdminHandlers = HttpApiBuilder.group(AdminHttpApi, "admin", (handle .handle("listInvites", () => Effect.gen(function* () { yield* requireAdmin(yield* requestHeaders); - const { client } = yield* SelfHostDb; + const { dbClient } = yield* BetterAuth; const rows = yield* Effect.tryPromise({ - try: () => listInviteCodes(client), + try: () => listInviteCodes(dbClient), catch: () => new AdminError({ message: "Failed to list invites" }), }); return { invites: rows.map(toWire) }; @@ -76,13 +94,13 @@ export const AdminHandlers = HttpApiBuilder.group(AdminHttpApi, "admin", (handle .handle("createInvite", ({ payload }) => Effect.gen(function* () { const member = yield* requireAdmin(yield* requestHeaders); - const { client } = yield* SelfHostDb; + const { dbClient } = yield* BetterAuth; const days = payload.expiresInDays ?? null; const expiresAt = days && days > 0 ? new Date(Date.now() + days * 86_400_000).toISOString() : null; const row = yield* Effect.tryPromise({ try: () => - createInviteCode(client, { + createInviteCode(dbClient, { createdBy: member.userId, role: narrowRole(payload.role), label: payload.label?.trim() ? payload.label.trim() : null, @@ -96,9 +114,9 @@ export const AdminHandlers = HttpApiBuilder.group(AdminHttpApi, "admin", (handle .handle("revokeInvite", ({ params }) => Effect.gen(function* () { yield* requireAdmin(yield* requestHeaders); - const { client } = yield* SelfHostDb; + const { dbClient } = yield* BetterAuth; yield* Effect.tryPromise({ - try: () => revokeInviteCode(client, params.inviteId), + try: () => revokeInviteCode(dbClient, params.inviteId), catch: () => new AdminError({ message: "Failed to revoke invite" }), }); return { success: true }; @@ -106,34 +124,21 @@ export const AdminHandlers = HttpApiBuilder.group(AdminHttpApi, "admin", (handle ), ); -export interface SelfHostAdminApiDeps { +export interface BetterAuthAdminApiDeps { readonly betterAuth: BetterAuthHandle; - readonly db: SelfHostDbHandle; readonly mountPrefix: `/${string}`; } -/** - * The mountable extension route layer: registers the admin routes on the - * `mountPrefix`-prefixed view of the ambient router (so `/admin/*` is served at - * `/api/admin/*`). Better Auth + the DB handle are app singletons, provided via - * `provideRequest` so the handlers' per-request requirement markers are cleared - * (a plain `Layer.provide` leaves them on the layer's requirement channel). The - * residual platform/router requirements are cleared by the serve binding — the - * loose `RouteExtension` channel the app's `extensions.routes` accepts. - */ -export const makeSelfHostAdminApiLayer = ({ +export const makeBetterAuthAdminApiLayer = ({ betterAuth, - db, mountPrefix, -}: SelfHostAdminApiDeps) => { +}: BetterAuthAdminApiDeps) => { const prefixedRouter = Layer.effect(HttpRouter.HttpRouter)( Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed(mountPrefix)), ); return HttpApiBuilder.layer(AdminHttpApi).pipe( Layer.provide(AdminHandlers), Layer.provide(prefixedRouter), - HttpRouter.provideRequest( - Layer.mergeAll(Layer.succeed(BetterAuth)(betterAuth), Layer.succeed(SelfHostDb)(db)), - ), + HttpRouter.provideRequest(Layer.succeed(BetterAuth)(betterAuth)), ); }; diff --git a/packages/core/api/src/better-auth/consent.ts b/packages/core/api/src/better-auth/consent.ts new file mode 100644 index 0000000000..dbae363ac6 --- /dev/null +++ b/packages/core/api/src/better-auth/consent.ts @@ -0,0 +1,32 @@ +const AUTHORIZE_PATH = "/api/auth/mcp/authorize"; +const CONSENT_PAGE = "/mcp-consent"; + +export const promptWithConsent = (prompt: string | null): string => { + const set = new Set((prompt ?? "").split(/\s+/).filter((value) => value.length > 0)); + set.add("consent"); + return Array.from(set).join(" "); +}; + +export const withForcedMcpConsent = (request: Request): Request => { + if (request.method !== "GET") return request; + const url = new URL(request.url); + if (url.pathname !== AUTHORIZE_PATH) return request; + const prompt = url.searchParams.get("prompt"); + if (prompt && prompt.split(/\s+/).includes("consent")) return request; + url.searchParams.set("prompt", promptWithConsent(prompt)); + return new Request(url, request); +}; + +export const consentRedirectClientId = (location: string | null): string | null => { + if (!location) return null; + const url = new URL(location, "http://host.internal"); + if (url.pathname !== CONSENT_PAGE) return null; + if (url.searchParams.get("client_name")) return null; + return url.searchParams.get("client_id"); +}; + +export const withClientName = (location: string, clientName: string): string => { + const url = new URL(location, "http://host.internal"); + url.searchParams.set("client_name", clientName); + return `${url.pathname}${url.search}`; +}; diff --git a/packages/core/api/src/better-auth/identity.ts b/packages/core/api/src/better-auth/identity.ts new file mode 100644 index 0000000000..6b71778a84 --- /dev/null +++ b/packages/core/api/src/better-auth/identity.ts @@ -0,0 +1,66 @@ +import { Context, Effect, Layer } from "effect"; + +import { IdentityProvider, Unauthorized } from "../server/identity"; +import { type BetterAuthInstance, type BetterAuthDbClient } from "./shared"; + +export interface BetterAuthHandle { + readonly auth: BetterAuthInstance; + readonly organizationId: string; + readonly organizationName: string; + readonly organizationSlug: string; + readonly handler: (request: Request) => Promise; + readonly dbClient: BetterAuthDbClient; +} + +export class BetterAuth extends Context.Service()( + "@executor-js/api/BetterAuth", +) {} + +const bearerToken = (headers: Headers): string | undefined => { + const authorization = headers.get("authorization"); + if (!authorization) return undefined; + return authorization.toLowerCase().startsWith("bearer ") + ? authorization.slice(7).trim() || undefined + : undefined; +}; + +export const betterAuthIdentityLayer: Layer.Layer = + Layer.effect(IdentityProvider)( + Effect.gen(function* () { + const { auth, organizationId, organizationName, organizationSlug } = yield* BetterAuth; + return IdentityProvider.of({ + authenticate: (request) => + Effect.gen(function* () { + let resolved = yield* Effect.promise(() => + auth.api.getSession({ headers: request.headers }), + ); + if (!resolved) { + const token = bearerToken(request.headers); + if (token) { + resolved = yield* Effect.tryPromise({ + try: () => auth.api.getSession({ headers: { "x-api-key": token } }), + catch: () => "api-key session lookup failed", + }).pipe(Effect.orElseSucceed(() => null)); + } + } + if (!resolved) return yield* new Unauthorized(); + const resolvedOrganizationId = + (resolved.session as any).activeOrganizationId ?? organizationId; + return { + kind: "member" as const, + accountId: resolved.user.id, + organizationId: resolvedOrganizationId, + organizationName, + organizationSlug, + email: resolved.user.email, + name: resolved.user.name ?? null, + avatarUrl: resolved.user.image ?? null, + roles: (((resolved.user as any).role ?? "user") as string) + .split(",") + .map((role) => role.trim()) + .filter((role) => role.length > 0), + }; + }), + }); + }), + ); diff --git a/apps/host-selfhost/src/auth/invalid-origin-help.ts b/packages/core/api/src/better-auth/invalid-origin.ts similarity index 68% rename from apps/host-selfhost/src/auth/invalid-origin-help.ts rename to packages/core/api/src/better-auth/invalid-origin.ts index de0df2f065..496afa03e2 100644 --- a/apps/host-selfhost/src/auth/invalid-origin-help.ts +++ b/packages/core/api/src/better-auth/invalid-origin.ts @@ -1,13 +1,5 @@ -// Better Auth rejects any request whose Origin isn't the configured webBaseUrl -// with a bare 403 "Invalid origin". On a self-host deploy that almost always -// means the instance's public URL wasn't detected or configured — so we replace -// that dead-end with a message naming the exact fix (the URL to set). The error -// `code` is preserved so programmatic clients are unaffected; only the -// human-facing `message` changes. - const INVALID_ORIGIN = /invalid origin/i; -/** The origin a request came from: the browser `Origin`, else the proxy host. */ export const originOf = (request: Request): string | null => { const explicit = request.headers.get("origin"); if (explicit) return explicit; @@ -17,7 +9,6 @@ export const originOf = (request: Request): string | null => { return `${proto}://${host}`; }; -/** Actionable replacement for "Invalid origin". */ export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string => requestOrigin ? `This Executor instance is configured for ${webBaseUrl}, but you're connecting from ${requestOrigin}. ` + @@ -27,11 +18,6 @@ export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: stri : `This Executor instance is configured for ${webBaseUrl}. If you're reaching it at a different address, ` + `set EXECUTOR_WEB_BASE_URL to that canonical address or add the alias to EXECUTOR_TRUSTED_ORIGINS, then restart the server.`; -/** - * If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy - * with the same status + `code` but an actionable message. Otherwise null — the - * caller passes the original response through untouched. - */ export const rewriteInvalidOrigin = async ( request: Request, response: Response, diff --git a/packages/core/api/src/better-auth/invites.ts b/packages/core/api/src/better-auth/invites.ts new file mode 100644 index 0000000000..773e20468d --- /dev/null +++ b/packages/core/api/src/better-auth/invites.ts @@ -0,0 +1,127 @@ +import { randomBytes } from "node:crypto"; +import type { BetterAuthDbClient } from "./shared"; + +export type InviteRole = "admin" | "member"; + +export interface InviteCodeRow { + readonly id: string; + readonly code: string; + readonly role: InviteRole; + readonly label: string | null; + readonly createdBy: string; + readonly createdAt: string; + readonly expiresAt: string | null; + readonly usedBy: string | null; + readonly usedByEmail: string | null; + readonly usedAt: string | null; +} + +const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +const generateCode = (): string => { + const bytes = randomBytes(12); + const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]); + return [chars.slice(0, 4), chars.slice(4, 8), chars.slice(8, 12)] + .map((g) => g.join("")) + .join("-"); +}; + +const toRow = (raw: any): InviteCodeRow => ({ + id: String(raw.id), + code: String(raw.code), + role: raw.role === "admin" ? "admin" : "member", + label: raw.label == null ? null : String(raw.label), + createdBy: String(raw.created_by), + createdAt: String(raw.created_at), + expiresAt: raw.expires_at == null ? null : String(raw.expires_at), + usedBy: raw.used_by == null ? null : String(raw.used_by), + usedByEmail: raw.used_by_email == null ? null : String(raw.used_by_email), + usedAt: raw.used_at == null ? null : String(raw.used_at), +}); + +export const ensureInviteCodeTable = async (client: BetterAuthDbClient): Promise => { + await client.execute(` + CREATE TABLE IF NOT EXISTS invite_code ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + role TEXT NOT NULL DEFAULT 'member', + label TEXT, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + used_by TEXT, + used_by_email TEXT, + used_at TEXT + ) + `); +}; + +export interface CreateInviteCodeInput { + readonly createdBy: string; + readonly role?: InviteRole; + readonly label?: string | null; + readonly expiresAt?: string | null; +} + +export const createInviteCode = async ( + client: BetterAuthDbClient, + input: CreateInviteCodeInput, +): Promise => { + const row: InviteCodeRow = { + id: randomBytes(16).toString("hex"), + code: generateCode(), + role: input.role ?? "member", + label: input.label ?? null, + createdBy: input.createdBy, + createdAt: new Date().toISOString(), + expiresAt: input.expiresAt ?? null, + usedBy: null, + usedByEmail: null, + usedAt: null, + }; + await client.execute( + `INSERT INTO invite_code (id, code, role, label, created_by, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [row.id, row.code, row.role, row.label, row.createdBy, row.createdAt, row.expiresAt], + ); + return row; +}; + +export const listInviteCodes = async ( + client: BetterAuthDbClient, +): Promise => { + const result = await client.execute("SELECT * FROM invite_code ORDER BY created_at DESC"); + return result.rows.map(toRow); +}; + +export const revokeInviteCode = async (client: BetterAuthDbClient, id: string): Promise => { + await client.execute("DELETE FROM invite_code WHERE id = ? AND used_at IS NULL", [id]); +}; + +export const findRedeemableCode = async ( + client: BetterAuthDbClient, + code: string, +): Promise => { + const result = await client.execute( + "SELECT * FROM invite_code WHERE code = ? AND used_at IS NULL", + [code.trim().toUpperCase()], + ); + const raw = result.rows[0]; + if (!raw) return null; + const row = toRow(raw); + if (row.expiresAt && Date.parse(row.expiresAt) < Date.now()) return null; + return row; +}; + +export const consumeInviteCode = async ( + client: BetterAuthDbClient, + code: string, + by: { usedBy: string; usedByEmail: string }, +): Promise => { + const result = await client.execute( + `UPDATE invite_code SET used_by = ?, used_by_email = ?, used_at = ? + WHERE code = ? AND used_at IS NULL`, + [by.usedBy, by.usedByEmail, new Date().toISOString(), code.trim().toUpperCase()], + ); + return (result.rowsAffected ?? 0) > 0; +}; diff --git a/apps/host-selfhost/src/mcp/auth.ts b/packages/core/api/src/better-auth/mcp-auth.ts similarity index 57% rename from apps/host-selfhost/src/mcp/auth.ts rename to packages/core/api/src/better-auth/mcp-auth.ts index abd07ba384..e31aef95e6 100644 --- a/apps/host-selfhost/src/mcp/auth.ts +++ b/packages/core/api/src/better-auth/mcp-auth.ts @@ -1,7 +1,7 @@ import { Effect, Layer } from "effect"; import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from "better-auth/plugins"; -import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server"; +import { IdentityProvider, isPlatformPrincipal } from "../server/identity"; import { authenticated, McpAuthProvider, @@ -11,46 +11,47 @@ import { type Principal, } from "@executor-js/host-mcp"; -import { BetterAuth } from "../auth/better-auth"; -import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org-path"; +import { BetterAuth } from "./identity"; // --------------------------------------------------------------------------- -// Self-host McpAuthProvider adapter, backed by Better Auth's mcp() plugin. -// -// Responsibilities the envelope needs: -// -// 1. DECLARE the discovery routes it owns. MCP clients probe the true origin -// ROOT, but Better Auth's handler only mounts the well-known docs under -// /api/auth/.well-known/*, so we re-emit BOTH docs at the bare origin root -// via the plugin's helpers. The envelope registers a GET for each declared -// path. -// -// 2. `resourceMetadataUrl(request)` — the absolute `resource_metadata` URL the -// 401 challenge points at: the bare origin-root protected-resource doc -// (`/.well-known/oauth-protected-resource`) UNLESS the request came -// in org-scoped (`//mcp…`), in which case both this and the PRM -// document's `resource` field must echo the org-scoped form back — the MCP -// SDK client enforces that the advertised `resource` is a same-origin -// path-prefix of the URL it actually dialed (RFC 9728). The strip -// middleware (../serve.ts, ../../vite.config.ts) rewrites org-scoped -// requests to the bare route before they reach here, so the org prefix is -// recovered from MCP_ORIGINAL_PATH_HEADER, not the live request path. -// -// 3. `authenticate(request)` resolving an MCP principal as a typed AuthOutcome, -// trying two credential shapes in order: -// a. The mcp() OAuth opaque bearer (getMcpSession) — ONLY when an -// `Authorization: Bearer …` header is present (avoids a getMcpSession -// round-trip on every cookie request). getMcpSession does NOT validate -// `accessTokenExpiresAt`, so we ENFORCE expiry ourselves before -// accepting it, then enrich the bare {userId} into a full principal. -// b. The existing IdentityProvider path (session cookie / bearer-session / -// x-api-key) — preserves API-key Bearer access for the API + MCP. -// Anything that fails or yields nothing collapses to `Unauthorized`; the -// envelope renders the 401 + challenge. Self-host always has an org, so it -// never returns Forbidden/Unavailable. -// -// The OAuth endpoints themselves (/api/auth/mcp/{register,authorize,token}) -// stay on the Better Auth handler mounted at /api/auth — NOT in this seam. +// Org-path segment stripping and recovery helpers. +// Re-homed and bundled here so the shared Better Auth MCP auth provider is +// self-contained. +// --------------------------------------------------------------------------- + +const PRM_PREFIX = "/.well-known/oauth-protected-resource"; +export const MCP_ORIGINAL_PATH_HEADER = "x-executor-mcp-original-path"; + +export const stripMcpOrgSegment = (pathname: string): string | null => { + if (pathname.startsWith(`${PRM_PREFIX}/`)) { + const rest = pathname + .slice(PRM_PREFIX.length + 1) + .split("/") + .filter((segment) => segment.length > 0); + if (rest.length === 2 && rest[1] === "mcp") return PRM_PREFIX; + if (rest.length === 4 && rest[1] === "mcp" && rest[2] === "toolkits") { + return `${PRM_PREFIX}/mcp/toolkits/${rest[3]}`; + } + return null; + } + const segments = pathname.split("/").filter((segment) => segment.length > 0); + if (segments.length === 2 && segments[1] === "mcp") return "/mcp"; + if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") { + return `/mcp/toolkits/${segments[3]}`; + } + return null; +}; + +export const isRecognizedMcpOrgPath = (pathname: string): boolean => + stripMcpOrgSegment(pathname) !== null; + +export const mcpResourcePathFromOriginalPath = (pathname: string): string | null => { + if (!isRecognizedMcpOrgPath(pathname)) return null; + return pathname.startsWith(`${PRM_PREFIX}/`) ? pathname.slice(PRM_PREFIX.length) : pathname; +}; + +// --------------------------------------------------------------------------- +// Better Auth MCP Auth Provider Seam implementation. // --------------------------------------------------------------------------- const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; @@ -64,10 +65,6 @@ const parseRoles = (role: string | null | undefined): ReadonlyArray => .map((r) => r.trim()) .filter((r) => r.length > 0); -/** - * The admin plugin's `role` column is populated at runtime but isn't part of - * Better Auth's static base-user type, so read it through a single typed view. - */ const userRole = (user: object): string | null => { const role = (user as { readonly role?: unknown }).role; return typeof role === "string" ? role : null; @@ -76,23 +73,11 @@ const userRole = (user: object): string | null => { const hasBearer = (request: Request): boolean => (request.headers.get("authorization") ?? "").startsWith("Bearer "); -/** - * The org-scoped pathname the client actually dialed, recovered from the strip - * middleware's header (see ./org-path.ts). `null` for a request that was never - * org-scoped (already-bare `/mcp…`), OR whose header value isn't one the - * middleware would itself have set — never trust an arbitrary client-supplied - * string here, even though the middleware already strips a spoofed header at - * its own boundary; this is a second, cheap check against reflecting garbage - * into a security-relevant URL. - */ const originalOrgScopedPathFor = (request: Request): string | null => { const header = request.headers.get(MCP_ORIGINAL_PATH_HEADER); return header ? mcpResourcePathFromOriginalPath(header) : null; }; -/** The pathname to derive the toolkit slug / resource path from: the - * org-scoped original when the client dialed org-scoped, else the request's - * own (already-bare) path. */ const effectivePathnameFor = (request: Request): string => originalOrgScopedPathFor(request) ?? new URL(request.url).pathname; @@ -111,13 +96,6 @@ const mcpResourcePathFor = (request: Request): string => { return toolkitSlug ? `/mcp/toolkits/${toolkitSlug}` : "/mcp"; }; -/** - * Absolute protected-resource metadata URL for the 401 challenge. Derive the - * origin from `baseURL` when set; otherwise from the live request so the URL is - * never relative (cloud-drop-in: a self-host behind any host resolves right). - * When the client dialed org-scoped, echo the org-scoped PRM path back (see - * `mcpResourcePathFor`) so the MCP SDK's same-origin resource check passes. - */ const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request): string => { const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin; const orgScoped = originalOrgScopedPathFor(request); @@ -152,7 +130,7 @@ const toolkitProtectedResourceMetadata = ( }); }; -export const selfHostMcpAuth: Layer.Layer = +export const betterAuthMcpAuth: Layer.Layer = Layer.effect( McpAuthProvider, Effect.gen(function* () { @@ -162,13 +140,10 @@ export const selfHostMcpAuth: Layer.Layer resourceMetadataUrlFor(baseURL, request); - // RFC 9728 challenge string carried on the Unauthorized outcome. Same shape - // as the envelope's default; we supply it explicitly to keep the 401's - // `WWW-Authenticate` fully owned by the provider. const challengeFor = (request: Request): string => `Bearer resource_metadata="${resourceMetadataUrl(request)}"`; @@ -197,18 +172,14 @@ export const selfHostMcpAuth: Layer.Layer auth.$context); - /** Enrich a bare OAuth `userId` into the full provider-neutral principal. */ const principalFromUserId = (userId: string): Effect.Effect => Effect.gen(function* () { const user = yield* Effect.promise(() => context.internalAdapter.findUserById(userId)); if (!user) return null; return { accountId: user.id, - // Single-org self-host: OAuth tokens carry no active org, so pin to - // the seeded org (same default as the cookie/api-key path). organizationId, organizationName, organizationSlug, @@ -219,24 +190,16 @@ export const selfHostMcpAuth: Layer.Layer => Effect.gen(function* () { const session = yield* Effect.promise(() => auth.api.getMcpSession({ headers: request.headers }), ); if (!session) return null; - // GOTCHA: getMcpSession does NOT validate accessTokenExpiresAt — an - // expired token still resolves. Reject it here. if (new Date(session.accessTokenExpiresAt).getTime() < Date.now()) return null; return yield* principalFromUserId(session.userId); }).pipe(Effect.orElseSucceed(() => null)); - /** (b) The existing cookie / bearer-session / x-api-key path. The fallback's - * api `Principal` shape is byte-identical to host-mcp's `Principal`. The - * neutral seam can also resolve a platform credential, which self-host's - * identity never produces — narrowed away rather than asserted, so an MCP - * session can never bind to a subject-less credential if that changes. */ const authenticateSession = (request: Request): Effect.Effect => fallback.authenticate(request).pipe( Effect.map((principal) => (isPlatformPrincipal(principal) ? null : principal)), @@ -247,12 +210,6 @@ export const selfHostMcpAuth: Layer.Layer => (hasBearer(request) ? authenticateOAuthBearer(request).pipe( diff --git a/packages/core/api/src/better-auth/redirection.ts b/packages/core/api/src/better-auth/redirection.ts new file mode 100644 index 0000000000..6c2b1ce808 --- /dev/null +++ b/packages/core/api/src/better-auth/redirection.ts @@ -0,0 +1,36 @@ +const pathPart = (path: string): string => path.split(/[?#]/, 1)[0] ?? ""; + +const isOAuthCallbackReturnTo = (path: string): boolean => pathPart(path) === "/api/oauth/callback"; + +export const isSafeReturnTo = (path: string): boolean => + path.startsWith("/") && + !path.startsWith("//") && + (!/^\/api(\/|$)/.test(path) || isOAuthCallbackReturnTo(path)); + +export const safeReturnTo = (path: string | null | undefined): string | null => + path && isSafeReturnTo(path) ? path : null; + +export const loginPath = (returnTo: string): string => + returnTo === "/" ? "/login" : `/login?returnTo=${encodeURIComponent(returnTo)}`; + +const MCP_AUTHORIZE_PATH = "/api/auth/mcp/authorize"; + +export const mcpAuthorizeResumeTarget = (search: string): string | null => { + const params = new URLSearchParams(search); + if (params.get("response_type") !== "code") return null; + if (!params.get("client_id") || !params.get("redirect_uri")) return null; + return `${MCP_AUTHORIZE_PATH}?${params.toString()}`; +}; + +const LOGIN_PATH = "/login"; + +export const postLoginTarget = (location: { + readonly pathname: string; + readonly search: string; +}): string => + mcpAuthorizeResumeTarget(location.search) ?? + safeReturnTo(new URLSearchParams(location.search).get("returnTo")) ?? + (location.pathname === LOGIN_PATH + ? null + : safeReturnTo(`${location.pathname}${location.search}`)) ?? + "/"; diff --git a/packages/core/api/src/better-auth/seed.ts b/packages/core/api/src/better-auth/seed.ts new file mode 100644 index 0000000000..0e1ce30714 --- /dev/null +++ b/packages/core/api/src/better-auth/seed.ts @@ -0,0 +1,66 @@ +import { randomBytes } from "node:crypto"; +import type { BetterAuthInstance, BetterAuthDbClient } from "./shared"; + +export interface SeedConfig { + readonly orgSlug: string; + readonly bootstrapAdminEmail?: string; + readonly bootstrapAdminPassword?: string; + readonly bootstrapAdminName?: string; + readonly organizationName: string; +} + +export const seedOrgAndAdmin = async ( + auth: BetterAuthInstance, + client: BetterAuthDbClient, + config: SeedConfig, +): Promise<{ organizationId: string; organizationName: string }> => { + const result = await client.execute( + "SELECT id, name, slug FROM organization ORDER BY createdAt ASC LIMIT 1", + ); + const existingOrg = result.rows[0] as { id: string; name: string; slug: string } | undefined; + if (existingOrg) { + if (existingOrg.slug !== config.orgSlug) { + await client.execute("UPDATE organization SET slug = ? WHERE id = ?", [ + config.orgSlug, + existingOrg.id, + ]); + } + return { organizationId: existingOrg.id, organizationName: existingOrg.name }; + } + + if (config.bootstrapAdminEmail && config.bootstrapAdminPassword) { + const userResult = await client.execute("SELECT id FROM user WHERE email = ?", [ + config.bootstrapAdminEmail, + ]); + const existingUser = userResult.rows[0] as { id: string } | undefined; + let adminId = existingUser?.id; + if (!adminId) { + const created = await auth.api.createUser({ + body: { + email: config.bootstrapAdminEmail, + password: config.bootstrapAdminPassword, + name: config.bootstrapAdminName ?? "Admin", + role: "admin", + }, + }); + adminId = created.user.id; + } + const org = await auth.api.createOrganization({ + body: { name: config.organizationName, slug: config.orgSlug, userId: adminId }, + }); + if (!org) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: org creation must succeed for a usable instance + throw new Error("Failed to create the bootstrap organization"); + } + return { organizationId: org.id, organizationName: config.organizationName }; + } + + const organizationId = randomBytes(16).toString("hex"); + await client.execute("INSERT INTO organization (id, name, slug, createdAt) VALUES (?, ?, ?, ?)", [ + organizationId, + config.organizationName, + config.orgSlug, + new Date().toISOString(), + ]); + return { organizationId, organizationName: config.organizationName }; +}; diff --git a/packages/core/api/src/better-auth/shared.ts b/packages/core/api/src/better-auth/shared.ts new file mode 100644 index 0000000000..6c0bccc0ef --- /dev/null +++ b/packages/core/api/src/better-auth/shared.ts @@ -0,0 +1,168 @@ +import { betterAuth, type BetterAuthOptions } from "better-auth"; +import { APIError } from "better-auth/api"; +import { admin, bearer, deviceAuthorization, mcp, organization } from "better-auth/plugins"; +import { apiKey } from "@better-auth/api-key"; + +const SIGNUP_PATH = "/sign-up/email"; + +export interface BetterAuthDbClient { + execute(sql: string, args?: any[]): Promise<{ rows: any[]; rowsAffected?: number }>; +} + +export interface SignupGate { + readonly organizationId: string; + readonly getAuth: () => BetterAuthInstance | null; + readonly findRedeemableCode: ( + code: string, + ) => Promise<{ role: "admin" | "member"; expiresAt: string | null } | null>; + readonly consumeInviteCode: ( + code: string, + by: { usedBy: string; usedByEmail: string }, + ) => Promise; +} + +export const getSharedPlugins = () => [ + organization({ allowUserToCreateOrganization: false }), + admin(), + apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }), + bearer(), + deviceAuthorization({ verificationUri: "/device" }), + mcp({ + loginPage: "/login", + oidcConfig: { loginPage: "/login", consentPage: "/mcp-consent" }, + }), +]; + +const dummyOptions = { + plugins: getSharedPlugins(), +}; + +export type BetterAuthInstance = ReturnType>; + +export const inviteCodeFrom = (context: { body?: unknown }): string | undefined => { + const body = context.body; + if (body && typeof body === "object" && "inviteCode" in body) { + const code = (body as { inviteCode?: unknown }).inviteCode; + if (typeof code === "string" && code.trim().length > 0) return code; + } + return undefined; +}; + +export const countOrgMembers = ( + auth: BetterAuthInstance, + organizationId: string, +): Promise => + auth.$context.then(({ adapter }) => + adapter.count({ model: "member", where: [{ field: "organizationId", value: organizationId }] }), + ); + +const orgHasNoMembers = async (gate: SignupGate): Promise => { + const auth = gate.getAuth(); + if (!auth) return true; + return (await countOrgMembers(auth, gate.organizationId)) === 0; +}; + +let warnedInsecureTrustedOrigin = false; + +export const makeBetterAuthSharedOptions = ( + getOrganizationId: () => string, + config: { + authSecret: string; + webBaseUrl?: string; + trustedOrigins?: readonly string[]; + }, + gate?: SignupGate, +) => { + const origins = + config.trustedOrigins && config.trustedOrigins.length > 0 + ? [...config.trustedOrigins] + : config.webBaseUrl + ? [config.webBaseUrl] + : []; + + const hasInsecureTrustedOrigin = origins.some((origin) => origin && origin.startsWith("http://")); + const downgradesCanonicalCookies = + hasInsecureTrustedOrigin && !!config.webBaseUrl && config.webBaseUrl.startsWith("https://"); + if (downgradesCanonicalCookies && !warnedInsecureTrustedOrigin) { + warnedInsecureTrustedOrigin = true; + console.warn( + "[executor] EXECUTOR_TRUSTED_ORIGINS contains an http:// origin, so session cookies drop the Secure attribute for every origin — including the https:// canonical URL. Use https:// aliases to keep session cookies transport-secure.", + ); + } + + const secret = config.authSecret; + if (secret.length < 32) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a multi-user auth server must not boot with a weak session secret + throw new Error("BETTER_AUTH_SECRET (or AUTH_SECRET), if set, must be at least 32 characters"); + } + + return { + secret, + ...(config.webBaseUrl ? { baseURL: config.webBaseUrl } : {}), + ...(origins.length > 0 ? { trustedOrigins: origins } : {}), + advanced: { useSecureCookies: !hasInsecureTrustedOrigin }, + emailAndPassword: { enabled: true }, + plugins: getSharedPlugins(), + databaseHooks: { + session: { + create: { + before: async (session: Record) => ({ + data: { ...session, activeOrganizationId: getOrganizationId() }, + }), + }, + }, + ...(gate + ? { + user: { + create: { + before: async (_user, context) => { + if (context?.path !== SIGNUP_PATH) return; + if (await orgHasNoMembers(gate)) return; + const code = inviteCodeFrom(context); + if (!code) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Better Auth hook rejects by throwing APIError + throw new APIError("FORBIDDEN", { + message: "An invite code is required to sign up.", + }); + } + const redeemable = await gate.findRedeemableCode(code); + if (!redeemable) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Better Auth hook rejects by throwing APIError + throw new APIError("FORBIDDEN", { + message: "That invite code is invalid, already used, or expired.", + }); + } + }, + after: async (user, context) => { + if (context?.path !== SIGNUP_PATH) return; + const auth = gate.getAuth(); + if (!auth) return; + if (await orgHasNoMembers(gate)) { + await auth.api.addMember({ + body: { userId: user.id, role: "owner", organizationId: gate.organizationId }, + }); + return; + } + const code = inviteCodeFrom(context); + if (!code) return; + const redeemable = await gate.findRedeemableCode(code); + if (!redeemable) return; + await auth.api.addMember({ + body: { + userId: user.id, + role: redeemable.role, + organizationId: gate.organizationId, + }, + }); + await gate.consumeInviteCode(code, { + usedBy: user.id, + usedByEmail: user.email, + }); + }, + }, + }, + } + : {}), + }, + } satisfies BetterAuthOptions; +}; diff --git a/apps/host-selfhost/src/system/api.ts b/packages/core/api/src/better-auth/system-api.ts similarity index 64% rename from apps/host-selfhost/src/system/api.ts rename to packages/core/api/src/better-auth/system-api.ts index 4b6200dc8d..6ec8ff84a5 100644 --- a/apps/host-selfhost/src/system/api.ts +++ b/packages/core/api/src/better-auth/system-api.ts @@ -1,17 +1,6 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -// --------------------------------------------------------------------------- -// Public system API — unauthenticated status endpoints served under /api. -// -// GET /api/health readiness probe (used by the container healthcheck) -// GET /api/setup-status whether the instance still needs first-run setup, so -// the pre-login SPA can route a fresh operator to /setup -// -// Both are deliberately unauthenticated and return only booleans/status — no -// sensitive data — so they can be read before anyone has signed in. -// --------------------------------------------------------------------------- - export class SystemError extends Schema.TaggedErrorClass()( "SystemError", { message: Schema.String }, diff --git a/apps/host-selfhost/src/system/handlers.ts b/packages/core/api/src/better-auth/system.ts similarity index 54% rename from apps/host-selfhost/src/system/handlers.ts rename to packages/core/api/src/better-auth/system.ts index 8ecb4199b8..a1ef8da07e 100644 --- a/apps/host-selfhost/src/system/handlers.ts +++ b/packages/core/api/src/better-auth/system.ts @@ -2,24 +2,18 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpRouter } from "effect/unstable/http"; import { Effect, Layer } from "effect"; -import { SystemError, SystemHttpApi } from "./api"; -import { BetterAuth, countOrgMembers, type BetterAuthHandle } from "../auth/better-auth"; -import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; -import { findRedeemableCode } from "../auth/invites"; - -// --------------------------------------------------------------------------- -// Handlers for the public system API. Unauthenticated; every DB touch is an -// Effect.tryPromise. `health` fails soft (a DB hiccup reports "degraded", it -// never throws); `setup-status` reports whether the one org has zero members. -// --------------------------------------------------------------------------- +import { SystemError, SystemHttpApi } from "./system-api"; +import { BetterAuth, type BetterAuthHandle } from "./identity"; +import { countOrgMembers } from "./shared"; +import { findRedeemableCode } from "./invites"; export const SystemHandlers = HttpApiBuilder.group(SystemHttpApi, "system", (handlers) => handlers .handle("health", () => Effect.gen(function* () { - const { client } = yield* SelfHostDb; + const { dbClient } = yield* BetterAuth; const status = yield* Effect.tryPromise({ - try: () => client.execute("SELECT 1"), + try: () => dbClient.execute("SELECT 1"), catch: () => new SystemError({ message: "database unreachable" }), }).pipe( Effect.as("ok"), @@ -31,8 +25,6 @@ export const SystemHandlers = HttpApiBuilder.group(SystemHttpApi, "system", (han .handle("setupStatus", () => Effect.gen(function* () { const { auth, organizationId } = yield* BetterAuth; - // Count via Better Auth's adapter (see countOrgMembers) so this read is - // consistent with how memberships are written. const count = yield* Effect.tryPromise({ try: () => countOrgMembers(auth, organizationId), catch: () => new SystemError({ message: "failed to read setup status" }), @@ -42,9 +34,9 @@ export const SystemHandlers = HttpApiBuilder.group(SystemHttpApi, "system", (han ) .handle("inviteStatus", ({ params }) => Effect.gen(function* () { - const { client } = yield* SelfHostDb; + const { dbClient } = yield* BetterAuth; const code = yield* Effect.tryPromise({ - try: () => findRedeemableCode(client, params.code), + try: () => findRedeemableCode(dbClient, params.code), catch: () => new SystemError({ message: "failed to read invite status" }), }); return { valid: code !== null }; @@ -52,26 +44,21 @@ export const SystemHandlers = HttpApiBuilder.group(SystemHttpApi, "system", (han ), ); -export interface SelfHostSystemApiDeps { +export interface BetterAuthSystemApiDeps { readonly betterAuth: BetterAuthHandle; - readonly db: SelfHostDbHandle; readonly mountPrefix: `/${string}`; } -/** Mountable extension route layer (see makeSelfHostAdminApiLayer). */ -export const makeSelfHostSystemApiLayer = ({ +export const makeBetterAuthSystemApiLayer = ({ betterAuth, - db, mountPrefix, -}: SelfHostSystemApiDeps) => { +}: BetterAuthSystemApiDeps) => { const prefixedRouter = Layer.effect(HttpRouter.HttpRouter)( Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed(mountPrefix)), ); return HttpApiBuilder.layer(SystemHttpApi).pipe( Layer.provide(SystemHandlers), Layer.provide(prefixedRouter), - HttpRouter.provideRequest( - Layer.mergeAll(Layer.succeed(BetterAuth)(betterAuth), Layer.succeed(SelfHostDb)(db)), - ), + HttpRouter.provideRequest(Layer.succeed(BetterAuth)(betterAuth)), ); }; diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 2b7f8ea2e1..4af9f87663 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -94,3 +94,22 @@ export { captureEngineError, type ErrorCaptureShape, } from "./observability"; +export { postLoginTarget } from "./better-auth/redirection"; +export { + AdminHttpApi, + AdminApi, + AdminError, + AdminForbidden, + AdminUnauthorized, + InviteCode, + InvitesResponse, + CreateInviteBody, +} from "./better-auth/admin-api"; +export { + SystemHttpApi, + SystemApi, + SystemError, + HealthResponse, + SetupStatusResponse, + InviteStatusResponse, +} from "./better-auth/system-api"; diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index 104d841c39..54688cdcb9 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -154,3 +154,17 @@ export type { EngineProviders, McpProviders, } from "./server/executor-app"; + +export * from "./better-auth/shared"; +export * from "./better-auth/identity"; +export * from "./better-auth/account-provider"; +export * from "./better-auth/mcp-auth"; +export * from "./better-auth/consent"; +export * from "./better-auth/invalid-origin"; +export * from "./better-auth/redirection"; +export * from "./better-auth/invites"; +export * from "./better-auth/seed"; +export * from "./better-auth/admin-api"; +export * from "./better-auth/admin"; +export * from "./better-auth/system-api"; +export * from "./better-auth/system"; diff --git a/packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts b/packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts index b23148ff05..484a0b822d 100644 --- a/packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts +++ b/packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts @@ -19,7 +19,7 @@ import { invokeWithLayer } from "./invoke"; import { openApiPlugin } from "./plugin"; import type { OperationBinding } from "./types"; -const RESPONSE_HEADERS_TIMEOUT_MS = 100; +const RESPONSE_HEADERS_TIMEOUT_MS = 250; const STREAM_TOOL = "logs.getLogs"; const encoder = new TextEncoder(); @@ -148,14 +148,16 @@ describe("OpenAPI response headers timeout", () => { const elapsedMs = Date.now() - startedAt; const socketClosed = yield* Deferred.await(closed).pipe(Effect.timeoutOption(1_000)); - expect(elapsedMs).toBeGreaterThanOrEqual(RESPONSE_HEADERS_TIMEOUT_MS - 25); - expect(elapsedMs).toBeLessThan(2_000); + expect(elapsedMs).toBeGreaterThanOrEqual(RESPONSE_HEADERS_TIMEOUT_MS - 50); + expect(elapsedMs).toBeLessThan(3_000); expect(Option.isSome(socketClosed)).toBe(true); expect(result).toMatchObject({ ok: false, error: { code: "upstream_response_headers_timeout", - message: expect.stringContaining("Upstream returned no response headers within 100ms"), + message: expect.stringContaining( + `Upstream returned no response headers within ${RESPONSE_HEADERS_TIMEOUT_MS}ms`, + ), }, }); }),