Files

240 lines
8.7 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The app as a production build with AUTH_PROVIDER=convex-better-auth, for gate J-auth (joint
// contract §14): a disposable Better Auth deployment on the platform's pinned self-hosted Convex
// image, a Postgres with the three roles, the fake platform of @conusai-kit/testing answering the
// real platform API (the app runs in platform mode, as in production: Support mints widget
// sessions and Guides bootstraps through it), the migrate job (which deploys the Better Auth
// functions), the worker and `next start`. Containers publish on 127.0.0.1 only (rootless Docker),
// and every container and volume is removed when the run ends.
//
// Run it with `pnpm run build` first, then
// pnpm exec playwright test -c e2e/better-auth/playwright.config.ts
import { type ChildProcess, execFileSync, spawn } from "node:child_process"
import { randomBytes } from "node:crypto"
import { mkdirSync, openSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { startFakePlatform } from "@conusai-kit/testing/fake-platform"
export const APP_ROOT = new URL("../..", import.meta.url).pathname
const LOGS = join(APP_ROOT, "e2e", ".logs")
/** The platform's pinned self-hosted Convex backend (images/third-party.toml `convex-backend`). */
export const CONVEX_BACKEND_IMAGE =
process.env.CONVEX_BACKEND_IMAGE ??
"build.conusai.com/conusai/convex-backend@sha256:1738f1673f8d63161043a7859710d2301b1e9d6271e06afbb7af31594ea3a58f"
const WEB_PORT = Number(process.env.JAUTH_WEB_PORT ?? 3150)
/** What the specs read: the app, the fake platform and the Better Auth deployment's server side. */
export interface JauthStack {
readonly baseUrl: string
readonly platformUrl: string
readonly convexUrl: string
readonly convexSiteUrl: string
readonly adminKey: string
readonly backend: string
/** Server-side values a browser must never see: the network inspection looks for each. */
readonly secrets: readonly string[]
}
const sh = (command: string, args: string[], env?: NodeJS.ProcessEnv): string =>
execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...(env ? { env } : {}) }).trim()
async function until(label: string, check: () => Promise<boolean> | boolean, seconds = 120): Promise<void> {
const deadline = Date.now() + seconds * 1000
for (;;) {
try {
if (await check()) return
} catch {
// not yet
}
if (Date.now() > deadline) throw new Error(`j-auth: ${label} did not come up in ${seconds}s (logs in e2e/.logs)`)
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
function spawnLogged(name: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): ChildProcess {
mkdirSync(LOGS, { recursive: true })
const out = openSync(join(LOGS, `jauth-${name}.log`), "w")
return spawn(command, args, { cwd, env, stdio: ["ignore", out, out], detached: true })
}
function stopGroup(child: ChildProcess): void {
if (child.pid === undefined) return
try {
process.kill(-child.pid, "SIGTERM")
} catch {
// already gone
}
}
/** The host port Docker published for a container port, on 127.0.0.1. */
const portOf = (container: string, port: number): number =>
Number(
sh("docker", ["port", container, `${port}/tcp`])
.split("\n")[0]
?.split(":")
.at(-1),
)
const removeContainer = (name: string): void => {
try {
// -v: the anonymous data volume goes with the container.
sh("docker", ["rm", "-f", "-v", name])
} catch {
// already gone
}
}
export async function startJauthStack(): Promise<{ stack: JauthStack; stop(): Promise<void> }> {
const run = randomBytes(4).toString("hex")
const backend = `jauth-better-auth-${run}`
const postgres = `jauth-postgres-${run}`
const processes: ChildProcess[] = []
const platform = await startFakePlatform()
const stop = async (): Promise<void> => {
for (const child of processes) stopGroup(child)
await platform.close()
removeContainer(backend)
removeContainer(postgres)
}
try {
sh("docker", [
"run",
"-d",
"--name",
backend,
"-l",
"conusai.test=j-auth",
"-e",
`INSTANCE_NAME=jauth_${run}`,
"-e",
`INSTANCE_SECRET=${randomBytes(32).toString("hex")}`,
// The backend reaches its own site URL (the issuer of Better Auth's Convex tokens) inside the container.
"-e",
"CONVEX_CLOUD_ORIGIN=http://127.0.0.1:3210",
"-e",
"CONVEX_SITE_ORIGIN=http://127.0.0.1:3211",
"-p",
"127.0.0.1::3210",
"-p",
"127.0.0.1::3211",
CONVEX_BACKEND_IMAGE,
])
sh("docker", [
"run",
"-d",
"--name",
postgres,
"-l",
"conusai.test=j-auth",
"-p",
"127.0.0.1::5432",
"-e",
"POSTGRES_PASSWORD=postgres",
"-e",
"POSTGRES_DB=appdev",
"-v",
`${join(APP_ROOT, "dev", "postgres-roles.sql")}:/docker-entrypoint-initdb.d/10-roles.sql:ro`,
"pgvector/pgvector:pg18",
])
const convexUrl = `http://127.0.0.1:${portOf(backend, 3210)}`
const convexSiteUrl = `http://127.0.0.1:${portOf(backend, 3211)}`
const pgPort = portOf(postgres, 5432)
const pg = (role: string) => `postgresql://appdev_${role}:appdev_${role}@127.0.0.1:${pgPort}/appdev`
await until("the Better Auth backend", async () => (await fetch(`${convexUrl}/version`)).ok)
const adminKey = sh("docker", ["exec", backend, "./generate_admin_key.sh"]).split("\n").at(-1) ?? ""
if (!adminKey) throw new Error("j-auth: the backend generated no admin key")
await until("postgres", () => {
sh("docker", ["exec", postgres, "psql", "-U", "appdev_app", "-h", "127.0.0.1", "-d", "appdev", "-c", "select 1"])
return true
})
const baseUrl = `http://localhost:${WEB_PORT}`
const betterAuthSecret = randomBytes(32).toString("base64url")
const platformToken = `jauth-platform-token-${randomBytes(8).toString("hex")}`
const sessionSecret = randomBytes(32).toString("base64url")
// A production run against the platform API (the fake platform answers it): no fake-platform
// switch, no dev sign-in, no insecure cookies, no OIDC variable -- what a deployment has.
const common: NodeJS.ProcessEnv = {
PATH: process.env.PATH,
HOME: process.env.HOME,
TMPDIR: process.env.TMPDIR,
NODE_ENV: "production",
CONUSAI_API_URL: `${platform.url}/api`,
CONUSAI_API_TOKEN: platformToken,
CONUSAI_PROJECT_ID: "42",
CONUSAI_ORG_ID: "jauth",
CONUSAI_ORG_SLUG: "jauth",
CONUSAI_PUBLIC_URL: baseUrl,
CONUSAI_RELEASE: "j-auth",
CONUSAI_SUPPORT_CHANNEL: "spk_jauth", // kit:support
POSTGRES_URL: pg("app"),
POSTGRES_READER_URL: pg("reader"),
KNOWLEDGE_VECTOR_STORE: "pgvector",
AUTH_SESSION_SECRET: sessionSecret,
AUTH_PROVIDER: "convex-better-auth",
AUTH_IDENTITY_NAMESPACE: "better-auth:jauth",
BETTER_AUTH_CONVEX_URL: convexUrl,
BETTER_AUTH_CONVEX_SITE_URL: convexSiteUrl,
BETTER_AUTH_CONVEX_ADMIN_KEY: adminKey,
BETTER_AUTH_RELAY_INTERVAL_MS: "250",
}
// The migrate job: the schema, then the Better Auth functions (it alone holds BETTER_AUTH_SECRET).
execFileSync(process.execPath, ["apps/worker/dist/migrate.js"], {
cwd: APP_ROOT,
env: { ...common, POSTGRES_MIGRATE_URL: pg("owner"), BETTER_AUTH_SECRET: betterAuthSecret },
stdio: "inherit",
})
// The deployed functions typecheck against the contexts Convex generated for them (deploy.mjs
// pushes without a typecheck: the runtime image carries no TypeScript).
execFileSync(
process.execPath,
[join(APP_ROOT, "node_modules/typescript/bin/tsc"), "-p", "better-auth/functions/tsconfig.json", "--noEmit"],
{ cwd: APP_ROOT, stdio: "inherit" },
)
processes.push(
spawnLogged("worker", process.execPath, ["apps/worker/dist/main.js"], APP_ROOT, {
...common,
WORKER_HEARTBEAT_FILE: join(LOGS, "jauth-worker-heartbeat"),
}),
)
processes.push(
spawnLogged(
"web",
process.execPath,
["node_modules/next/dist/bin/next", "start", "-p", String(WEB_PORT), "-H", "localhost"],
join(APP_ROOT, "apps", "web"),
common,
),
)
await until("the web shell", async () => (await fetch(`${baseUrl}/api/ready`)).ok, 120)
await until(
"the worker",
() => readFileSync(join(LOGS, "jauth-worker.log"), "utf8").includes("worker: serving"),
60,
)
return {
stack: {
baseUrl,
platformUrl: platform.url,
convexUrl,
convexSiteUrl,
adminKey,
backend,
secrets: [adminKey, betterAuthSecret, platformToken, sessionSecret],
},
stop,
}
} catch (error) {
await stop()
throw error
}
}