Skip to content
Open
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
3 changes: 2 additions & 1 deletion apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
boolean,
customType,
unique,
uniqueIndex,
index,
} from "drizzle-orm/pg-core";

Expand Down Expand Up @@ -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),
],
Expand Down
57 changes: 50 additions & 7 deletions apps/api/src/plugins/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

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

Expand Down Expand Up @@ -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<boolean> {
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;
Expand All @@ -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
Expand Down
67 changes: 34 additions & 33 deletions apps/api/src/services/connection-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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);
}
}
}

Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/services/secret-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,12 @@ export async function listSecrets(
): Promise<SecretRef[]> {
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 =
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/services/task-config-service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions apps/api/src/services/task-service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 8 additions & 4 deletions apps/api/src/services/workflow-service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 15 additions & 1 deletion apps/web/src/app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 31 additions & 9 deletions apps/web/src/app/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, boolean>>({});
type AddedTracker = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -766,6 +784,16 @@ export default function SetupPage() {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
<div className="w-full max-w-2xl">
{/* Logged-in user indicator */}
{currentUser && (
<div className="flex items-center justify-end gap-2 mb-4 text-sm text-text-muted">
<div className="w-6 h-6 rounded-full bg-primary/20 text-primary flex items-center justify-center text-xs font-medium">
{(currentUser.displayName ?? currentUser.email ?? "?")[0].toUpperCase()}
</div>
<span>{currentUser.displayName ?? currentUser.email}</span>
</div>
)}

{/* Progress */}
<div className="flex items-center justify-center gap-1 mb-8">
{STEPS.map((s, i) => (
Expand Down Expand Up @@ -2583,17 +2611,11 @@ export default function SetupPage() {
</div>

<div className="flex justify-center gap-3">
<button
onClick={() => router.push("/tasks/new")}
className="flex items-center gap-2 px-6 py-2.5 rounded-md bg-primary text-white text-sm font-medium hover:bg-primary-hover"
>
Create Your First Task <ArrowRight className="w-4 h-4" />
</button>
<button
onClick={() => router.push("/")}
className="flex items-center gap-2 px-6 py-2.5 rounded-md bg-bg-hover text-text-muted text-sm hover:text-text"
className="flex items-center gap-2 px-6 py-2.5 rounded-md bg-primary text-white text-sm font-medium hover:bg-primary-hover"
>
Go to Dashboard
Go to Dashboard <ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
Expand Down
Loading