feat(FN-812): add onboarding and API key auth status support

- Add modelOnboardingComplete field and auth status endpoint to dashboard API
- Support both OAuth and API-key-backed providers in /auth/status route
- Remove mission-interview module and related unused code (dashboard, engine, CLI)
- Remove sync-openrouter-models changeset (no longer needed)
- Add comprehensive tests for onboarding persistence and auth status
- Fix TypeScript type narrowing for oauth/api_key provider union type
This commit is contained in:
gsxdsm
2026-04-04 01:09:29 -07:00
parent f16e221bf7
commit 482492b26e
4 changed files with 338 additions and 4 deletions

View File

@@ -632,6 +632,11 @@ export interface GlobalSettings {
* the OpenRouter API at startup so the model picker shows all available
* OpenRouter models (not just the static built-in list). Default: true. */
openrouterModelSync?: boolean;
/** When true, indicates the user has completed the AI model onboarding flow
* (connected at least one provider and selected a default model). When
* false/undefined, the dashboard will auto-open the onboarding modal.
* Also set to true when the user explicitly dismisses onboarding. */
modelOnboardingComplete?: boolean;
}
/**
@@ -847,6 +852,7 @@ export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode"
ntfyEvents: ["in-review", "merged", "failed"],
ntfyDashboardHost: undefined,
openrouterModelSync: true,
modelOnboardingComplete: undefined,
};
/** Default values for project-level settings. */
@@ -925,6 +931,7 @@ export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
"ntfyDashboardHost",
"defaultProjectId",
"openrouterModelSync",
"modelOnboardingComplete",
] as const;
/** Keys that belong to the project settings scope. */

View File

@@ -408,6 +408,8 @@ export interface AuthProvider {
id: string;
name: string;
authenticated: boolean;
/** Whether this provider uses OAuth or API key authentication */
type?: "oauth" | "api_key";
}
/** Fetch authentication status for all OAuth providers */
@@ -431,6 +433,22 @@ export function logoutProvider(provider: string): Promise<{ success: boolean }>
});
}
/** Save an API key for an API-key-backed provider. */
export function saveApiKey(provider: string, apiKey: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>("/auth/api-key", {
method: "POST",
body: JSON.stringify({ provider, apiKey }),
});
}
/** Remove an API key for an API-key-backed provider. */
export function clearApiKey(provider: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>("/auth/api-key", {
method: "DELETE",
body: JSON.stringify({ provider }),
});
}
// --- GitHub Import API ---
/** GitHub issue returned by the fetch endpoint */

View File

