From b32ba095fc68afc610d482f6d9d431168dbfaf28 Mon Sep 17 00:00:00 2001 From: jplorier Date: Thu, 7 May 2026 18:30:30 -0300 Subject: [PATCH 1/3] fix: setup wizard auth, workspace filtering, and post-setup login redirect - Allow unauthenticated setup wizard requests until first user logs in (hasAnyUser gate replaces per-endpoint isSetupComplete bypasses) - Redirect to /login after setup completion instead of directly into app - Fix listSecrets and task/workflow/config list queries excluding NULL workspace_id rows, making secrets and jobs invisible after login - Increase API readiness/liveness probe delays to prevent premature restarts Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/plugins/auth.ts | 57 +++++++++++++++++--- apps/api/src/services/secret-service.ts | 7 ++- apps/api/src/services/task-config-service.ts | 4 +- apps/api/src/services/task-service.ts | 8 +-- apps/api/src/services/workflow-service.ts | 12 +++-- apps/web/src/app/setup/page.tsx | 11 ++-- helm/optio/templates/api-deployment.yaml | 4 +- 7 files changed, 75 insertions(+), 28 deletions(-) diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 6697336c..0879c872 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -5,6 +5,8 @@ import { validateApiKey } from "../services/api-key-service.js"; import { isAuthDisabled } from "../services/oauth/index.js"; import { getUserRole, ensureUserHasWorkspace } from "../services/workspace-service.js"; import { listSecrets } from "../services/secret-service.js"; +import { db } from "../db/client.js"; +import { users } from "../db/schema.js"; import type { WorkspaceRole } from "@optio/shared"; import { emitAuthFailureLog } from "../telemetry/logs.js"; @@ -30,6 +32,10 @@ export function requireRole(minimumRole: WorkspaceRole) { // Auth disabled — allow everything (local dev) if (isAuthDisabled()) return; + // No session: allow through while no user accounts exist (setup wizard phase). + // Once the first user logs in via OAuth the gate closes permanently. + if (!req.user && !(await hasAnyUser())) return; + const role = req.user?.workspaceRole; const level = role ? (ROLE_LEVEL[role] ?? 0) : 0; @@ -126,6 +132,46 @@ export function resetSetupCompleteCache(): void { _setupCompleteCache = null; } +let _hasAnyUserCache: { value: boolean; expires: number } | null = null; +const HAS_ANY_USER_CACHE_TTL_MS = 30_000; // 30 seconds + +/** + * Returns true when at least one user account exists in the database. + * Used as the gate for the setup wizard bypass: all wizard endpoints are + * open until the first user logs in via OAuth, after which normal auth + * applies everywhere. Result is cached for 30 seconds. + */ +async function hasAnyUser(): Promise { + const now = Date.now(); + if (_hasAnyUserCache && now < _hasAnyUserCache.expires) { + return _hasAnyUserCache.value; + } + try { + const [row] = await db.select({ id: users.id }).from(users).limit(1); + const value = !!row; + _hasAnyUserCache = { value, expires: now + HAS_ANY_USER_CACHE_TTL_MS }; + return value; + } catch { + return false; + } +} + +/** + * All endpoints the setup wizard writes to. These are open to unauthenticated + * requests while no user accounts exist (initial setup phase). + */ +function isSetupWizardEndpoint(method: string, url: string): boolean { + const path = url.split("?")[0]; + if (path.startsWith("/api/setup/")) return true; + if (method !== "POST") return false; + return ( + path === "/api/secrets" || + path === "/api/repos" || + path === "/api/prompt-templates" || + path === "/api/tickets/providers" + ); +} + export function isPublicRoute(url: string): boolean { const path = url.split("?")[0]; if (PUBLIC_ROUTES.has(path) || PUBLIC_AUTH_ROUTES.has(path)) return true; @@ -151,13 +197,10 @@ async function authPlugin(app: FastifyInstance) { // Public routes — no auth needed if (isPublicRoute(req.url)) return; - // Setup routes (other than /status) are public only before initial setup. - // Once setup is complete they require authentication like any other route. - if (req.url.startsWith("/api/setup/")) { - const complete = await isSetupComplete(); - if (!complete) return; // Allow without auth during initial setup - // Fall through to normal auth check - } + // Setup wizard endpoints are open while no user accounts exist yet. + // Once the first user logs in via OAuth, hasAnyUser() returns true and + // this bypass closes permanently — all requests require a valid session. + if (isSetupWizardEndpoint(req.method, req.url) && !(await hasAnyUser())) return; // Token resolution order: Bearer header → session cookie // Note: WebSocket auth is handled separately by authenticateWs() in ws-auth.ts diff --git a/apps/api/src/services/secret-service.ts b/apps/api/src/services/secret-service.ts index 7f5b10cf..8a230dc1 100644 --- a/apps/api/src/services/secret-service.ts +++ b/apps/api/src/services/secret-service.ts @@ -232,7 +232,12 @@ export async function listSecrets( ): Promise { const conditions = []; if (scope) conditions.push(eq(secrets.scope, scope)); - if (workspaceId) conditions.push(eq(secrets.workspaceId, workspaceId)); + // Global secrets are stored with workspace_id=NULL (enforced by storeSecret). + // Filtering by workspaceId for global/unscoped queries would exclude them all. + // Only apply the workspace filter for repo-scoped secrets. + if (workspaceId && scope && scope !== "global") { + conditions.push(eq(secrets.workspaceId, workspaceId)); + } if (userId) conditions.push(eq(secrets.userId, userId)); const query = diff --git a/apps/api/src/services/task-config-service.ts b/apps/api/src/services/task-config-service.ts index 88a12016..839aca6f 100644 --- a/apps/api/src/services/task-config-service.ts +++ b/apps/api/src/services/task-config-service.ts @@ -1,4 +1,4 @@ -import { eq, and, desc, sql } from "drizzle-orm"; +import { eq, and, desc, sql, or, isNull } from "drizzle-orm"; import { db } from "../db/client.js"; import { taskConfigs, workflowTriggers } from "../db/schema.js"; import { TaskState } from "@optio/shared"; @@ -65,7 +65,7 @@ export async function getTaskConfig(id: string) { export async function listTaskConfigs(opts?: { workspaceId?: string | null }) { const conditions = []; - if (opts?.workspaceId) conditions.push(eq(taskConfigs.workspaceId, opts.workspaceId)); + if (opts?.workspaceId) conditions.push(or(eq(taskConfigs.workspaceId, opts.workspaceId), isNull(taskConfigs.workspaceId))!); let q = db.select().from(taskConfigs).orderBy(desc(taskConfigs.createdAt)); if (conditions.length > 0) q = q.where(and(...conditions)) as typeof q; diff --git a/apps/api/src/services/task-service.ts b/apps/api/src/services/task-service.ts index 1087fdc6..5b0d14c0 100644 --- a/apps/api/src/services/task-service.ts +++ b/apps/api/src/services/task-service.ts @@ -1,4 +1,4 @@ -import { eq, desc, and, or, ilike, gte, lte, sql } from "drizzle-orm"; +import { eq, desc, and, or, isNull, ilike, gte, lte, sql } from "drizzle-orm"; import { db } from "../db/client.js"; import { tasks, taskEvents, taskLogs, users, repos } from "../db/schema.js"; import { @@ -92,7 +92,7 @@ export async function listTasks(opts?: { conditions.push(eq(tasks.state, opts.state as any)); } if (opts?.workspaceId) { - conditions.push(eq(tasks.workspaceId, opts.workspaceId)); + conditions.push(or(eq(tasks.workspaceId, opts.workspaceId), isNull(tasks.workspaceId))!); } let query = db.select().from(tasks).orderBy(desc(tasks.createdAt)); @@ -130,7 +130,7 @@ export async function searchTasks(opts: SearchTasksOpts) { // Workspace filter if (opts.workspaceId) { - conditions.push(eq(tasks.workspaceId, opts.workspaceId)); + conditions.push(or(eq(tasks.workspaceId, opts.workspaceId), isNull(tasks.workspaceId))!); } // Full-text search on title and prompt @@ -720,7 +720,7 @@ export async function getRepoConfig(repoUrl: string) { export async function getTaskStats(workspaceId?: string | null) { const conditions = []; if (workspaceId) { - conditions.push(eq(tasks.workspaceId, workspaceId)); + conditions.push(or(eq(tasks.workspaceId, workspaceId), isNull(tasks.workspaceId))!); } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; diff --git a/apps/api/src/services/workflow-service.ts b/apps/api/src/services/workflow-service.ts index 94e5ff79..28a6b8d8 100644 --- a/apps/api/src/services/workflow-service.ts +++ b/apps/api/src/services/workflow-service.ts @@ -1,4 +1,4 @@ -import { eq, desc, sql, and, lte } from "drizzle-orm"; +import { eq, desc, sql, and, lte, or, isNull } from "drizzle-orm"; import { CronExpressionParser } from "cron-parser"; import { db } from "../db/client.js"; import { @@ -16,7 +16,7 @@ import { logger } from "../logger.js"; export async function listWorkflows(workspaceId?: string) { const conditions = []; - if (workspaceId) conditions.push(eq(workflows.workspaceId, workspaceId)); + if (workspaceId) conditions.push(or(eq(workflows.workspaceId, workspaceId), isNull(workflows.workspaceId))!); const baseQuery = db.select().from(workflows).orderBy(desc(workflows.createdAt)); if (conditions.length > 0) { @@ -158,7 +158,9 @@ export async function cloneWorkflow( * `getTaskStats()` so the frontend can treat it symmetrically. */ export async function getWorkflowRunStats(workspaceId?: string | null) { - const wsFilter = workspaceId ? sql`AND w.workspace_id = ${workspaceId}` : sql``; + const wsFilter = workspaceId + ? sql`AND (w.workspace_id = ${workspaceId} OR w.workspace_id IS NULL)` + : sql``; const rows = await db.execute<{ state: string; count: string }>(sql` SELECT wr.state, COUNT(*)::text AS count @@ -197,7 +199,9 @@ export async function getWorkflowRunStats(workspaceId?: string | null) { } export async function listWorkflowsWithStats(workspaceId?: string) { - const wsFilter = workspaceId ? sql`AND w.workspace_id = ${workspaceId}` : sql``; + const wsFilter = workspaceId + ? sql`AND (w.workspace_id = ${workspaceId} OR w.workspace_id IS NULL)` + : sql``; const rows = await db.execute<{ id: string; diff --git a/apps/web/src/app/setup/page.tsx b/apps/web/src/app/setup/page.tsx index 423eb25c..3210105b 100644 --- a/apps/web/src/app/setup/page.tsx +++ b/apps/web/src/app/setup/page.tsx @@ -2582,18 +2582,13 @@ export default function SetupPage() { )} +

Log in to start using Optio.

-
diff --git a/helm/optio/templates/api-deployment.yaml b/helm/optio/templates/api-deployment.yaml index b85ab7d6..8547589d 100644 --- a/helm/optio/templates/api-deployment.yaml +++ b/helm/optio/templates/api-deployment.yaml @@ -119,13 +119,13 @@ spec: httpGet: path: /api/health port: {{ .Values.api.port }} - initialDelaySeconds: 10 + initialDelaySeconds: 120 periodSeconds: 10 livenessProbe: httpGet: path: /api/health port: {{ .Values.api.port }} - initialDelaySeconds: 15 + initialDelaySeconds: 150 periodSeconds: 20 volumes: - name: tmp From 025003524569745611b5c833897b9a989c230a25 Mon Sep 17 00:00:00 2001 From: jplorier Date: Thu, 7 May 2026 19:33:04 -0300 Subject: [PATCH 2/3] ensure login for setup --- apps/web/src/app/auth/callback/route.ts | 16 ++++++++- apps/web/src/app/setup/page.tsx | 35 ++++++++++++++++--- .../web/src/components/layout/setup-check.tsx | 16 ++++++--- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/auth/callback/route.ts b/apps/web/src/app/auth/callback/route.ts index a8cf0673..126ac805 100644 --- a/apps/web/src/app/auth/callback/route.ts +++ b/apps/web/src/app/auth/callback/route.ts @@ -38,7 +38,21 @@ export async function GET(request: NextRequest) { const { token } = (await res.json()) as { token: string }; - const response = NextResponse.redirect(new URL("/", PUBLIC_URL)); + // Redirect to setup wizard if initial setup hasn't been completed yet + let redirectPath = "/"; + try { + const setupRes = await fetch(`${INTERNAL_API_URL}/api/setup/status`); + if (setupRes.ok) { + const setupData = (await setupRes.json()) as { isSetUp: boolean }; + if (!setupData.isSetUp) { + redirectPath = "/setup"; + } + } + } catch { + // If we can't check setup status, proceed to home + } + + const response = NextResponse.redirect(new URL(redirectPath, PUBLIC_URL)); response.cookies.set(SESSION_COOKIE_NAME, token, { path: "/", httpOnly: true, diff --git a/apps/web/src/app/setup/page.tsx b/apps/web/src/app/setup/page.tsx index 3210105b..f2c77583 100644 --- a/apps/web/src/app/setup/page.tsx +++ b/apps/web/src/app/setup/page.tsx @@ -155,6 +155,13 @@ export default function SetupPage() { const [agentSecretScope, setAgentSecretScope] = useState<"global" | "user">("global"); const [canSetGlobalSecrets, setCanSetGlobalSecrets] = useState(true); + // Logged-in user context (populated once getCurrentUser resolves) + const [currentUser, setCurrentUser] = useState<{ + displayName: string | null; + email: string | null; + avatarUrl: string | null; + } | null>(null); + // Step 6: Tickets — per-repo GitHub Issues toggles + a list of external trackers const [githubIssueRepos, setGithubIssueRepos] = useState>({}); type AddedTracker = { @@ -188,14 +195,25 @@ export default function SetupPage() { .catch(() => {}); // Determine if the current user can store global secrets. Only admins // (or anyone, when auth is disabled) may; non-admins must use user scope. + // Also redirect to /login if not authenticated (setup requires login). api .getCurrentUser() .then((res) => { const isAdmin = res.authDisabled || res.user.workspaceRole === "admin"; setCanSetGlobalSecrets(isAdmin); setAgentSecretScope(isAdmin ? "global" : "user"); + if (!res.authDisabled) { + setCurrentUser({ + displayName: res.user.displayName, + email: res.user.email, + avatarUrl: res.user.avatarUrl, + }); + } }) - .catch(() => {}); + .catch(() => { + // Not authenticated — redirect to login so the user logs in first + router.replace("/login"); + }); }, []); // Check if OAuth token is already stored when reaching the agents step @@ -766,6 +784,16 @@ export default function SetupPage() { return (
+ {/* Logged-in user indicator */} + {currentUser && ( +
+
+ {(currentUser.displayName ?? currentUser.email ?? "?")[0].toUpperCase()} +
+ {currentUser.displayName ?? currentUser.email} +
+ )} + {/* Progress */}
{STEPS.map((s, i) => ( @@ -2582,13 +2610,12 @@ export default function SetupPage() { )}
-

Log in to start using Optio.

diff --git a/apps/web/src/components/layout/setup-check.tsx b/apps/web/src/components/layout/setup-check.tsx index c0407a7b..de0d3436 100644 --- a/apps/web/src/components/layout/setup-check.tsx +++ b/apps/web/src/components/layout/setup-check.tsx @@ -10,17 +10,25 @@ export function SetupCheck() { const [checked, setChecked] = useState(false); useEffect(() => { - // Don't redirect if already on setup page - if (pathname === "/setup") { + // Don't redirect if already on setup or login page + if (pathname === "/setup" || pathname === "/login") { setChecked(true); return; } api .getSetupStatus() - .then((res) => { + .then(async (res) => { if (!res.isSetUp) { - router.replace("/setup"); + // Not set up — require login before showing the wizard + try { + await api.getCurrentUser(); + // Logged in but not set up → go to setup + router.replace("/setup"); + } catch { + // Not logged in → go to login first + router.replace("/login"); + } } }) .catch(() => { From bb640bd2c76a8b428a52d8af2500bcae128662ee Mon Sep 17 00:00:00 2001 From: jplorier Date: Mon, 11 May 2026 14:54:38 -0300 Subject: [PATCH 3/3] fix to duplicate connections --- apps/api/src/db/schema.ts | 3 +- apps/api/src/services/connection-service.ts | 67 +++++++++++---------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 8b71f29b..904848b0 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -10,6 +10,7 @@ import { boolean, customType, unique, + uniqueIndex, index, } from "drizzle-orm/pg-core"; @@ -742,7 +743,7 @@ export const connectionProviders = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ - unique("connection_providers_slug_ws_key").on(table.slug, table.workspaceId), + uniqueIndex("connection_providers_slug_ws_key").on(table.slug, table.workspaceId).nullsNotDistinct(), index("connection_providers_category_idx").on(table.category), index("connection_providers_workspace_id_idx").on(table.workspaceId), ], diff --git a/apps/api/src/services/connection-service.ts b/apps/api/src/services/connection-service.ts index fefcc3ad..75e54039 100644 --- a/apps/api/src/services/connection-service.ts +++ b/apps/api/src/services/connection-service.ts @@ -332,42 +332,43 @@ export async function createProvider( /** * Idempotent seeder: creates or updates built-in providers. - * Uses upsert on the (slug, workspaceId) unique constraint. + * + * Uses explicit check-then-update rather than onConflictDoUpdate because + * PostgreSQL standard unique indexes treat NULL != NULL, so the conflict + * target (slug, workspaceId) never fires for built-in providers whose + * workspaceId is NULL — causing a new row to be inserted on every restart. */ export async function seedBuiltInProviders(): Promise { for (const provider of BUILT_IN_PROVIDERS) { - await db - .insert(connectionProviders) - .values({ - slug: provider.slug, - name: provider.name, - description: provider.description, - icon: provider.icon, - category: provider.category, - type: provider.type, - configSchema: provider.configSchema, - requiredSecrets: provider.requiredSecrets, - mcpConfig: provider.mcpConfig ?? undefined, - capabilities: provider.capabilities, - builtIn: true, - workspaceId: undefined, // built-in providers have NULL workspaceId - }) - .onConflictDoUpdate({ - target: [connectionProviders.slug, connectionProviders.workspaceId], - set: { - name: provider.name, - description: provider.description, - icon: provider.icon, - category: provider.category, - type: provider.type, - configSchema: provider.configSchema, - requiredSecrets: provider.requiredSecrets, - mcpConfig: provider.mcpConfig ?? undefined, - capabilities: provider.capabilities, - builtIn: true, - updatedAt: new Date(), - }, - }); + const values = { + slug: provider.slug, + name: provider.name, + description: provider.description, + icon: provider.icon, + category: provider.category, + type: provider.type, + configSchema: provider.configSchema, + requiredSecrets: provider.requiredSecrets, + mcpConfig: provider.mcpConfig ?? undefined, + capabilities: provider.capabilities, + builtIn: true, + workspaceId: undefined as undefined, // built-in providers have NULL workspaceId + }; + + const [existing] = await db + .select({ id: connectionProviders.id }) + .from(connectionProviders) + .where(and(eq(connectionProviders.slug, provider.slug), isNull(connectionProviders.workspaceId))) + .limit(1); + + if (existing) { + await db + .update(connectionProviders) + .set({ ...values, updatedAt: new Date() }) + .where(eq(connectionProviders.id, existing.id)); + } else { + await db.insert(connectionProviders).values(values); + } } }