240 lines
8.7 KiB
TypeScript
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
|
|
}
|
|
}
|