178 lines · 7.8 KB · TypeScript
RawDownload
// The self-host app as a target: its real dev server (`bunx --bun vite dev`)// on a throwaway data dir, with Better Auth + the bootstrap admin. MCP OAuth is// headless via `forcedMcpConsent` below. Boot lives in// setup/selfhost.globalsetup.ts.import { randomBytes } from "node:crypto"; import { Effect } from "effect"; import { e2ePort } from "../src/ports";import type { Identity, Target } from "../src/target"; export const SELFHOST_PORT = e2ePort("E2E_SELFHOST_PORT", 4);export const SELFHOST_BASE_URL =  process.env.E2E_SELFHOST_URL ?? `http://localhost:${SELFHOST_PORT}`; export const SELFHOST_ADMIN = {  email: process.env.E2E_SELFHOST_ADMIN_EMAIL ?? "admin@e2e.test",  password: process.env.E2E_SELFHOST_ADMIN_PASSWORD ?? "e2e-admin-password-123",}; // Sign the bootstrap admin in via Better Auth email and return the session// cookie in both shapes we need: the `Cookie` header the API surface attaches,// and the {name,value} list Playwright injects into a browser context. The// `origin` header is required — Better Auth rejects state-changing requests// without it.export const signInSession = async (  baseUrl: string,  credentials: { readonly email: string; readonly password: string },): Promise<{  readonly cookieHeader: string;  readonly cookies: ReadonlyArray<{ readonly name: string; readonly value: string }>;}> => {  const response = await fetch(new URL("/api/auth/sign-in/email", baseUrl), {    method: "POST",    headers: { "content-type": "application/json", origin: new URL(baseUrl).origin },    body: JSON.stringify(credentials),    redirect: "manual",  });  const pairs = (response.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]!.trim());  if (pairs.length === 0) throw new Error(`selfhost: sign-in set no cookie (${response.status})`);  const cookies = pairs.map((pair) => {    const eq = pair.indexOf("=");    return { name: pair.slice(0, eq), value: pair.slice(eq + 1) };  });  return { cookieHeader: pairs.join("; "), cookies };}; // Headless MCP OAuth consent. The self-host serving layer forces// `prompt=consent` on every MCP authorize (src/auth/force-mcp-consent), so an// authenticated authorize no longer redirects straight to the callback with a// `code` — it stops on the `/mcp-consent` approval screen with a `consent_code`.// mcporter's `cookieConsentStrategy` only handles the old direct-code redirect,// so this completes the screen the way the page does: sign in, drive authorize,// then POST the same `/api/auth/oauth2/consent` grant the Allow button fires.export const forcedMcpConsent =  (baseUrl: string, credentials: { readonly email: string; readonly password: string }) =>  async ({ authorizationUrl }: { authorizationUrl: string }): Promise<{ code: string }> => {    const origin = new URL(baseUrl).origin;    const { cookieHeader } = await signInSession(baseUrl, credentials);     const authorize = await fetch(authorizationUrl, {      headers: { cookie: cookieHeader },      redirect: "manual",    });    const location = authorize.headers.get("location");    if (!location) {      throw new Error(`forcedMcpConsent: authorize did not redirect (status ${authorize.status})`);    }    // The consent redirect is relative (`/mcp-consent?...`) — resolve it against    // the instance origin. If the server issued a code directly (consent not    // forced), use it; otherwise complete the forced approval below.    const redirect = new URL(location, baseUrl);    const direct = redirect.searchParams.get("code");    if (direct) return { code: direct };    const consentCode = redirect.searchParams.get("consent_code");    if (!consentCode) {      throw new Error(`forcedMcpConsent: no consent_code in authorize redirect: ${location}`);    }     const decision = await fetch(new URL("/api/auth/oauth2/consent", baseUrl), {      method: "POST",      headers: { "content-type": "application/json", origin, cookie: cookieHeader },      body: JSON.stringify({ accept: true, consent_code: consentCode }),    });    if (!decision.ok) {      throw new Error(`forcedMcpConsent: consent grant failed (status ${decision.status})`);    }    const decisionText = await decision.text();    if (!decisionText) {      throw new Error(        `forcedMcpConsent: consent grant returned an empty body (status ${decision.status})`,      );    }    const body = JSON.parse(decisionText) as { redirectURI?: string };    const code = body.redirectURI ? new URL(body.redirectURI).searchParams.get("code") : null;    if (!code) {      throw new Error(        `forcedMcpConsent: no code in consent redirect: ${body.redirectURI ?? "(none)"}`,      );    }    return { code };  }; // A second, distinct member of the (single) selfhost org: admin invite →// invited email signup → Better Auth session. What multi-user scenarios use// until per-test invite-signup isolation lands in newIdentity itself.export const createInvitedIdentity = async (  baseUrl: string,  admin: Identity,  options?: { readonly role?: "admin" | "member"; readonly emailPrefix?: string },): Promise<Identity> => {  const cookie = admin.headers?.cookie;  if (typeof cookie !== "string") {    throw new Error("createInvitedIdentity: the admin identity has no Better Auth session cookie");  }  const origin = new URL(baseUrl).origin;   const invite = await fetch(new URL("/api/admin/invites", baseUrl), {    method: "POST",    headers: { "content-type": "application/json", cookie, origin },    body: JSON.stringify({ role: options?.role ?? "member" }),  });  if (invite.status !== 200) {    throw new Error(`createInvitedIdentity: invite create failed (${await invite.text()})`);  }  const inviteBody = (await invite.json()) as { readonly code?: string };  if (typeof inviteBody.code !== "string") {    throw new Error("createInvitedIdentity: invite response carried no redeemable code");  }   const email = `${options?.emailPrefix ?? "invited-member"}-${randomBytes(5).toString("hex")}@e2e.test`;  const password = "invited-member-password-123";  const signup = await fetch(new URL("/api/auth/sign-up/email", baseUrl), {    method: "POST",    headers: { "content-type": "application/json", origin },    body: JSON.stringify({ email, password, name: email, inviteCode: inviteBody.code }),  });  if (signup.status !== 200) {    throw new Error(`createInvitedIdentity: invited signup failed (${await signup.text()})`);  }   const session = await signInSession(baseUrl, { email, password });  return {    label: email,    credentials: { email, password },    headers: { cookie: session.cookieHeader },    cookies: session.cookies,  };}; export const selfhostTarget = (): Target => ({  name: "selfhost",  baseUrl: SELFHOST_BASE_URL,  mcpUrl: `${SELFHOST_BASE_URL}/mcp`,  // No "billing" (no limits) and no setAccessTokenTtl yet (Better Auth is the  // authorization server; its token TTL isn't test-adjustable, so token-expiry  // scenarios skip here). Identity is the bootstrap admin for now —  // single-tenant; per-test invite-signup isolation is the next step here, so  // browser scenarios must prefix the resources they create.  capabilities: new Set(["api", "browser", "mcp-oauth"]),  newIdentity: () =>    Effect.promise(async (): Promise<Identity> => {      // Sign in once and carry the session in both shapes: `headers` for the      // API surface, `cookies` for an injectable logged-in browser context.      const { cookieHeader, cookies } = await signInSession(SELFHOST_BASE_URL, SELFHOST_ADMIN);      return {        label: SELFHOST_ADMIN.email,        credentials: SELFHOST_ADMIN,        headers: { cookie: cookieHeader },        cookies,      };    }),  mcpConsent: (identity: Identity) =>    forcedMcpConsent(SELFHOST_BASE_URL, {      email: identity.credentials?.email ?? SELFHOST_ADMIN.email,      password: identity.credentials?.password || SELFHOST_ADMIN.password,    }),});