This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user