diff --git a/.changeset/bootstrap-admin-api-url.md b/.changeset/bootstrap-admin-api-url.md new file mode 100644 index 0000000..b94afe8 --- /dev/null +++ b/.changeset/bootstrap-admin-api-url.md @@ -0,0 +1,17 @@ +--- +"seamless-cli": patch +--- + +Fix `seamless bootstrap-admin` to target the app API instead of the login profile's auth server. + +The bootstrap invite route (`/auth/internal/bootstrap/admin-invite`) and its +delivery are exposed by the app API (the SeamlessAuth server adapter), not the +auth server directly — the auth server does not serve that path. Previously +`bootstrap-admin` fell back to the active profile's `instanceUrl`, so once a +profile pointed at the auth server (as `seamless login` and the admin commands +require), bootstrap requests 404'd. + +`bootstrap-admin` now resolves its target independently of any profile: +`--api-url ` → `SEAMLESS_API_URL` → the local default `http://localhost:3000`. +The `--profile` flag is removed from this command (it no longer affects the +target; the bootstrap secret is still resolved from the local project). diff --git a/.changeset/scaffold-email-otp-default.md b/.changeset/scaffold-email-otp-default.md new file mode 100644 index 0000000..eb72c54 --- /dev/null +++ b/.changeset/scaffold-email-otp-default.md @@ -0,0 +1,11 @@ +--- +"seamless-cli": minor +--- + +Enable `email_otp` in the scaffolded auth server's default login methods. + +The auth server's own default (`passkey,magic_link`) has no method the CLI can +drive without a browser authenticator, so `seamless login` could not sign in to +a freshly scaffolded local stack. `buildAuthEnv` now appends `email_otp` to +`LOGIN_METHODS` (composing with the OAuth method when providers are configured), +so email-OTP login works out of the box. diff --git a/src/commands/bootstrapAdmin.test.ts b/src/commands/bootstrapAdmin.test.ts index 7f803d6..1b8c271 100644 --- a/src/commands/bootstrapAdmin.test.ts +++ b/src/commands/bootstrapAdmin.test.ts @@ -1,17 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveBootstrapSecret } from "../core/bootstrapSecret.js"; -import { getActiveProfile } from "../core/config.js"; import { runBootstrapAdmin } from "./bootstrapAdmin.js"; vi.mock("../core/bootstrapSecret.js", () => ({ resolveBootstrapSecret: vi.fn(), })); -vi.mock("../core/config.js", () => ({ - getActiveProfile: vi.fn(), -})); - vi.mock("@clack/prompts", () => ({ intro: vi.fn(), outro: vi.fn(), @@ -34,14 +29,12 @@ beforeEach(() => { errors = []; vi.mocked(resolveBootstrapSecret).mockReset(); - vi.mocked(getActiveProfile).mockReset(); vi.mocked(intro).mockReset(); vi.mocked(outro).mockReset(); vi.mocked(text).mockReset(); vi.mocked(confirm).mockReset(); vi.mocked(spinner).mockReset(); - vi.mocked(getActiveProfile).mockReturnValue(undefined); vi.mocked(confirm).mockResolvedValue(true); spinnerStart = vi.fn(); @@ -140,7 +133,7 @@ describe("runBootstrapAdmin — confirm cancellation", () => { }); describe("runBootstrapAdmin — API URL resolution", () => { - it("defaults to localhost:3000 when no profile or override is set", async () => { + it("defaults to the local app API at localhost:3000 when no override is set", async () => { vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ data: {} }), @@ -154,59 +147,54 @@ describe("runBootstrapAdmin — API URL resolution", () => { ); }); - it("targets the active profile's instance URL", async () => { - vi.mocked(getActiveProfile).mockReturnValue({ - name: "prod", - instanceUrl: "https://auth.prod.example.com", - }); + it("targets the --api-url flag when provided", async () => { vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ data: {} }), } as Response); - await runBootstrapAdmin(["admin@example.com"]); + await runBootstrapAdmin([ + "--api-url", + "http://localhost:4000", + "admin@example.com", + ]); expect(fetch).toHaveBeenCalledWith( - "https://auth.prod.example.com/auth/internal/bootstrap/admin-invite", + "http://localhost:4000/auth/internal/bootstrap/admin-invite", expect.anything(), ); }); - it("resolves the profile named by the --profile flag", async () => { - vi.mocked(getActiveProfile).mockReturnValue({ - name: "staging", - instanceUrl: "https://auth.staging.example.com", - }); + it("lets SEAMLESS_API_URL override the default", async () => { + process.env.SEAMLESS_API_URL = "https://api.example.com"; vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ data: {} }), } as Response); - await runBootstrapAdmin(["--profile", "staging", "admin@example.com"]); + await runBootstrapAdmin(["admin@example.com"]); - expect(getActiveProfile).toHaveBeenCalledWith({ profileFlag: "staging" }); expect(fetch).toHaveBeenCalledWith( - "https://auth.staging.example.com/auth/internal/bootstrap/admin-invite", + "https://api.example.com/auth/internal/bootstrap/admin-invite", expect.anything(), ); }); - it("lets SEAMLESS_API_URL override the profile", async () => { - process.env.SEAMLESS_API_URL = "https://api.example.com"; - vi.mocked(getActiveProfile).mockReturnValue({ - name: "prod", - instanceUrl: "https://auth.prod.example.com", - }); + it("prefers --api-url over SEAMLESS_API_URL", async () => { + process.env.SEAMLESS_API_URL = "https://env.example.com"; vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ data: {} }), } as Response); - await runBootstrapAdmin(["admin@example.com"]); + await runBootstrapAdmin([ + "--api-url", + "https://flag.example.com", + "admin@example.com", + ]); - expect(getActiveProfile).not.toHaveBeenCalled(); expect(fetch).toHaveBeenCalledWith( - "https://api.example.com/auth/internal/bootstrap/admin-invite", + "https://flag.example.com/auth/internal/bootstrap/admin-invite", expect.anything(), ); }); diff --git a/src/commands/bootstrapAdmin.ts b/src/commands/bootstrapAdmin.ts index 05671a3..4937578 100644 --- a/src/commands/bootstrapAdmin.ts +++ b/src/commands/bootstrapAdmin.ts @@ -1,29 +1,27 @@ import { intro, outro, text, confirm, spinner } from "@clack/prompts"; import kleur from "kleur"; import { extractFlag } from "../core/args.js"; -import { getActiveProfile } from "../core/config.js"; import { resolveBootstrapSecret } from "../core/bootstrapSecret.js"; const DEFAULT_API_URL = "http://localhost:3000"; -// Bootstrap authenticates with the shared bootstrap secret (not a user -// session), so the profile is only used to target the right instance. The -// SEAMLESS_API_URL env override wins for backward compatibility; otherwise fall -// back to the local dev default when no profile is configured. -function resolveApiUrl(profileFlag?: string): string { - const override = process.env.SEAMLESS_API_URL?.trim(); - if (override) return override; - - const profile = getActiveProfile({ profileFlag }); - if (profile) return profile.instanceUrl; - - return DEFAULT_API_URL; +// Bootstrap targets the app API (the SeamlessAuth server adapter), which is what +// exposes /auth/internal/bootstrap/admin-invite and delivers the invite. That is a +// different service from a login profile's auth-server URL, which does not serve +// that route — so this resolves independently of any profile: an explicit +// --api-url wins, then SEAMLESS_API_URL, then the local dev default. +function resolveApiUrl(apiUrlFlag?: string): string { + return ( + apiUrlFlag?.trim() || + process.env.SEAMLESS_API_URL?.trim() || + DEFAULT_API_URL + ); } export async function runBootstrapAdmin(args: string[] = []) { intro("Seamless Auth Bootstrap"); - const { value: profileFlag, rest } = extractFlag(args, "profile"); + const { value: apiUrlFlag, rest } = extractFlag(args, "api-url"); let email = rest.find((a) => !a.startsWith("-")); if (!email) { @@ -48,7 +46,7 @@ export async function runBootstrapAdmin(args: string[] = []) { return; } - const apiUrl = resolveApiUrl(profileFlag); + const apiUrl = resolveApiUrl(apiUrlFlag); let secret = resolveBootstrapSecret(); diff --git a/src/commands/help.ts b/src/commands/help.ts index fa7933e..c2cdeb3 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -12,7 +12,7 @@ USAGE seamless init [project-name] [--] seamless check - seamless bootstrap-admin [email] [--profile ] + seamless bootstrap-admin [email] [--api-url ] seamless verify [--api-only] [--filter=] [--keep-up] seamless profile seamless login [identifier] [--identifier ] [--local] [--profile ] @@ -159,12 +159,12 @@ COMMANDS seamless-auth-server, override with SEAMLESS_SERVER_DIR) instead of the published npm packages — so you can catch SDK regressions before publishing. - bootstrap-admin [email] [--profile ] + bootstrap-admin [email] [--api-url ] Create a bootstrap admin invite - Targets the active profile's instance URL (override with --profile or - SEAMLESS_API_URL). Falls back to http://localhost:3000 when no profile is - configured. + Targets your app API (the SeamlessAuth server adapter), which exposes the + bootstrap route and delivers the invite — not the auth server directly. + Defaults to http://localhost:3000; override with --api-url or SEAMLESS_API_URL. Automatically resolves bootstrap secret from: • .env @@ -176,7 +176,7 @@ COMMANDS Examples: seamless bootstrap-admin seamless bootstrap-admin admin@example.com - seamless bootstrap-admin admin@example.com --profile prod + seamless bootstrap-admin admin@example.com --api-url http://localhost:3000 ──────────────────────────────────────────── diff --git a/src/generators/docker/docker.test.ts b/src/generators/docker/docker.test.ts index ac0fcdd..2e37ab8 100644 --- a/src/generators/docker/docker.test.ts +++ b/src/generators/docker/docker.test.ts @@ -96,7 +96,7 @@ describe("buildAuthEnv", () => { 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"); - expect(env.LOGIN_METHODS).toBeUndefined(); + expect(env.LOGIN_METHODS).toBe("passkey,magic_link,email_otp"); expect(shared.kid).toBe("dev-main"); }); @@ -114,7 +114,7 @@ describe("buildAuthEnv", () => { const providers = JSON.parse(env.OAUTH_PROVIDERS); expect(providers).toHaveLength(1); expect(providers[0]).toMatchObject({ id: "google", enabled: true }); - expect(env.LOGIN_METHODS).toBe("passkey,magic_link,oauth"); + expect(env.LOGIN_METHODS).toBe("passkey,magic_link,email_otp,oauth"); }); }); diff --git a/src/generators/docker/docker.ts b/src/generators/docker/docker.ts index 40189f1..80803c1 100644 --- a/src/generators/docker/docker.ts +++ b/src/generators/docker/docker.ts @@ -258,6 +258,11 @@ export function buildAuthEnv( env.APP_ORIGINS = "http://localhost:3000"; env.ORIGINS = "http://localhost:5173,http://localhost:5174"; + // 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 + // method the CLI can drive without a browser authenticator. + env.LOGIN_METHODS = withLoginMethod(env.LOGIN_METHODS, "email_otp"); + // When the OAuth template collected providers, wire them into the auth server // (OAUTH_PROVIDERS, per-provider secret env vars, OAUTH_STATE_SECRET) and enable // the oauth login method.