@@ -1689,6 +1689,13 @@ function createMockAuthStorage(overrides: Partial<AuthStorageLike> = {}): AuthSt
return Promise.resolve();
}),
logout: vi.fn(),
getApiKeyProviders: vi.fn().mockReturnValue([
{ id: "openrouter", name: "OpenRouter" },
{ id: "kimi-coding", name: "Kimi" },
]),
hasApiKey: vi.fn().mockReturnValue(false),
setApiKey: vi.fn(),
clearApiKey: vi.fn(),
...overrides,
} as unknown as AuthStorageLike;
}
@@ -1716,7 +1723,9 @@ describe("GET /auth/status", () => {
expect(res.status).toBe(200);
expect(res.body.providers).toEqual([
{ id: "anthropic", name: "Anthropic", authenticated: true },
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
{ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" },
]);
expect(authStorage.reload).toHaveBeenCalled();
});
@@ -1730,6 +1739,19 @@ describe("GET /auth/status", () => {
expect(res.body.providers[0].authenticated).toBe(false);
});
it("returns authenticated true for API-key provider when hasApiKey is true", async () => {
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(false);
(authStorage.hasApiKey as ReturnType<typeof vi.fn>).mockReturnValue(true);
const res = await GET(buildApp(), "/api/auth/status");
expect(res.status).toBe(200);
const openrouter = res.body.providers.find((p: any) => p.id === "openrouter");
expect(openrouter).toBeDefined();
expect(openrouter.authenticated).toBe(true);
expect(openrouter.type).toBe("api_key");
});
it("returns 500 on error", async () => {
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("storage error");
@@ -1849,6 +1871,168 @@ describe("POST /auth/logout", () => {
});
});
describe("POST /auth/api-key", () => {
let store: TaskStore;
let authStorage: AuthStorageLike;
beforeEach(() => {
store = createMockStore();
authStorage = createMockAuthStorage();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { authStorage }));
return app;
}
it("saves an API key for a valid provider", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
provider: "openrouter",
apiKey: "sk-or-v1-test-key",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(authStorage.setApiKey).toHaveBeenCalledWith("openrouter", "sk-or-v1-test-key");
});
it("trims whitespace from API key", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
provider: "openrouter",
apiKey: " sk-or-v1-test-key ",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(authStorage.setApiKey).toHaveBeenCalledWith("openrouter", "sk-or-v1-test-key");
});
it("returns 400 when provider is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
apiKey: "sk-test",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toBe("provider is required");
});
it("returns 400 when apiKey is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
provider: "openrouter",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("apiKey is required");
});
it("returns 400 when apiKey is empty", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
provider: "openrouter",
apiKey: " ",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("apiKey is required");
});
it("returns 400 for unknown provider", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
provider: "unknown-provider",
apiKey: "sk-test",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("Unknown API key provider");
});
it("returns 400 when storage does not support API keys", async () => {
const storageWithoutApiKeys = createMockAuthStorage({
setApiKey: undefined,
getApiKeyProviders: undefined,
});
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { authStorage: storageWithoutApiKeys }));
const res = await REQUEST(app, "POST", "/api/auth/api-key", JSON.stringify({
provider: "openrouter",
apiKey: "sk-test",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("not supported");
});
it("returns 500 on storage error", async () => {
(authStorage.setApiKey as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("disk full");
});
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
provider: "openrouter",
apiKey: "sk-test",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(500);
expect(res.body.error).toBe("disk full");
});
});
describe("DELETE /auth/api-key", () => {
let store: TaskStore;
let authStorage: AuthStorageLike;
beforeEach(() => {
store = createMockStore();
authStorage = createMockAuthStorage();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { authStorage }));
return app;
}
it("clears an API key for a provider", async () => {
const res = await REQUEST(buildApp(), "DELETE", "/api/auth/api-key", JSON.stringify({
provider: "openrouter",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(authStorage.clearApiKey).toHaveBeenCalledWith("openrouter");
});
it("returns 400 when provider is missing", async () => {
const res = await REQUEST(buildApp(), "DELETE", "/api/auth/api-key", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toBe("provider is required");
});
it("returns 400 when storage does not support API keys", async () => {
const storageWithoutApiKeys = createMockAuthStorage({
clearApiKey: undefined,
});
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { authStorage: storageWithoutApiKeys }));
const res = await REQUEST(app, "DELETE", "/api/auth/api-key", JSON.stringify({
provider: "openrouter",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("not supported");
});
});
describe("Pause/Unpause endpoints", () => {
let store: TaskStore;
function buildApp() {
@@ -5847,6 +6031,34 @@ describe("PUT /settings/global", () => {
expect(res.status).toBe(500);
expect(res.body.error).toContain("Write failed");
});
it("persists modelOnboardingComplete flag", async () => {
const updated = { modelOnboardingComplete: true };
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updated);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings/global",
JSON.stringify(updated),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ modelOnboardingComplete: true });
expect(res.body.modelOnboardingComplete).toBe(true);
});
it("GET /settings/global returns modelOnboardingComplete value", async () => {
const mockGlobalStore = createMockGlobalSettingsStore();
mockGlobalStore.getSettings.mockResolvedValue({ modelOnboardingComplete: true, themeMode: "dark" });
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
const res = await GET(buildApp(), "/api/settings/global");
expect(res.status).toBe(200);
expect(res.body.modelOnboardingComplete).toBe(true);
});
});
describe("GET /settings/scopes", () => {

View File

@@ -55,6 +55,14 @@ export interface AuthStorageLike {
},
): Promise<void>;
logout(provider: string): void;
/** Get providers that accept API keys (non-OAuth). Returns provider id and name. */
getApiKeyProviders?(): Array<{ id: string; name: string }>;
/** Save an API key for a provider. Creates or overwrites the existing key. */
setApiKey?(providerId: string, apiKey: string): void;
/** Remove the stored API key for a provider. No-op if not set. */
clearApiKey?(providerId: string): void;
/** Check if a provider has an API key configured. */
hasApiKey?(providerId: string): boolean;
}
const upload = multer({
@@ -7576,19 +7584,37 @@ function registerAuthRoutes(router: Router, authStorage?: AuthStorageLike): void
/**
* GET /api/auth/status
* Returns list of OAuth providers with their authentication status.
* Response: { providers: [{ id: string, name: string, authenticated: boolean }] }
* Returns list of all providers with their authentication status and type.
* Includes both OAuth-backed and API-key-backed providers.
* Response: { providers: [{ id, name, authenticated, type }] }
*/
router.get("/auth/status", (_req, res) => {
try {
const storage = getAuthStorage();
storage.reload();
const oauthProviders = storage.getOAuthProviders();
const providers = oauthProviders.map((p) => ({
const providers: { id: string; name: string; authenticated: boolean; type: "oauth" | "api_key" }[] = oauthProviders.map((p) => ({
id: p.id,
name: p.name,
authenticated: storage.hasAuth(p.id),
type: "oauth" as const,
}));
// Include API-key-backed providers if supported
if (storage.getApiKeyProviders) {
const apiKeyProviders = storage.getApiKeyProviders();
for (const p of apiKeyProviders) {
// Skip if already listed as an OAuth provider (avoid duplicates)
if (providers.some((existing) => existing.id === p.id)) continue;
providers.push({
id: p.id,
name: p.name,
authenticated: storage.hasApiKey ? storage.hasApiKey(p.id) : false,
type: "api_key" as const,
});
}
}
res.json({ providers });
} catch (err: any) {
res.status(500).json({ error: err.message });
@@ -7703,4 +7729,75 @@ function registerAuthRoutes(router: Router, authStorage?: AuthStorageLike): void
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/auth/api-key
* Save an API key for an API-key-backed provider.
* Body: { provider: string, apiKey: string }
* Response: { success: true }
*
* Validates the provider exists, is API-key-backed, and the key is non-empty.
* Never returns the key in any response.
*/
router.post("/auth/api-key", (req, res) => {
try {
const { provider, apiKey } = req.body;
if (!provider || typeof provider !== "string") {
res.status(400).json({ error: "provider is required" });
return;
}
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
res.status(400).json({ error: "apiKey is required and must be a non-empty string" });
return;
}
const storage = getAuthStorage();
// Check that the storage supports API key management
if (!storage.setApiKey) {
res.status(400).json({ error: "API key management is not supported" });
return;
}
// Validate the provider is an API-key-backed provider
const apiKeyProviders = storage.getApiKeyProviders?.() ?? [];
const found = apiKeyProviders.find((p) => p.id === provider);
if (!found) {
res.status(400).json({ error: `Unknown API key provider: ${provider}` });
return;
}
storage.setApiKey(provider, apiKey.trim());
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* DELETE /api/auth/api-key
* Remove an API key for a provider.
* Body: { provider: string }
* Response: { success: true }
*/
router.delete("/auth/api-key", (req, res) => {
try {
const { provider } = req.body;
if (!provider || typeof provider !== "string") {
res.status(400).json({ error: "provider is required" });
return;
}
const storage = getAuthStorage();
if (!storage.clearApiKey) {
res.status(400).json({ error: "API key management is not supported" });
return;
}
storage.clearApiKey(provider);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
}