diff --git a/.changeset/console-hosting-choice.md b/.changeset/console-hosting-choice.md new file mode 100644 index 0000000..a4aef22 --- /dev/null +++ b/.changeset/console-hosting-choice.md @@ -0,0 +1,23 @@ +--- +"seamless-cli": minor +--- + +Let adopters choose how the admin console is hosted during `seamless init`. + +The old "Include Admin Dashboard?" / image-vs-source prompts are replaced by a +single question with four options: + +- **Served by your API at /console** (recommended default) — the app backend + proxies the console via the SDK's `createSeamlessConsoleProxy`, so it loads + from the API's own origin. The scaffold sets `SERVE_ADMIN_CONSOLE=true` on the + API, `SERVE_ADMIN_DASHBOARD=true` on the auth server, and adds the API origin + to the auth server's `ORIGINS` so console passkey ceremonies verify. No + separate admin container. +- **Separate container** — official image or cloned source, as before, on + `http://localhost:5174`. +- **None** — no console is scaffolded. + +Each choice pre-configures the auth-server env, the app-backend env, the Docker +Compose services, `seamless.config.json`, the success output, and `seamless +check` accordingly. Pins the auth API image to `v0.3.0` and the templates to +`v0.3.0` (which env-gate the console proxy). diff --git a/src/commands/check.test.ts b/src/commands/check.test.ts index 3ca6ad6..641a553 100644 --- a/src/commands/check.test.ts +++ b/src/commands/check.test.ts @@ -170,6 +170,53 @@ describe("runCheck", () => { expect(execSync).not.toHaveBeenCalled(); }); + it("probes the API /console URL in API-served mode", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + ...CONFIG, + services: { + ...CONFIG.services, + admin: { mode: "api", url: "http://localhost:3000/console" }, + }, + }), + ); + vi.mocked(execSync).mockReturnValue(Buffer.from("api\n")); + const seen: string[] = []; + vi.mocked(fetch).mockImplementation(async (url: unknown) => { + seen.push(String(url)); + return { ok: true, status: 200 } as Response; + }); + + await runCheck(); + + expect(seen).toContain("http://localhost:3000/console"); + expect(seen).not.toContain("http://localhost:5174"); + expect(output()).toContain("Console is healthy"); + }); + + it("skips the console probe when hosting is none", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + ...CONFIG, + services: { ...CONFIG.services, admin: { mode: "none" } }, + }), + ); + vi.mocked(execSync).mockReturnValue(Buffer.from("api\n")); + const seen: string[] = []; + vi.mocked(fetch).mockImplementation(async (url: unknown) => { + seen.push(String(url)); + return { ok: true, status: 200 } as Response; + }); + + await runCheck(); + + expect(seen).not.toContain("http://localhost:5174"); + expect(seen).not.toContain("http://localhost:3000/console"); + expect(output()).not.toContain("Console"); + }); + it("reports a container check failure when docker ps throws", async () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(CONFIG)); diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index 0bbed76..c45bdb4 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -321,7 +321,6 @@ describe("scaffoldLocal", () => { expect(generateDockerCompose).toHaveBeenCalledWith("/work", { authMode: "docker", adminMode: "image", - includeAdmin: true, oauth: [], }); // env applied with the docker-provided token/kid for both templates. @@ -388,6 +387,7 @@ describe("scaffoldLocal", () => { expect.arrayContaining([ expect.objectContaining({ catalog: { label: "Google" } }), ]), + "image", ); // Docker not requested, so the compose generator is untouched. expect(generateDockerCompose).not.toHaveBeenCalled(); diff --git a/src/commands/init.ts b/src/commands/init.ts index d13a9ab..77b815a 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -188,6 +188,9 @@ async function scaffoldManaged( apiUrl: API_URL, apiToken: serviceToken, jwksKid: MANAGED_JWKS_KID, + // A managed instance hosts its own dashboard, so the app API does not proxy + // the console. Keeps the template's SERVE_ADMIN_CONSOLE gate off. + serveAdminConsole: "false", }; for (const { manifest, dir } of selected) { @@ -263,14 +266,18 @@ async function scaffoldLocal( let sharedConfig: any = {}; if (answers.authMode === "local") { - sharedConfig = await generateAuthServer({ root }, "local", oauthProviders); + sharedConfig = await generateAuthServer( + { root }, + "local", + oauthProviders, + answers.adminMode, + ); } if (answers.useDocker) { const dockerShared = await generateDockerCompose(root, { authMode: answers.authMode, adminMode: answers.adminMode, - includeAdmin: answers.includeAdmin, oauth: oauthProviders, }); @@ -284,6 +291,7 @@ async function scaffoldLocal( apiUrl: API_URL, apiToken: sharedConfig.apiToken, jwksKid: sharedConfig.kid, + serveAdminConsole: answers.adminMode === "api" ? "true" : "false", }; for (const { manifest, dir } of selected) { @@ -308,6 +316,7 @@ async function scaffoldLocal( apiFramework: apiEntry.framework, authMode: answers.authMode, useDocker: answers.useDocker, + adminMode: answers.adminMode, }); printOAuthNextSteps(oauthProviders); diff --git a/src/core/images.ts b/src/core/images.ts index 7bd16a3..f7167ee 100644 --- a/src/core/images.ts +++ b/src/core/images.ts @@ -1,6 +1,6 @@ export const POSTGRES_IMAGE = "postgres:17"; -export const SEAMLESS_AUTH_API_VERSION = "v0.2.1"; +export const SEAMLESS_AUTH_API_VERSION = "v0.3.0"; export const SEAMLESS_AUTH_API_IMAGE = `ghcr.io/fells-code/seamless-auth-api:${SEAMLESS_AUTH_API_VERSION}`; @@ -13,4 +13,4 @@ export const SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE = `ghcr.io/fells-code/seamless- // SEAMLESS_TEMPLATES_REF, or point at a local checkout with SEAMLESS_TEMPLATES_DIR. export const SEAMLESS_TEMPLATES_REPO = "fells-code/seamless-templates"; -export const SEAMLESS_TEMPLATES_REF = "v0.2.4"; +export const SEAMLESS_TEMPLATES_REF = "v0.3.0"; diff --git a/src/core/output.test.ts b/src/core/output.test.ts index 20d33f6..8ceff3d 100644 --- a/src/core/output.test.ts +++ b/src/core/output.test.ts @@ -24,6 +24,7 @@ describe("printSuccessOutput", () => { apiFramework: "express", authMode: "local", useDocker: true, + adminMode: "image", }); const out = allLogs(); @@ -37,7 +38,7 @@ describe("printSuccessOutput", () => { expect(out).toContain("API server"); expect(out).toContain("(Express)"); expect(out).toContain("(local source)"); - expect(out).toContain("Admin dashboard"); + expect(out).toContain("Admin console"); expect(out).toContain("1. Start services"); expect(out).toContain("docker compose up"); expect(out).toContain("2. Create your first admin user"); @@ -52,6 +53,41 @@ describe("printSuccessOutput", () => { expect(out).toContain("Setup complete."); }); + it("prints the /console URL for API-served hosting", () => { + printSuccessOutput({ + projectName: "my-app", + root: "/tmp/my-app", + webFramework: "react", + apiFramework: "express", + authMode: "docker", + useDocker: true, + adminMode: "api", + }); + + const out = allLogs(); + expect(out).toContain("Admin console"); + expect(out).toContain("served by API at /console"); + expect(out).toContain("Console: http://localhost:3000/console"); + expect(out).not.toContain("http://localhost:5174"); + }); + + it("omits the console line entirely when hosting is none", () => { + printSuccessOutput({ + projectName: "my-app", + root: "/tmp/my-app", + webFramework: "react", + apiFramework: "express", + authMode: "docker", + useDocker: true, + adminMode: "none", + }); + + const out = allLogs(); + expect(out).not.toContain("Admin console"); + expect(out).not.toContain("Console:"); + expect(out).not.toContain("http://localhost:5174"); + }); + it("prints docker authMode label even when useDocker is falsy (symbol edge case not exercised here)", () => { printSuccessOutput({ root: "/tmp/my-app", @@ -59,6 +95,7 @@ describe("printSuccessOutput", () => { apiFramework: null, authMode: "docker", useDocker: false, + adminMode: "image", }); const out = allLogs(); @@ -83,6 +120,7 @@ describe("printSuccessOutput", () => { apiFramework: "fastapi", authMode: "local", useDocker: false, + adminMode: "image", }); const out = allLogs(); @@ -112,6 +150,7 @@ describe("printSuccessOutput", () => { apiFramework: "django", authMode: "docker", useDocker: true, + adminMode: "image", }); const out = allLogs(); @@ -126,6 +165,7 @@ describe("printSuccessOutput", () => { apiFramework: null, authMode: "docker", useDocker: Symbol("cancel"), + adminMode: "none", }); const out = allLogs(); diff --git a/src/core/output.ts b/src/core/output.ts index abe738c..a767940 100644 --- a/src/core/output.ts +++ b/src/core/output.ts @@ -7,10 +7,20 @@ export function printSuccessOutput(config: { apiFramework: string | null; authMode: "local" | "docker"; useDocker: boolean | symbol; + adminMode: "api" | "image" | "source" | "none"; }) { - const { projectName, webFramework, apiFramework, authMode, useDocker } = + const { projectName, webFramework, apiFramework, authMode, useDocker, adminMode } = config; + // Where the admin console lives: proxied by the app API at /console, or a + // standalone container on 5174. "none" scaffolds no console at all. + const consoleUrl = + adminMode === "api" + ? "http://localhost:3000/console" + : adminMode === "none" + ? null + : "http://localhost:5174"; + const title = kleur.bold().cyan("SEAMLESS"); console.log(` @@ -50,9 +60,15 @@ export function printSuccessOutput(config: { kleur.dim(authMode === "local" ? " (local source)" : " (Docker image)"), ); - console.log( - " • " + kleur.white("Admin dashboard") + kleur.dim(" (management UI)"), - ); + if (consoleUrl) { + console.log( + " • " + + kleur.white("Admin console") + + kleur.dim( + adminMode === "api" ? " (served by API at /console)" : " (management UI)", + ), + ); + } console.log(""); @@ -114,7 +130,9 @@ export function printSuccessOutput(config: { console.log(" Web: " + kleur.cyan("http://localhost:5173")); } - console.log(" Admin: " + kleur.cyan("http://localhost:5174")); + if (consoleUrl) { + console.log(" Console:" + kleur.cyan(` ${consoleUrl}`)); + } console.log(""); @@ -122,7 +140,15 @@ export function printSuccessOutput(config: { console.log(kleur.dim(" • Web connects to API automatically")); console.log(kleur.dim(" • API connects to Auth automatically")); - console.log(kleur.dim(" • Admin dashboard uses the same auth system")); + if (consoleUrl) { + console.log( + kleur.dim( + adminMode === "api" + ? " • Admin console is served by the API at /console" + : " • Admin console uses the same auth system", + ), + ); + } console.log( kleur.dim(" • Bootstrap command provisions the first admin user"), ); diff --git a/src/core/templates.test.ts b/src/core/templates.test.ts index 682a57d..b70b76a 100644 --- a/src/core/templates.test.ts +++ b/src/core/templates.test.ts @@ -436,6 +436,20 @@ describe("applyTemplateEnv", () => { expect(written).toContain("PLAIN=literal-value"); }); + it("resolves the serveAdminConsole placeholder from the scaffold context", () => { + applyTemplateEnv( + destDir, + { + id: "x", + targetDir: ".", + env: { set: { SERVE_ADMIN_CONSOLE: "{{serveAdminConsole}}" } }, + }, + { ...ctx, serveAdminConsole: "true" }, + ); + + expect(readEnv()).toContain("SERVE_ADMIN_CONSOLE=true"); + }); + it("resolves secret:N placeholders to N bytes of hex", () => { applyTemplateEnv( destDir, diff --git a/src/core/templates.ts b/src/core/templates.ts index 1711bdb..4606e9d 100644 --- a/src/core/templates.ts +++ b/src/core/templates.ts @@ -55,6 +55,9 @@ export interface ScaffoldContext { apiUrl: string; apiToken?: string; jwksKid?: string; + // "true" when the app API should serve the admin console at /console, "false" + // when the console is hosted elsewhere (a standalone container) or omitted. + serveAdminConsole?: string; } // Build artifacts and local-only files that must never be copied into a scaffold, @@ -246,6 +249,7 @@ function resolveToken(token: string, ctx: ScaffoldContext): string { apiUrl: ctx.apiUrl, apiToken: ctx.apiToken, jwksKid: ctx.jwksKid, + serveAdminConsole: ctx.serveAdminConsole, }; if (token in known) { diff --git a/src/generators/auth/auth.ts b/src/generators/auth/auth.ts index b3f1bf6..420d3eb 100644 --- a/src/generators/auth/auth.ts +++ b/src/generators/auth/auth.ts @@ -10,6 +10,7 @@ import { envToDockerBlock, } from "../docker/docker.js"; import type { CollectedOAuthProvider } from "../../core/oauthProviders.js"; +import type { AdminMode } from "../docker/docker.js"; const AUTH_REPO = "https://github.com/fells-code/seamless-auth-api"; @@ -17,17 +18,22 @@ export async function generateAuthServer( context: any, mode: "local" | "docker" | Symbol, oauth: CollectedOAuthProvider[] = [], + adminMode: AdminMode = "api", ) { const { root } = context; if (mode === "local") { - return await setupLocalAuth(root, oauth); + return await setupLocalAuth(root, oauth, adminMode); } return await setupDockerAuth(root); } -async function setupLocalAuth(root: string, oauth: CollectedOAuthProvider[] = []) { +async function setupLocalAuth( + root: string, + oauth: CollectedOAuthProvider[] = [], + adminMode: AdminMode = "api", +) { const authDir = path.join(root, "auth"); console.log("Cloning SeamlessAuth server..."); @@ -36,7 +42,7 @@ async function setupLocalAuth(root: string, oauth: CollectedOAuthProvider[] = [] console.log("Writing auth environment..."); - const shared = await configureAuthLocalEnv(root, oauth); + const shared = await configureAuthLocalEnv(root, oauth, adminMode); console.log("Auth server ready in /auth"); return shared; diff --git a/src/generators/config/config.test.ts b/src/generators/config/config.test.ts index 6eacf6d..c9410c3 100644 --- a/src/generators/config/config.test.ts +++ b/src/generators/config/config.test.ts @@ -59,6 +59,7 @@ describe("generateSeamlessConfig", () => { mode: "hosted", image: null, path: null, + url: null, }); expect(config.services.database).toEqual({ type: "postgres" }); expect(config.docker).toBeNull(); @@ -108,10 +109,49 @@ describe("generateSeamlessConfig", () => { mode: "image", image: SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE, path: null, + url: null, }); expect(config.docker).toEqual({ composeFile: "docker-compose.yml" }); }); + it("writes an api-served console config with the /console url", () => { + generateSeamlessConfig(tmpDir, { + projectName: "my-app", + webFramework: "react", + apiFramework: "express", + authMode: "docker", + adminMode: "api", + }); + + const config = readConfig(tmpDir); + + expect(config.services.admin).toEqual({ + mode: "api", + image: null, + path: null, + url: "http://localhost:3000/console", + }); + }); + + it("writes a none-mode admin block when the console is omitted", () => { + generateSeamlessConfig(tmpDir, { + projectName: "my-app", + webFramework: "react", + apiFramework: "express", + authMode: "docker", + adminMode: "none", + }); + + const config = readConfig(tmpDir); + + expect(config.services.admin).toEqual({ + mode: "none", + image: null, + path: null, + url: null, + }); + }); + it("writes a local-auth config with a source-mode admin dashboard", () => { generateSeamlessConfig(tmpDir, { projectName: "my-app", @@ -132,6 +172,7 @@ describe("generateSeamlessConfig", () => { mode: "source", image: null, path: "./admin", + url: null, }); expect(config.services.web).toEqual({ framework: "vue", path: "./web" }); expect(config.services.api).toEqual({ diff --git a/src/generators/config/config.ts b/src/generators/config/config.ts index 4bc3eb7..56b9cae 100644 --- a/src/generators/config/config.ts +++ b/src/generators/config/config.ts @@ -19,7 +19,7 @@ export function generateSeamlessConfig( webFramework: string; apiFramework: string; authMode: "local" | "docker" | "managed"; - adminMode: "image" | "source"; + adminMode: "api" | "image" | "source" | "none"; managed?: ManagedConfig; }, ) { @@ -43,14 +43,18 @@ export function generateSeamlessConfig( // A managed instance hosts its own admin dashboard, so no admin service is // scaffolded locally. const admin = managed - ? { mode: "hosted" as const, image: null, path: null } + ? { mode: "hosted" as const, image: null, path: null, url: null } : { mode: options.adminMode, + // API-served: the app API proxies the console at /console (no separate + // image or checkout). Container modes carry an image or a source path. image: options.adminMode === "image" ? SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE : null, path: options.adminMode === "source" ? "./admin" : null, + url: + options.adminMode === "api" ? "http://localhost:3000/console" : null, }; const config = { diff --git a/src/generators/docker/docker.test.ts b/src/generators/docker/docker.test.ts index a2e34ee..3d68353 100644 --- a/src/generators/docker/docker.test.ts +++ b/src/generators/docker/docker.test.ts @@ -95,12 +95,28 @@ describe("buildAuthEnv", () => { expect(env.REFRESH_TOKEN_LOOKUP_SECRET).toMatch(/^[0-9a-f]{64}$/); expect(env.TOTP_SECRET_ENCRYPTION_KEY).toMatch(/^[0-9a-f]{64}$/); expect(env.APP_ORIGINS).toBe("http://localhost:3000"); - expect(env.ORIGINS).toBe("http://localhost:5173,http://localhost:5174"); + // Defaults to API-served: the console shares the app API origin (3000). + expect(env.ORIGINS).toBe("http://localhost:5173,http://localhost:3000"); + expect(env.SERVE_ADMIN_DASHBOARD).toBe("true"); expect(env.LOGIN_METHODS).toBe("passkey,magic_link,email_otp"); expect(env.ALLOW_UNCREDENTIALED_DELIVERY_SECRETS).toBe("true"); expect(shared.kid).toBe("dev-main"); }); + it("adds the standalone console origin and disables serving in container mode", () => { + const { env } = buildAuthEnv({}, "docker", [], "image"); + + expect(env.ORIGINS).toBe("http://localhost:5173,http://localhost:5174"); + expect(env.SERVE_ADMIN_DASHBOARD).toBe("false"); + }); + + it("lists only the web origin and disables serving when the console is omitted", () => { + const { env } = buildAuthEnv({}, "docker", [], "none"); + + expect(env.ORIGINS).toBe("http://localhost:5173"); + expect(env.SERVE_ADMIN_DASHBOARD).toBe("false"); + }); + it("wires local-mode networking values", () => { const { env } = buildAuthEnv({}, "local"); @@ -183,7 +199,6 @@ describe("generateDockerCompose", () => { const shared = await generateDockerCompose(tmpDir, { authMode: "local", adminMode: "image", - includeAdmin: true, }); expect(shared).toEqual({ apiToken: "existing-token", kid: "existing-kid" }); @@ -208,13 +223,12 @@ describe("generateDockerCompose", () => { expect(compose.endsWith("\n")).toBe(true); }); - it("omits the admin service entirely when includeAdmin is false", async () => { + it("omits the admin container in API-served mode and trims 5174 from the api CORS origins", async () => { writeAuthEnvFixture(tmpDir, "SEAMLESS_JWKS_ACTIVE_KID"); await generateDockerCompose(tmpDir, { authMode: "local", - adminMode: "image", - includeAdmin: false, + adminMode: "api", }); const compose = fs.readFileSync( @@ -222,15 +236,30 @@ describe("generateDockerCompose", () => { "utf-8", ); expect(compose).not.toContain("container_name: admin"); + expect(compose).toContain("UI_ORIGINS: http://localhost:5173\n"); }); - it("builds a source-mode admin service when includeAdmin is a truthy symbol", async () => { + it("omits the admin container entirely when the console is not included", async () => { + writeAuthEnvFixture(tmpDir, "SEAMLESS_JWKS_ACTIVE_KID"); + + await generateDockerCompose(tmpDir, { + authMode: "local", + adminMode: "none", + }); + + const compose = fs.readFileSync( + path.join(tmpDir, "docker-compose.yml"), + "utf-8", + ); + expect(compose).not.toContain("container_name: admin"); + }); + + it("builds a source-mode admin service and keeps 5174 in the api CORS origins", async () => { writeAuthEnvFixture(tmpDir, "SEAMLESS_JWKS_ACTIVE_KID"); await generateDockerCompose(tmpDir, { authMode: "local", adminMode: "source", - includeAdmin: Symbol("include"), }); const compose = fs.readFileSync( @@ -241,6 +270,9 @@ describe("generateDockerCompose", () => { expect(compose).toContain("build: ./admin"); expect(compose).toContain("AUTH_MODE: server"); expect(compose).toContain("- ./admin:/app"); + expect(compose).toContain( + "UI_ORIGINS: http://localhost:5173,http://localhost:5174", + ); }); it("builds a docker-auth compose file using the fetched env.example and oauth wiring", async () => { @@ -249,7 +281,6 @@ describe("generateDockerCompose", () => { const shared = await generateDockerCompose(tmpDir, { authMode: "docker", adminMode: "image", - includeAdmin: true, oauth: [googleProvider()], }); diff --git a/src/generators/docker/docker.ts b/src/generators/docker/docker.ts index 72f2870..82d958d 100644 --- a/src/generators/docker/docker.ts +++ b/src/generators/docker/docker.ts @@ -19,8 +19,7 @@ export async function generateDockerCompose( root: string, options: { authMode: "local" | "docker"; - adminMode: "image" | "source"; - includeAdmin: boolean | symbol; + adminMode: AdminMode; oauth?: CollectedOAuthProvider[]; }, ) { @@ -38,15 +37,21 @@ export async function generateDockerCompose( async function buildCompose( options: { authMode: "local" | "docker"; - adminMode: "image" | "source"; - includeAdmin: boolean | symbol; + adminMode: AdminMode; oauth?: CollectedOAuthProvider[]; }, root: string, ) { - const { authMode, adminMode, includeAdmin, oauth } = options; + const { authMode, adminMode, oauth } = options; - const { service: authBlock, shared } = await authService(authMode, root, oauth); + const { service: authBlock, shared } = await authService( + authMode, + root, + oauth, + adminMode, + ); + + const includeAdminContainer = adminMode === "image" || adminMode === "source"; return { compose: ` @@ -70,11 +75,11 @@ services: ${authBlock} -${apiService(shared)} +${apiService(shared, adminMode)} ${webService()} -${includeAdmin ? adminService(adminMode) : ""} +${includeAdminContainer ? adminService(adminMode) : ""} volumes: pgdata: @@ -86,6 +91,7 @@ async function authService( mode: "local" | "docker", root: string, oauth: CollectedOAuthProvider[] = [], + adminMode: AdminMode = "api", ) { if (mode === "local") { // auth/.env was already written by generateAuthServer (with its secrets and any @@ -118,10 +124,20 @@ async function authService( }; } - return await authServiceDocker(oauth); + return await authServiceDocker(oauth, adminMode); +} + +// The app API's CORS allowlist. The console is same-origin here in API-served +// mode, so 5174 only belongs when a standalone dashboard container runs there. +function apiUiOrigins(adminMode: AdminMode): string { + const web = "http://localhost:5173"; + if (adminMode === "image" || adminMode === "source") { + return `${web},http://localhost:5174`; + } + return web; } -function apiService(shared: any) { +function apiService(shared: any, adminMode: AdminMode) { return ` api: container_name: api @@ -132,7 +148,7 @@ function apiService(shared: any) { - ./api/.env environment: AUTH_SERVER_URL: http://auth:5312 - UI_ORIGINS: http://localhost:5173,http://localhost:5174 + UI_ORIGINS: ${apiUiOrigins(adminMode)} DB_HOST: db API_SERVICE_TOKEN: ${shared.apiToken} JWKS_KID: ${shared.kid} @@ -169,11 +185,14 @@ function webService() { `; } -async function authServiceDocker(oauth: CollectedOAuthProvider[] = []) { +async function authServiceDocker( + oauth: CollectedOAuthProvider[] = [], + adminMode: AdminMode = "api", +) { const raw = await fetchEnvExample(); const parsed = parseEnvString(raw); - const { env, shared } = buildAuthEnv(parsed, "docker", oauth); + const { env, shared } = buildAuthEnv(parsed, "docker", oauth, adminMode); const envBlock = envToDockerBlock(env); @@ -226,10 +245,25 @@ function adminService(mode: "image" | "source") { `; } +export type AdminMode = "api" | "image" | "source" | "none"; + +// WebAuthn allowed origins the auth server accepts passkey ceremonies from. The +// web app (5173) is always present; the console adds either the app API origin +// (when the API serves it at /console) or the standalone container origin (5174). +function adminOrigins(adminMode: AdminMode): string { + const web = "http://localhost:5173"; + if (adminMode === "api") return `${web},http://localhost:3000`; + if (adminMode === "image" || adminMode === "source") { + return `${web},http://localhost:5174`; + } + return web; +} + export function buildAuthEnv( env: Record, mode: "local" | "docker", oauth: CollectedOAuthProvider[] = [], + adminMode: AdminMode = "api", ) { const apiToken = generateSecret(32); const bootstrapSecret = generateSecret(32); @@ -256,7 +290,11 @@ export function buildAuthEnv( env.TOTP_SECRET_ENCRYPTION_KEY = generateSecret(32); env.APP_ORIGINS = "http://localhost:3000"; - env.ORIGINS = "http://localhost:5173,http://localhost:5174"; + env.ORIGINS = adminOrigins(adminMode); + + // Serve the bundled admin dashboard build only when the app API proxies it at + // /console; otherwise the console is a standalone container (or omitted). + env.SERVE_ADMIN_DASHBOARD = adminMode === "api" ? "true" : "false"; // Enable email OTP so `seamless login` works against a freshly scaffolded stack // out of the box. The auth server's own default (passkey,magic_link) has no @@ -334,6 +372,7 @@ export function buildJWKSConfig() { export async function configureAuthLocalEnv( root: string, oauth: CollectedOAuthProvider[] = [], + adminMode: AdminMode = "api", ) { const authDir = path.join(root, "auth"); const envExamplePath = path.join(authDir, ".env.example"); @@ -347,7 +386,7 @@ export async function configureAuthLocalEnv( const parsed = parseEnvString(raw); - const { env, shared } = buildAuthEnv(parsed, "local", oauth); + const { env, shared } = buildAuthEnv(parsed, "local", oauth, adminMode); writeEnvFile(envPath, env); diff --git a/src/prompts/projectSetup.test.ts b/src/prompts/projectSetup.test.ts index 661e339..a581332 100644 --- a/src/prompts/projectSetup.test.ts +++ b/src/prompts/projectSetup.test.ts @@ -136,14 +136,13 @@ describe("runManagedTemplatePrompts", () => { }); describe("runProjectSetupPrompts", () => { - it("runs the full docker + admin-image flow with no preselection", async () => { + it("runs the full docker + API-served console flow with no preselection", async () => { mockSelect({ "Web example": "web-a", "Backend framework": "api-a", "How would you like to run SeamlessAuth?": "docker", - "Admin dashboard source": "image", + "How would you like to host the admin console?": "api", }); - mockConfirm({ "Include Admin Dashboard?": true }); const result = await runProjectSetupPrompts(fullRegistry()); @@ -154,16 +153,16 @@ describe("runProjectSetupPrompts", () => { apiTemplateId: "api-a", authMode: "docker", useDocker: true, - includeAdmin: true, - adminMode: "image", + adminMode: "api", }); + expect(confirm).not.toHaveBeenCalled(); }); it("uses preselected template ids and logs them instead of prompting", async () => { mockSelect({ "How would you like to run SeamlessAuth?": "docker", + "How would you like to host the admin console?": "none", }); - mockConfirm({ "Include Admin Dashboard?": false }); const result = await runProjectSetupPrompts(fullRegistry(), { webTemplateId: "web-b", @@ -176,35 +175,41 @@ describe("runProjectSetupPrompts", () => { expect(out()).toContain("Backend: Express"); }); - it("skips the admin-source prompt and keeps the image default when admin is declined", async () => { - mockSelect({ + it("returns the chosen console hosting mode", async () => { + const calls = mockSelect({ "Web example": "web-a", "Backend framework": "api-a", "How would you like to run SeamlessAuth?": "docker", + "How would you like to host the admin console?": "source", }); - mockConfirm({ "Include Admin Dashboard?": false }); const result = await runProjectSetupPrompts(fullRegistry()); - expect(result.includeAdmin).toBe(false); - expect(result.adminMode).toBe("image"); - expect(select).not.toHaveBeenCalledWith( - expect.objectContaining({ message: "Admin dashboard source" }), - ); + expect(result.adminMode).toBe("source"); + + // The console prompt offers all four hosting options, defaulting to api. + const consoleCall = calls.find( + (c) => c.message === "How would you like to host the admin console?", + )!; + expect(consoleCall.options.map((o) => o.value)).toEqual([ + "api", + "image", + "source", + "none", + ]); }); - it("selects the source admin mode when chosen", async () => { + it("selects the standalone image console mode when chosen", async () => { mockSelect({ "Web example": "web-a", "Backend framework": "api-a", "How would you like to run SeamlessAuth?": "docker", - "Admin dashboard source": "source", + "How would you like to host the admin console?": "image", }); - mockConfirm({ "Include Admin Dashboard?": true }); const result = await runProjectSetupPrompts(fullRegistry()); - expect(result.adminMode).toBe("source"); + expect(result.adminMode).toBe("image"); }); it("confirms docker is required when local auth mode is chosen and accepted", async () => { @@ -212,10 +217,9 @@ describe("runProjectSetupPrompts", () => { "Web example": "web-a", "Backend framework": "api-a", "How would you like to run SeamlessAuth?": "local", - "Admin dashboard source": "image", + "How would you like to host the admin console?": "api", }); mockConfirm({ - "Include Admin Dashboard?": true, "Auth server still requires Docker for full stack. Enable Docker?": true, }); @@ -231,10 +235,9 @@ describe("runProjectSetupPrompts", () => { "Web example": "web-a", "Backend framework": "api-a", "How would you like to run SeamlessAuth?": "local", - "Admin dashboard source": "image", + "How would you like to host the admin console?": "api", }); mockConfirm({ - "Include Admin Dashboard?": true, "Auth server still requires Docker for full stack. Enable Docker?": false, }); diff --git a/src/prompts/projectSetup.ts b/src/prompts/projectSetup.ts index 530d8a3..2763366 100644 --- a/src/prompts/projectSetup.ts +++ b/src/prompts/projectSetup.ts @@ -3,7 +3,7 @@ import { confirm, select } from "@clack/prompts"; import type { RegistryEntry, TemplateKind } from "../core/templates.js"; type AuthMode = "local" | "docker"; -type AdminMode = "image" | "source"; +type AdminMode = "api" | "image" | "source" | "none"; interface Option { value: string; @@ -108,28 +108,28 @@ export async function runProjectSetupPrompts( ], })) as AuthMode; - const includeAdmin = await confirm({ - message: "Include Admin Dashboard?", - initialValue: true, - }); - - let adminMode: AdminMode = "image"; - - if (includeAdmin) { - adminMode = (await select({ - message: "Admin dashboard source", - options: [ - { - value: "image", - label: "Use official Docker image (recommended)", - }, - { - value: "source", - label: "Clone repo for modification", - }, - ], - })) as AdminMode; - } + const adminMode = (await select({ + message: "How would you like to host the admin console?", + options: [ + { + value: "api", + label: "Served by your API at /console (recommended)", + }, + { + value: "image", + label: "Separate container — official Docker image", + }, + { + value: "source", + label: "Separate container — clone repo for modification", + }, + { + value: "none", + label: "Don't include the admin console", + }, + ], + initialValue: "api", + })) as AdminMode; if (authMode === "local") { const confirmDocker = await confirm({ @@ -155,7 +155,6 @@ export async function runProjectSetupPrompts( authMode, useDocker: true, - includeAdmin, adminMode, }; }