Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/bootstrap-admin-api-url.md
Original file line number Diff line number Diff line change
@@ -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 <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).
11 changes: 11 additions & 0 deletions .changeset/scaffold-email-otp-default.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 20 additions & 32 deletions src/commands/bootstrapAdmin.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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();
Expand Down Expand Up @@ -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: {} }),
Expand All @@ -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(["[email protected]"]);
await runBootstrapAdmin([
"--api-url",
"http://localhost:4000",
"[email protected]",
]);

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", "[email protected]"]);
await runBootstrapAdmin(["[email protected]"]);

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(["[email protected]"]);
await runBootstrapAdmin([
"--api-url",
"https://flag.example.com",
"[email protected]",
]);

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(),
);
});
Expand Down
28 changes: 13 additions & 15 deletions src/commands/bootstrapAdmin.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -48,7 +46,7 @@ export async function runBootstrapAdmin(args: string[] = []) {
return;
}

const apiUrl = resolveApiUrl(profileFlag);
const apiUrl = resolveApiUrl(apiUrlFlag);

let secret = resolveBootstrapSecret();

Expand Down
12 changes: 6 additions & 6 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ USAGE

seamless init [project-name] [--<example>]
seamless check
seamless bootstrap-admin [email] [--profile <name>]
seamless bootstrap-admin [email] [--api-url <url>]
seamless verify [--api-only] [--filter=<flow>] [--keep-up]
seamless profile <list|add|use|remove>
seamless login [identifier] [--identifier <email>] [--local] [--profile <name>]
Expand Down Expand Up @@ -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 <name>]
bootstrap-admin [email] [--api-url <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
Expand All @@ -176,7 +176,7 @@ COMMANDS
Examples:
seamless bootstrap-admin
seamless bootstrap-admin [email protected]
seamless bootstrap-admin [email protected] --profile prod
seamless bootstrap-admin [email protected] --api-url http://localhost:3000

────────────────────────────────────────────

Expand Down
4 changes: 2 additions & 2 deletions src/generators/docker/docker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand All @@ -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");
});
});

Expand Down
5 changes: 5 additions & 0 deletions src/generators/docker/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading