diff --git a/.env.example b/.env.example index c646f38..a3028db 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,25 @@ # Copy to Coolify environment variables (or .env.local for dev). Do not commit secrets. -# --- Postgres (Coolify internal service DNS, same stack as this app) --- -# Example: postgresql://USER:PASS@:5432/vibn +# --- Postgres: local `next dev` (Coolify internal hostnames do NOT work on your laptop) --- +# npm run db:local:up then npm run db:local:push with: +# DATABASE_URL=postgresql://vibn:vibn@localhost:5433/vibn +# POSTGRES_URL=postgresql://vibn:vibn@localhost:5433/vibn + +# --- Postgres: production / Coolify (from Coolify UI, reachable from where the app runs) --- +# Coolify: open the Postgres service → expose/publish a host port → use SERVER_IP:HOST_PORT (not internal UUID host). +# From repo root, master-ai/.coolify.env with COOLIFY_URL + COOLIFY_API_TOKEN: npm run db:sync:coolify +# Example shape: postgresql://USER:PASSWORD@34.19.250.135:YOUR_PUBLISHED_PORT/vibn +# External/cloud: set DB_SSL=true if the DB requires TLS. DATABASE_URL= POSTGRES_URL= # --- Public URL of this Next app (OAuth callbacks, runner callbacks) --- +# Local Google OAuth (must match the host/port you open in the browser): +# NEXTAUTH_URL=http://localhost:3000 +# Google Cloud Console → OAuth client → Authorized redirect URIs (exact): +# http://localhost:3000/api/auth/callback/google +# If you use 127.0.0.1 or another port, use that consistently everywhere. +# Prisma adapter needs Postgres + tables: set DATABASE_URL then run: npx prisma db push NEXTAUTH_URL=https://vibnai.com NEXTAUTH_SECRET= @@ -18,6 +32,30 @@ AGENT_RUNNER_SECRET= # --- Optional: one-shot DDL via POST /api/admin/migrate --- # ADMIN_MIGRATE_SECRET= +# --- Gitea (git.vibnai.com) — admin token used to create per-workspace orgs/repos --- +# Token must have admin scope to create orgs. Per-workspace repos are created +# under "vibn-{workspace-slug}" orgs; legacy projects remain under GITEA_ADMIN_USER. +GITEA_API_URL=https://git.vibnai.com +GITEA_API_TOKEN= +GITEA_ADMIN_USER=mark +GITEA_WEBHOOK_SECRET= + +# --- Coolify (coolify.vibnai.com) — admin token used to create per-workspace Projects --- +# Each Vibn workspace gets one Coolify Project (named "vibn-ws-{slug}") that +# acts as the tenant boundary. All apps + DBs for that workspace live there. +COOLIFY_URL=https://coolify.vibnai.com +COOLIFY_API_TOKEN= +COOLIFY_SERVER_UUID=jws4g4cgssss4cw48s488woc + # --- Google OAuth / Gemini (see .google.env locally) --- GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= + +# --- Local dev: skip Google (next dev only) --- +# NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL=you@example.com +# Skip NextAuth session for API + project UI (same email must own rows in fs_users) +# NEXT_PUBLIC_DEV_BYPASS_PROJECT_AUTH=true +# Optional: require password for dev-local provider (omit to allow localhost Host only) +# DEV_LOCAL_AUTH_SECRET= +# Optional display name for the dev user row +# DEV_LOCAL_AUTH_NAME=Local dev diff --git a/app/[workspace]/settings/page.tsx b/app/[workspace]/settings/page.tsx index 20c52ce..3b4af00 100644 --- a/app/[workspace]/settings/page.tsx +++ b/app/[workspace]/settings/page.tsx @@ -9,6 +9,7 @@ import { auth } from '@/lib/firebase/config'; import { toast } from 'sonner'; import { Settings, User, Bell, Shield, Trash2 } from 'lucide-react'; import { useParams, useRouter } from 'next/navigation'; +import { WorkspaceKeysPanel } from '@/components/workspace/WorkspaceKeysPanel'; import { AlertDialog, AlertDialogAction, @@ -177,6 +178,9 @@ export default function SettingsPage() { + {/* Workspace tenancy + AI access keys */} + + {/* Notifications */} diff --git a/app/api/admin/migrate/route.ts b/app/api/admin/migrate/route.ts index 9d3e8a1..e8bd311 100644 --- a/app/api/admin/migrate/route.ts +++ b/app/api/admin/migrate/route.ts @@ -139,6 +139,54 @@ export async function POST(req: NextRequest) { expires TIMESTAMPTZ NOT NULL, UNIQUE (identifier, token) )`, + + // ── Vibn workspaces (logical tenancy on top of Coolify) ────────── + // One workspace per Vibn account. Holds a Coolify Project UUID + // (the team boundary inside Coolify) and a Gitea org name. + `CREATE TABLE IF NOT EXISTS vibn_workspaces ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + owner_user_id TEXT NOT NULL, + coolify_project_uuid TEXT, + coolify_team_id INT, + gitea_org TEXT, + provision_status TEXT NOT NULL DEFAULT 'pending', + provision_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE INDEX IF NOT EXISTS vibn_workspaces_owner_idx ON vibn_workspaces (owner_user_id)`, + + `CREATE TABLE IF NOT EXISTS vibn_workspace_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workspace_id, user_id) + )`, + `CREATE INDEX IF NOT EXISTS vibn_workspace_members_user_idx ON vibn_workspace_members (user_id)`, + + `CREATE TABLE IF NOT EXISTS vibn_workspace_api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_prefix TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + scopes JSONB NOT NULL DEFAULT '["workspace:*"]'::jsonb, + created_by TEXT NOT NULL, + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE INDEX IF NOT EXISTS vibn_workspace_api_keys_workspace_idx ON vibn_workspace_api_keys (workspace_id)`, + + // Tag projects with the workspace they belong to (nullable until backfill). + // The pre-existing fs_projects.workspace TEXT column stays for the legacy slug; + // this new UUID FK is the canonical link. + `ALTER TABLE fs_projects ADD COLUMN IF NOT EXISTS vibn_workspace_id UUID REFERENCES vibn_workspaces(id) ON DELETE SET NULL`, + `CREATE INDEX IF NOT EXISTS fs_projects_vibn_workspace_idx ON fs_projects (vibn_workspace_id)`, ]; for (const stmt of statements) { diff --git a/app/api/projects/create/route.ts b/app/api/projects/create/route.ts index 3799fe4..574dc84 100644 --- a/app/api/projects/create/route.ts +++ b/app/api/projects/create/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; import { randomUUID } from 'crypto'; import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea'; import { pushTurborepoScaffold } from '@/lib/scaffold'; -import { createProject as createCoolifyProject, createMonorepoAppService } from '@/lib/coolify'; +import { createMonorepoAppService } from '@/lib/coolify'; import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace'; +import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces'; import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts'; const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT; @@ -15,7 +15,7 @@ const GITEA_WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET ?? 'vibn-webhook-s export async function POST(request: Request) { try { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -53,6 +53,19 @@ export async function POST(request: Request) { const firebaseUserId = users[0]!.id; + // Resolve (and lazily provision) the user's workspace. Provides: + // - vibnWorkspace.coolify_project_uuid → namespace for Coolify apps/DBs + // - vibnWorkspace.gitea_org → owner for Gitea repos + // If provisioning failed for either, we fall back to legacy admin + // identifiers so the project create still succeeds (with degraded isolation). + let vibnWorkspace = await getOrCreateProvisionedWorkspace({ + userId: firebaseUserId, + email, + displayName: session.user.name ?? null, + }); + + const repoOwner = vibnWorkspace.gitea_org ?? GITEA_ADMIN_USER; + const body = await request.json(); const { projectName, @@ -97,14 +110,15 @@ export async function POST(request: Request) { description: `${projectName} — managed by Vibn`, private: true, auto_init: false, + owner: repoOwner, }); - console.log(`[API] Gitea repo created: ${GITEA_ADMIN_USER}/${repoName}`); + console.log(`[API] Gitea repo created: ${repoOwner}/${repoName}`); } catch (createErr) { const msg = createErr instanceof Error ? createErr.message : String(createErr); // 409 = repo already exists — link to it instead of failing if (msg.includes('409')) { - console.log(`[API] Gitea repo already exists, linking to ${GITEA_ADMIN_USER}/${repoName}`); - repo = await getRepo(GITEA_ADMIN_USER, repoName); + console.log(`[API] Gitea repo already exists, linking to ${repoOwner}/${repoName}`); + repo = await getRepo(repoOwner, repoName); if (!repo) throw new Error(`Repo ${repoName} exists but could not be fetched`); } else { throw createErr; @@ -125,7 +139,7 @@ export async function POST(request: Request) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ github_url: githubRepoUrl, - gitea_repo: `${GITEA_ADMIN_USER}/${repoName}`, + gitea_repo: `${repoOwner}/${repoName}`, project_name: projectName, github_token: githubToken || undefined, }), @@ -136,17 +150,17 @@ export async function POST(request: Request) { } console.log(`[API] GitHub repo mirrored to ${giteaRepo}`); } else { - await pushTurborepoScaffold(GITEA_ADMIN_USER, repoName, slug, projectName); + await pushTurborepoScaffold(repoOwner, repoName, slug, projectName); console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`); } // Register webhook — skip if one already points to this project const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`; - const existingHooks = await listWebhooks(GITEA_ADMIN_USER, repoName).catch(() => []); + const existingHooks = await listWebhooks(repoOwner, repoName).catch(() => []); const alreadyHooked = existingHooks.some(h => h.config.url.includes(projectId)); if (!alreadyHooked) { - const hook = await createWebhook(GITEA_ADMIN_USER, repoName, webhookUrl, GITEA_WEBHOOK_SECRET); + const hook = await createWebhook(repoOwner, repoName, webhookUrl, GITEA_WEBHOOK_SECRET); giteaWebhookId = hook.id; console.log(`[API] Webhook registered: ${giteaRepo}, id: ${giteaWebhookId}`); } else { @@ -160,7 +174,7 @@ export async function POST(request: Request) { } // ────────────────────────────────────────────── - // 2. Provision Coolify project + per-app services + // 2. Provision per-app services under the workspace's Coolify Project // ────────────────────────────────────────────── const APP_BASE_DOMAIN = process.env.APP_BASE_DOMAIN ?? 'vibnai.com'; const appNames = ['product', 'website', 'admin', 'storybook'] as const; @@ -168,35 +182,29 @@ export async function POST(request: Request) { name: string; path: string; coolifyServiceUuid: string | null; domain: string | null; }> = appNames.map(name => ({ name, path: `apps/${name}`, coolifyServiceUuid: null, domain: null })); - let coolifyProjectUuid: string | null = null; + // The workspace's Coolify Project IS our team boundary. All Vibn + // projects for a workspace share one Coolify Project namespace. + const coolifyProjectUuid: string | null = vibnWorkspace.coolify_project_uuid; - if (giteaCloneUrl) { - try { - const coolifyProject = await createCoolifyProject( - projectName, - `Vibn project for ${projectName}` - ); - coolifyProjectUuid = coolifyProject.uuid; - - for (const app of provisionedApps) { - try { - const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`; - const service = await createMonorepoAppService({ - projectUuid: coolifyProject.uuid, - appName: app.name, - gitRepo: giteaCloneUrl, - domain, - }); - app.coolifyServiceUuid = service.uuid; - app.domain = domain; - console.log(`[API] Coolify service created: ${app.name} → ${domain}`); - } catch (appErr) { - console.error(`[API] Coolify service failed for ${app.name}:`, appErr); - } + if (giteaCloneUrl && coolifyProjectUuid) { + for (const app of provisionedApps) { + try { + const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`; + const service = await createMonorepoAppService({ + projectUuid: coolifyProjectUuid, + appName: `${slug}-${app.name}`, // unique within the workspace's Coolify Project + gitRepo: giteaCloneUrl, + domain, + }); + app.coolifyServiceUuid = service.uuid; + app.domain = domain; + console.log(`[API] Coolify service created: ${app.name} → ${domain}`); + } catch (appErr) { + console.error(`[API] Coolify service failed for ${app.name}:`, appErr); } - } catch (coolifyErr) { - console.error('[API] Coolify project provisioning failed (non-fatal):', coolifyErr); } + } else if (!coolifyProjectUuid) { + console.warn('[API] Workspace has no Coolify Project UUID — skipped app provisioning. Run /api/workspaces/{slug}/provision to retry.'); } // ────────────────────────────────────────────── @@ -274,9 +282,9 @@ export async function POST(request: Request) { }; await query(` - INSERT INTO fs_projects (id, data, user_id, workspace, slug) - VALUES ($1, $2::jsonb, $3, $4, $5) - `, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug]); + INSERT INTO fs_projects (id, data, user_id, workspace, slug, vibn_workspace_id) + VALUES ($1, $2::jsonb, $3, $4, $5, $6) + `, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug, vibnWorkspace.id]); // Associate any unlinked sessions for this workspace path if (workspacePath) { diff --git a/app/api/workspaces/[slug]/keys/[keyId]/route.ts b/app/api/workspaces/[slug]/keys/[keyId]/route.ts new file mode 100644 index 0000000..d969974 --- /dev/null +++ b/app/api/workspaces/[slug]/keys/[keyId]/route.ts @@ -0,0 +1,28 @@ +/** + * DELETE /api/workspaces/[slug]/keys/[keyId] — revoke a workspace API key + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal, revokeWorkspaceApiKey } from '@/lib/auth/workspace-auth'; + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ slug: string; keyId: string }> } +) { + const { slug, keyId } = await params; + const principal = await requireWorkspacePrincipal(request, { targetSlug: slug }); + if (principal instanceof NextResponse) return principal; + + if (principal.source !== 'session') { + return NextResponse.json( + { error: 'API keys can only be revoked from a signed-in session' }, + { status: 403 } + ); + } + + const ok = await revokeWorkspaceApiKey(principal.workspace.id, keyId); + if (!ok) { + return NextResponse.json({ error: 'Key not found or already revoked' }, { status: 404 }); + } + return NextResponse.json({ revoked: true }); +} diff --git a/app/api/workspaces/[slug]/keys/route.ts b/app/api/workspaces/[slug]/keys/route.ts new file mode 100644 index 0000000..0683d61 --- /dev/null +++ b/app/api/workspaces/[slug]/keys/route.ts @@ -0,0 +1,72 @@ +/** + * Per-workspace API keys for AI agents. + * + * GET /api/workspaces/[slug]/keys — list keys (no secrets) + * POST /api/workspaces/[slug]/keys — mint a new key + * + * The full plaintext key is returned ONCE in the POST response and never + * persisted; only its sha256 hash is stored. + * + * API-key principals can list their own workspace's keys but cannot mint + * new ones (use the session UI for that). + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; +import { listWorkspaceApiKeys, mintWorkspaceApiKey } from '@/lib/auth/workspace-auth'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string }> } +) { + const { slug } = await params; + const principal = await requireWorkspacePrincipal(request, { targetSlug: slug }); + if (principal instanceof NextResponse) return principal; + + const keys = await listWorkspaceApiKeys(principal.workspace.id); + return NextResponse.json({ keys }); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ slug: string }> } +) { + const { slug } = await params; + const principal = await requireWorkspacePrincipal(request, { targetSlug: slug }); + if (principal instanceof NextResponse) return principal; + + if (principal.source !== 'session') { + return NextResponse.json( + { error: 'API keys can only be created from a signed-in session' }, + { status: 403 } + ); + } + + let body: { name?: string; scopes?: string[] }; + try { + body = (await request.json()) as { name?: string; scopes?: string[] }; + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const name = (body.name ?? '').trim(); + if (!name) { + return NextResponse.json({ error: 'Field "name" is required' }, { status: 400 }); + } + + const minted = await mintWorkspaceApiKey({ + workspaceId: principal.workspace.id, + name, + createdBy: principal.userId, + scopes: body.scopes, + }); + + return NextResponse.json({ + id: minted.id, + name: minted.name, + prefix: minted.prefix, + createdAt: minted.created_at, + // ↓ Only returned ONCE. Client must store this — we never see it again. + token: minted.token, + }); +} diff --git a/app/api/workspaces/[slug]/provision/route.ts b/app/api/workspaces/[slug]/provision/route.ts new file mode 100644 index 0000000..f0046d8 --- /dev/null +++ b/app/api/workspaces/[slug]/provision/route.ts @@ -0,0 +1,29 @@ +/** + * POST /api/workspaces/[slug]/provision — (re)run Coolify + Gitea provisioning + * + * Idempotent. Useful when initial provisioning during signin or first + * project create failed because Coolify or Gitea was unavailable. + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; +import { ensureWorkspaceProvisioned } from '@/lib/workspaces'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ slug: string }> } +) { + const { slug } = await params; + const principal = await requireWorkspacePrincipal(request, { targetSlug: slug }); + if (principal instanceof NextResponse) return principal; + + const updated = await ensureWorkspaceProvisioned(principal.workspace); + + return NextResponse.json({ + slug: updated.slug, + coolifyProjectUuid: updated.coolify_project_uuid, + giteaOrg: updated.gitea_org, + provisionStatus: updated.provision_status, + provisionError: updated.provision_error, + }); +} diff --git a/app/api/workspaces/[slug]/route.ts b/app/api/workspaces/[slug]/route.ts new file mode 100644 index 0000000..759c0b7 --- /dev/null +++ b/app/api/workspaces/[slug]/route.ts @@ -0,0 +1,33 @@ +/** + * GET /api/workspaces/[slug] — workspace details + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string }> } +) { + const { slug } = await params; + const principal = await requireWorkspacePrincipal(request, { targetSlug: slug }); + if (principal instanceof NextResponse) return principal; + + const w = principal.workspace; + return NextResponse.json({ + id: w.id, + slug: w.slug, + name: w.name, + coolifyProjectUuid: w.coolify_project_uuid, + coolifyTeamId: w.coolify_team_id, + giteaOrg: w.gitea_org, + provisionStatus: w.provision_status, + provisionError: w.provision_error, + createdAt: w.created_at, + updatedAt: w.updated_at, + principal: { + source: principal.source, + apiKeyId: principal.apiKeyId ?? null, + }, + }); +} diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts new file mode 100644 index 0000000..323eb0f --- /dev/null +++ b/app/api/workspaces/route.ts @@ -0,0 +1,52 @@ +/** + * GET /api/workspaces — list workspaces the caller can access + * + * Auth: + * - NextAuth session: returns the user's owned + member workspaces + * - vibn_sk_... API key: returns just the one workspace the key is bound to + */ + +import { NextResponse } from 'next/server'; +import { authSession } from '@/lib/auth/session-server'; +import { queryOne } from '@/lib/db-postgres'; +import { listWorkspacesForUser } from '@/lib/workspaces'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; + +export async function GET(request: Request) { + // API-key clients are pinned to one workspace + if (request.headers.get('authorization')?.toLowerCase().startsWith('bearer vibn_sk_')) { + const principal = await requireWorkspacePrincipal(request); + if (principal instanceof NextResponse) return principal; + return NextResponse.json({ workspaces: [serializeWorkspace(principal.workspace)] }); + } + + const session = await authSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const userRow = await queryOne<{ id: string }>( + `SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`, + [session.user.email] + ); + if (!userRow) { + return NextResponse.json({ workspaces: [] }); + } + + const list = await listWorkspacesForUser(userRow.id); + return NextResponse.json({ workspaces: list.map(serializeWorkspace) }); +} + +function serializeWorkspace(w: import('@/lib/workspaces').VibnWorkspace) { + return { + id: w.id, + slug: w.slug, + name: w.name, + coolifyProjectUuid: w.coolify_project_uuid, + giteaOrg: w.gitea_org, + provisionStatus: w.provision_status, + provisionError: w.provision_error, + createdAt: w.created_at, + updatedAt: w.updated_at, + }; +} diff --git a/components/workspace/WorkspaceKeysPanel.tsx b/components/workspace/WorkspaceKeysPanel.tsx new file mode 100644 index 0000000..dfd6c9b --- /dev/null +++ b/components/workspace/WorkspaceKeysPanel.tsx @@ -0,0 +1,778 @@ +"use client"; + +/** + * Workspace settings panel: shows workspace identity + API keys + * (mint / list / revoke), and renders ready-to-paste config for + * Cursor and other VS Code-style IDEs so an AI agent can act on + * behalf of this workspace. + * + * The full plaintext token is shown ONCE in the post-create modal + * and never again — `key_hash` is all we persist server-side. + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { toast } from "sonner"; +import { Copy, Download, KeyRound, Loader2, Plus, RefreshCw, Trash2 } from "lucide-react"; + +interface WorkspaceSummary { + id: string; + slug: string; + name: string; + coolifyProjectUuid: string | null; + coolifyTeamId: number | null; + giteaOrg: string | null; + provisionStatus: "pending" | "partial" | "ready" | "error"; + provisionError: string | null; +} + +interface ApiKey { + id: string; + name: string; + prefix: string; + scopes: string[]; + created_by: string; + last_used_at: string | null; + revoked_at: string | null; + created_at: string; +} + +interface MintedKey { + id: string; + name: string; + prefix: string; + token: string; + createdAt: string; +} + +const APP_BASE = + typeof window !== "undefined" ? window.location.origin : "https://vibnai.com"; + +export function WorkspaceKeysPanel({ workspaceSlug }: { workspaceSlug: string }) { + const [workspace, setWorkspace] = useState(null); + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [provisioning, setProvisioning] = useState(false); + + const [showCreate, setShowCreate] = useState(false); + const [newName, setNewName] = useState(""); + const [creating, setCreating] = useState(false); + + const [minted, setMinted] = useState(null); + const [keyToRevoke, setKeyToRevoke] = useState(null); + const [revoking, setRevoking] = useState(false); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const [wsRes, keysRes] = await Promise.all([ + fetch(`/api/workspaces/${workspaceSlug}`), + fetch(`/api/workspaces/${workspaceSlug}/keys`), + ]); + if (wsRes.ok) setWorkspace(await wsRes.json()); + if (keysRes.ok) { + const j = (await keysRes.json()) as { keys: ApiKey[] }; + setKeys(j.keys ?? []); + } + } catch (err) { + console.error("[workspace-keys] refresh failed", err); + } finally { + setLoading(false); + } + }, [workspaceSlug]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const provision = useCallback(async () => { + setProvisioning(true); + try { + const res = await fetch(`/api/workspaces/${workspaceSlug}/provision`, { method: "POST" }); + if (!res.ok) throw new Error(await res.text()); + toast.success("Provisioning re-run"); + await refresh(); + } catch (err) { + toast.error(`Provisioning failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setProvisioning(false); + } + }, [workspaceSlug, refresh]); + + const createKey = useCallback(async () => { + if (!newName.trim()) { + toast.error("Give the key a name"); + return; + } + setCreating(true); + try { + const res = await fetch(`/api/workspaces/${workspaceSlug}/keys`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName.trim() }), + }); + const j = await res.json(); + if (!res.ok) throw new Error(j.error ?? "Failed"); + setShowCreate(false); + setNewName(""); + setMinted(j as MintedKey); + await refresh(); + } catch (err) { + toast.error(`Could not mint key: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setCreating(false); + } + }, [workspaceSlug, newName, refresh]); + + const revokeKey = useCallback(async () => { + if (!keyToRevoke) return; + setRevoking(true); + try { + const res = await fetch( + `/api/workspaces/${workspaceSlug}/keys/${keyToRevoke.id}`, + { method: "DELETE" } + ); + if (!res.ok) throw new Error(await res.text()); + toast.success("Key revoked"); + setKeyToRevoke(null); + await refresh(); + } catch (err) { + toast.error(`Revoke failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setRevoking(false); + } + }, [workspaceSlug, keyToRevoke, refresh]); + + if (loading && !workspace) { + return ( +
+ Loading workspace… +
+ ); + } + + if (!workspace) { + return ( +
+ Workspace not found. +
+ ); + } + + return ( +
+ + + setShowCreate(true)} + onRevokeClick={k => setKeyToRevoke(k)} + onRefresh={refresh} + /> + + + + {/* ── Create key modal ─────────────────────────────────────── */} + + + + Create workspace API key + + Used by AI agents (Cursor, Claude, scripts) to act on + behalf of {workspace.slug}. The token is shown + once — save it somewhere safe. + + +
+ + setNewName(e.target.value)} + autoFocus + onKeyDown={e => { + if (e.key === "Enter") createKey(); + }} + /> +
+ + + + +
+
+ + {/* ── Show minted secret ONCE ──────────────────────────────── */} + !open && setMinted(null)}> + + + Save your API key + + This is the only time the full key is shown. Store it in a + password manager or paste it into the Cursor config below. + + + {minted && } + + + + + + + {/* ── Revoke confirm ───────────────────────────────────────── */} + !open && setKeyToRevoke(null)}> + + + Revoke this key? + + Any agent using {keyToRevoke?.prefix}… will + immediately lose access to {workspace.slug}. This cannot be undone. + + + + Cancel + + {revoking && } + Revoke + + + + +
+ ); +} + +// ────────────────────────────────────────────────── +// Sub-components +// ────────────────────────────────────────────────── + +function WorkspaceIdentityCard({ + workspace, + onProvision, + provisioning, +}: { + workspace: WorkspaceSummary; + onProvision: () => void; + provisioning: boolean; +}) { + const status = workspace.provisionStatus; + const statusColor = + status === "ready" ? "#10b981" : status === "partial" ? "#f59e0b" : status === "error" ? "#ef4444" : "#9ca3af"; + + return ( +
+
+
+

Workspace

+

+ Your tenant boundary on Coolify and Gitea. All AI access is scoped to this workspace. +

+
+ +
+
+ {workspace.slug}} /> + + + {status} + + } + /> + {workspace.coolifyProjectUuid} + ) : ( + not provisioned + ) + } + /> + {workspace.giteaOrg} + ) : ( + not provisioned + ) + } + /> +
+ {workspace.provisionError && ( +
+ {workspace.provisionError} +
+ )} +
+ ); +} + +function KeysCard({ + workspace, + keys, + onCreateClick, + onRevokeClick, + onRefresh, +}: { + workspace: WorkspaceSummary; + keys: ApiKey[]; + onCreateClick: () => void; + onRevokeClick: (k: ApiKey) => void; + onRefresh: () => void; +}) { + const active = useMemo(() => keys.filter(k => !k.revoked_at), [keys]); + const revoked = useMemo(() => keys.filter(k => k.revoked_at), [keys]); + + return ( +
+
+
+

API keys

+

+ Tokens scoped to {workspace.slug}. Use them in Cursor, + Claude Code, the CLI, or any HTTP client. +

+
+
+ + +
+
+ + {active.length === 0 ? ( + + ) : ( +
    + {active.map(k => ( + onRevokeClick(k)} /> + ))} +
+ )} + + {revoked.length > 0 && ( +
+ + {revoked.length} revoked + +
    + {revoked.map(k => ( + + ))} +
+
+ )} +
+ ); +} + +function KeyRow({ k, onRevoke }: { k: ApiKey; onRevoke?: () => void }) { + return ( +
  • + +
    +
    {k.name}
    +
    + {k.prefix}… + {k.last_used_at + ? ` · last used ${new Date(k.last_used_at).toLocaleString()}` + : " · never used"} +
    +
    + {onRevoke && ( + + )} +
  • + ); +} + +function EmptyKeysState({ onCreateClick }: { onCreateClick: () => void }) { + return ( +
    + +
    No API keys yet
    +
    + Mint one to let Cursor or any AI agent push code and deploy on your behalf. +
    + +
    + ); +} + +// ────────────────────────────────────────────────── +// Cursor / VS Code integration block +// ────────────────────────────────────────────────── + +function CursorIntegrationCard({ workspace }: { workspace: WorkspaceSummary }) { + const cursorRule = buildCursorRule(workspace); + const mcpJson = buildMcpJson(workspace, ""); + const envSnippet = buildEnvSnippet(workspace, ""); + + return ( +
    +
    +
    +

    Connect Cursor

    +

    + Drop these into your repo (or ~/.cursor/) so any + agent inside Cursor knows how to talk to this workspace. +

    +
    +
    + + + + + + +
    + ); +} + +function FileBlock({ + title, + description, + filename, + contents, + language, +}: { + title: string; + description: string; + filename: string; + contents: string; + language: string; +}) { + const copy = useCallback(() => { + navigator.clipboard.writeText(contents).then( + () => toast.success(`Copied ${title}`), + () => toast.error("Copy failed") + ); + }, [contents, title]); + + const download = useCallback(() => { + const blob = new Blob([contents], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, [contents, filename]); + + return ( +
    +
    +
    +
    {title}
    +
    {description}
    +
    +
    + + +
    +
    +
    +        {contents}
    +      
    +
    + ); +} + +function MintedKeyView({ workspace, minted }: { workspace: WorkspaceSummary; minted: MintedKey }) { + const cursorRule = buildCursorRule(workspace); + const mcpJson = buildMcpJson(workspace, minted.token); + const envSnippet = buildEnvSnippet(workspace, minted.token); + + return ( +
    + + + + +
    + ); +} + +// ────────────────────────────────────────────────── +// File generators +// ────────────────────────────────────────────────── + +function buildCursorRule(w: WorkspaceSummary): string { + return `--- +description: Vibn workspace "${w.slug}" — REST API for git/deploy via Coolify + Gitea +alwaysApply: true +--- + +# Vibn workspace: ${w.slug} + +You have access to the Vibn workspace **${w.slug}** through the REST API +at \`${APP_BASE}\`. The API is scoped to this workspace only — the bearer +token cannot reach any other tenant. + +## Auth + +All requests must include: + +\`\`\` +Authorization: Bearer $VIBN_API_KEY +\`\`\` + +The token lives in \`.env.local\` as \`VIBN_API_KEY\`. Never print it. + +## Workspace identity + +- Coolify Project UUID: \`${w.coolifyProjectUuid ?? "(not provisioned)"}\` +- Gitea organization: \`${w.giteaOrg ?? "(not provisioned)"}\` +- Provision status: \`${w.provisionStatus}\` + +All git operations should target the \`${w.giteaOrg ?? "(unset)"}\` org on +\`git.vibnai.com\`. All deployments live under the Coolify Project above. + +## Useful endpoints (all under ${APP_BASE}) + +| Method | Path | Purpose | +|-------:|--------------------------------------------|--------------------------| +| GET | /api/workspaces/${w.slug} | Workspace details | +| GET | /api/workspaces/${w.slug}/keys | List API keys | +| POST | /api/workspaces/${w.slug}/provision | Re-run Coolify+Gitea provisioning | +| GET | /api/projects | Projects in this workspace | +| POST | /api/projects/create | Create a new project (provisions repo + Coolify apps) | +| GET | /api/projects/{projectId} | Project details | +| GET | /api/projects/{projectId}/preview-url | Live deploy URLs | +| GET | /api/projects/{projectId}/file?path=... | Read a file from the repo | + +## House rules for AI agents + +1. Confirm with the user before creating new projects, repos, or deployments. +2. Use \`git.vibnai.com/${w.giteaOrg ?? ""}/\` for git remotes. +3. Prefer issuing PRs over force-pushing to \`main\`. +4. If a request is rejected with 403, re-check that the key is for + workspace \`${w.slug}\` (it cannot act on others). +5. Treat \`VIBN_API_KEY\` like a password — never echo, log, or commit it. +`; +} + +function buildMcpJson(w: WorkspaceSummary, token: string): string { + const config = { + mcpServers: { + [`vibn-${w.slug}`]: { + url: `${APP_BASE}/api/mcp`, + headers: { + Authorization: `Bearer ${token}`, + }, + }, + }, + }; + return JSON.stringify(config, null, 2); +} + +function buildEnvSnippet(w: WorkspaceSummary, token: string): string { + return `# Vibn workspace: ${w.slug} +# Generated ${new Date().toISOString()} +VIBN_API_BASE=${APP_BASE} +VIBN_WORKSPACE=${w.slug} +VIBN_API_KEY=${token} + +# Quick smoke test: +# curl -H "Authorization: Bearer $VIBN_API_KEY" $VIBN_API_BASE/api/workspaces/$VIBN_WORKSPACE +`; +} + +// ────────────────────────────────────────────────── +// Inline styles (matches the rest of the dashboard's plain-CSS look) +// ────────────────────────────────────────────────── + +const cardStyle: React.CSSProperties = { + background: "var(--card-bg, #fff)", + border: "1px solid var(--border, #e5e7eb)", + borderRadius: 12, + padding: 20, +}; + +const cardHeaderStyle: React.CSSProperties = { + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 16, + marginBottom: 16, +}; + +const cardTitleStyle: React.CSSProperties = { + fontSize: 16, + fontWeight: 700, + color: "var(--ink)", + margin: 0, + letterSpacing: "-0.01em", +}; + +const cardSubtitleStyle: React.CSSProperties = { + fontSize: 12.5, + color: "var(--muted)", + margin: "4px 0 0", + lineHeight: 1.5, + maxWidth: 520, +}; + +const kvGrid: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", + gap: 12, + margin: 0, +}; + +function Kv({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
    +
    + {label} +
    +
    {value}
    +
    + ); +} diff --git a/lib/auth/authOptions.ts b/lib/auth/authOptions.ts index b41486e..701d0ef 100644 --- a/lib/auth/authOptions.ts +++ b/lib/auth/authOptions.ts @@ -1,14 +1,85 @@ import { NextAuthOptions } from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; import GoogleProvider from "next-auth/providers/google"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { PrismaClient } from "@prisma/client"; import { query } from "@/lib/db-postgres"; +import { ensureWorkspaceForUser } from "@/lib/workspaces"; const prisma = new PrismaClient(); +const nextAuthUrl = (process.env.NEXTAUTH_URL ?? "").trim(); +const isLocalNextAuth = + nextAuthUrl.startsWith("http://localhost") || + nextAuthUrl.startsWith("http://127.0.0.1") || + (process.env.NODE_ENV === "development" && !nextAuthUrl); + +/** Set in .env.local (server + client): one email for local dev bypass. */ +const devLocalEmail = (process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL ?? "").trim(); +const devLocalSecret = (process.env.DEV_LOCAL_AUTH_SECRET ?? "").trim(); +const devLocalAuthEnabled = + process.env.NODE_ENV === "development" && devLocalEmail.length > 0; + +function isLocalhostHost(host: string): boolean { + const h = host.split(":")[0]?.toLowerCase() ?? ""; + return ( + h === "localhost" || + h === "127.0.0.1" || + h === "[::1]" || + h === "::1" + ); +} + export const authOptions: NextAuthOptions = { + debug: process.env.NODE_ENV === "development", adapter: PrismaAdapter(prisma), providers: [ + ...(devLocalAuthEnabled + ? [ + CredentialsProvider({ + id: "dev-local", + name: "Local dev", + credentials: { + password: { label: "Dev secret", type: "password" }, + }, + async authorize(credentials, req) { + const headers = (req as { headers?: Headers } | undefined)?.headers; + const host = + headers && typeof headers.get === "function" + ? (headers.get("host") ?? "") + : ""; + + if (devLocalSecret) { + if ((credentials?.password ?? "") !== devLocalSecret) { + return null; + } + } else if (!isLocalhostHost(host)) { + return null; + } + + const name = + (process.env.DEV_LOCAL_AUTH_NAME ?? "").trim() || "Local dev"; + + const user = await prisma.user.upsert({ + where: { email: devLocalEmail }, + create: { + email: devLocalEmail, + name, + emailVerified: new Date(), + }, + update: { name }, + }); + + return { + id: user.id, + email: user.email, + name: user.name, + image: user.image, + }; + }, + }), + ] + : []), GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID || "", clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", @@ -20,8 +91,8 @@ export const authOptions: NextAuthOptions = { }, callbacks: { async session({ session, user }) { - if (session.user) { - session.user.id = user.id; + if (session.user && "id" in user && user.id) { + (session.user as { id: string }).id = user.id; } return session; }, @@ -42,16 +113,34 @@ export const authOptions: NextAuthOptions = { `SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`, [user.email] ); + let fsUserId: string; if (existing.length === 0) { - await query( - `INSERT INTO fs_users (id, user_id, data) VALUES (gen_random_uuid()::text, $1, $2::jsonb)`, + const inserted = await query<{ id: string }>( + `INSERT INTO fs_users (id, user_id, data) + VALUES (gen_random_uuid()::text, $1, $2::jsonb) + RETURNING id`, [user.id, data] ); + fsUserId = inserted[0].id; } else { await query( `UPDATE fs_users SET user_id = $1, data = data || $2::jsonb, updated_at = NOW() WHERE id = $3`, [user.id, data, existing[0].id] ); + fsUserId = existing[0].id; + } + + // Ensure a Vibn workspace exists for this user. We DO NOT + // provision Coolify/Gitea here — that happens lazily on first + // project create so signin stays fast and resilient to outages. + try { + await ensureWorkspaceForUser({ + userId: fsUserId, + email: user.email, + displayName: user.name ?? null, + }); + } catch (wsErr) { + console.error("[signIn] Failed to ensure workspace:", wsErr); } } catch (e) { console.error("[signIn] Failed to upsert fs_user:", e); @@ -66,13 +155,14 @@ export const authOptions: NextAuthOptions = { secret: process.env.NEXTAUTH_SECRET, cookies: { sessionToken: { - name: `__Secure-next-auth.session-token`, + // __Secure- prefix requires Secure; localhost HTTP needs plain name + secure: false + name: isLocalNextAuth ? "next-auth.session-token" : "__Secure-next-auth.session-token", options: { httpOnly: true, sameSite: "lax", path: "/", - secure: true, - domain: ".vibnai.com", // share across all subdomains (theia.vibnai.com, etc.) + secure: !isLocalNextAuth, + ...(isLocalNextAuth ? {} : { domain: ".vibnai.com" }), }, }, }, diff --git a/lib/auth/workspace-auth.ts b/lib/auth/workspace-auth.ts new file mode 100644 index 0000000..65e1cc3 --- /dev/null +++ b/lib/auth/workspace-auth.ts @@ -0,0 +1,247 @@ +/** + * Workspace-scoped auth. + * + * Two principal types are accepted on `/api/...` routes: + * 1. NextAuth session (browser users) — `authSession()` + * 2. Per-workspace bearer API key (`Authorization: Bearer vibn_sk_...`) + * + * Either way we resolve a `WorkspacePrincipal` that is scoped to one + * workspace. Routes that touch Coolify/Gitea/Theia must call + * `requireWorkspacePrincipal()` and use `principal.workspace` to fetch + * the right Coolify Project / Gitea org. + */ + +import { createHash, randomBytes } from 'crypto'; +import { NextResponse } from 'next/server'; +import { authSession } from '@/lib/auth/session-server'; +import { query, queryOne } from '@/lib/db-postgres'; +import { + type VibnWorkspace, + getWorkspaceById, + getWorkspaceBySlug, + getWorkspaceByOwner, + userHasWorkspaceAccess, +} from '@/lib/workspaces'; + +const KEY_PREFIX = 'vibn_sk_'; +const KEY_RANDOM_BYTES = 32; // 256-bit secret + +export interface WorkspacePrincipal { + /** "session" = browser user; "api_key" = automated/AI client */ + source: 'session' | 'api_key'; + workspace: VibnWorkspace; + /** fs_users.id of the human ultimately responsible for this request */ + userId: string; + /** When source = "api_key", which key id was used */ + apiKeyId?: string; +} + +/** + * Resolve the workspace principal from either a NextAuth session or a + * `Bearer vibn_sk_...` token. Optional `targetSlug` enforces that the + * principal is for the requested workspace. + * + * Returns: + * - principal on success + * - NextResponse on failure (401 / 403) — return it directly from the route + */ +export async function requireWorkspacePrincipal( + request: Request, + opts: { targetSlug?: string; targetId?: string } = {}, +): Promise { + const apiKey = extractApiKey(request); + if (apiKey) { + const principal = await resolveApiKey(apiKey); + if (!principal) { + return NextResponse.json({ error: 'Invalid or revoked API key' }, { status: 401 }); + } + if (!matchesTarget(principal.workspace, opts)) { + return NextResponse.json({ error: 'API key not authorized for this workspace' }, { status: 403 }); + } + return principal; + } + + // Fall through to NextAuth session + const session = await authSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const userRow = await queryOne<{ id: string }>( + `SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`, + [session.user.email] + ); + if (!userRow) { + return NextResponse.json({ error: 'No fs_users row for session' }, { status: 401 }); + } + + let workspace: VibnWorkspace | null = null; + if (opts.targetSlug) workspace = await getWorkspaceBySlug(opts.targetSlug); + else if (opts.targetId) workspace = await getWorkspaceById(opts.targetId); + else workspace = await getWorkspaceByOwner(userRow.id); + + if (!workspace) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); + } + + const ok = await userHasWorkspaceAccess(userRow.id, workspace.id); + if (!ok) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return { source: 'session', workspace, userId: userRow.id }; +} + +function matchesTarget( + workspace: VibnWorkspace, + opts: { targetSlug?: string; targetId?: string } +): boolean { + if (opts.targetSlug && workspace.slug !== opts.targetSlug) return false; + if (opts.targetId && workspace.id !== opts.targetId) return false; + return true; +} + +function extractApiKey(request: Request): string | null { + const auth = request.headers.get('authorization'); + if (!auth) return null; + const m = /^Bearer\s+(.+)$/i.exec(auth.trim()); + if (!m) return null; + const token = m[1].trim(); + if (!token.startsWith(KEY_PREFIX)) return null; + return token; +} + +async function resolveApiKey(token: string): Promise { + const hash = hashKey(token); + const row = await queryOne<{ + id: string; + workspace_id: string; + created_by: string; + revoked_at: Date | null; + }>( + `SELECT id, workspace_id, created_by, revoked_at + FROM vibn_workspace_api_keys + WHERE key_hash = $1 + LIMIT 1`, + [hash] + ); + if (!row || row.revoked_at) return null; + + const workspace = await getWorkspaceById(row.workspace_id); + if (!workspace) return null; + + // Touch last_used_at without blocking + void query( + `UPDATE vibn_workspace_api_keys SET last_used_at = now() WHERE id = $1`, + [row.id] + ).catch(() => undefined); + + return { + source: 'api_key', + workspace, + userId: row.created_by, + apiKeyId: row.id, + }; +} + +// ────────────────────────────────────────────────── +// Key minting + hashing +// ────────────────────────────────────────────────── + +export interface MintedApiKey { + id: string; + /** Full plaintext key — shown once at creation, never stored. */ + token: string; + prefix: string; + workspace_id: string; + name: string; + created_at: Date; +} + +export async function mintWorkspaceApiKey(opts: { + workspaceId: string; + name: string; + createdBy: string; + scopes?: string[]; +}): Promise { + const random = randomBytes(KEY_RANDOM_BYTES).toString('base64url'); + const token = `${KEY_PREFIX}${random}`; + const hash = hashKey(token); + const prefix = token.slice(0, 12); // e.g. "vibn_sk_AbCd" + + const inserted = await query<{ id: string; created_at: Date }>( + `INSERT INTO vibn_workspace_api_keys + (workspace_id, name, key_prefix, key_hash, scopes, created_by) + VALUES ($1, $2, $3, $4, $5::jsonb, $6) + RETURNING id, created_at`, + [ + opts.workspaceId, + opts.name, + prefix, + hash, + JSON.stringify(opts.scopes ?? ['workspace:*']), + opts.createdBy, + ] + ); + + return { + id: inserted[0].id, + token, + prefix, + workspace_id: opts.workspaceId, + name: opts.name, + created_at: inserted[0].created_at, + }; +} + +export async function listWorkspaceApiKeys(workspaceId: string): Promise> { + const rows = await query<{ + id: string; + name: string; + key_prefix: string; + scopes: string[]; + created_by: string; + last_used_at: Date | null; + revoked_at: Date | null; + created_at: Date; + }>( + `SELECT id, name, key_prefix, scopes, created_by, last_used_at, revoked_at, created_at + FROM vibn_workspace_api_keys + WHERE workspace_id = $1 + ORDER BY created_at DESC`, + [workspaceId] + ); + return rows.map(r => ({ + id: r.id, + name: r.name, + prefix: r.key_prefix, + scopes: r.scopes, + created_by: r.created_by, + last_used_at: r.last_used_at, + revoked_at: r.revoked_at, + created_at: r.created_at, + })); +} + +export async function revokeWorkspaceApiKey(workspaceId: string, keyId: string): Promise { + const updated = await query<{ id: string }>( + `UPDATE vibn_workspace_api_keys + SET revoked_at = now() + WHERE id = $1 AND workspace_id = $2 AND revoked_at IS NULL + RETURNING id`, + [keyId, workspaceId] + ); + return updated.length > 0; +} + +function hashKey(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} diff --git a/lib/gitea.ts b/lib/gitea.ts index 8aa82e3..d5bb313 100644 --- a/lib/gitea.ts +++ b/lib/gitea.ts @@ -54,7 +54,10 @@ async function giteaFetch(path: string, options: RequestInit = {}) { } /** - * Create a new repo under the admin user (or a specified owner). + * Create a new repo. By default creates under the admin user. + * Pass `owner` to create under a specific user OR org — when the owner + * is an org (or any user other than the token holder), Gitea requires + * the org-scoped endpoint `/orgs/{owner}/repos`. */ export async function createRepo( name: string, @@ -62,18 +65,86 @@ export async function createRepo( ): Promise { const { description = '', private: isPrivate = true, owner = GITEA_ADMIN_USER, auto_init = true } = opts; - return giteaFetch(`/user/repos`, { + const body = JSON.stringify({ + name, + description, + private: isPrivate, + auto_init, + default_branch: 'main', + }); + + // Token-owner repos use /user/repos; everything else (orgs, other users) + // must go through /orgs/{owner}/repos. + const path = owner === GITEA_ADMIN_USER ? `/user/repos` : `/orgs/${owner}/repos`; + return giteaFetch(path, { method: 'POST', body }); +} + +// ────────────────────────────────────────────────── +// Organizations (per-workspace tenancy) +// ────────────────────────────────────────────────── + +export interface GiteaOrg { + id: number; + username: string; // org name (Gitea uses "username" for orgs too) + full_name: string; + description?: string; + visibility: 'public' | 'private' | 'limited'; +} + +/** + * Create a Gitea organization. Requires the admin token to have + * permission to create orgs. + */ +export async function createOrg(opts: { + name: string; + fullName?: string; + description?: string; + visibility?: 'public' | 'private' | 'limited'; +}): Promise { + const { name, fullName = name, description = '', visibility = 'private' } = opts; + return giteaFetch(`/orgs`, { method: 'POST', body: JSON.stringify({ - name, + username: name, + full_name: fullName, description, - private: isPrivate, - auto_init, - default_branch: 'main', + visibility, + repo_admin_change_team_access: true, }), }); } +export async function getOrg(name: string): Promise { + try { + return await giteaFetch(`/orgs/${name}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('404')) return null; + throw err; + } +} + +/** + * Add a Gitea user to an org's "Owners" team (full access to the org). + * Falls back to the org's default team when "Owners" cannot be located. + */ +export async function addOrgOwner(orgName: string, username: string): Promise { + const teams = (await giteaFetch(`/orgs/${orgName}/teams`)) as Array<{ id: number; name: string }>; + const owners = teams.find(t => t.name.toLowerCase() === 'owners') ?? teams[0]; + if (!owners) throw new Error(`No teams found for org ${orgName}`); + await giteaFetch(`/teams/${owners.id}/members/${username}`, { method: 'PUT' }); +} + +export async function getUser(username: string): Promise<{ id: number; login: string } | null> { + try { + return await giteaFetch(`/users/${username}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('404')) return null; + throw err; + } +} + /** * Get an existing repo. */ diff --git a/lib/workspaces.ts b/lib/workspaces.ts new file mode 100644 index 0000000..178a367 --- /dev/null +++ b/lib/workspaces.ts @@ -0,0 +1,270 @@ +/** + * Vibn workspaces — logical multi-tenancy on top of Coolify + Gitea. + * + * Each Vibn user gets one workspace. The workspace owns: + * - a Coolify Project UUID (the team/namespace boundary inside Coolify) + * - a Gitea org (which contains all repos for the workspace) + * + * All Vibn projects, apps, deployments, and AI access keys are + * scoped to a single workspace. Code that touches Coolify or Gitea + * MUST resolve a workspace first and use its IDs (never the legacy + * hardcoded admin user / project UUID). + * + * Coolify cannot create real Teams via its public API today — see + * Coolify changelog notes about scoping queries to current team and + * the lack of POST /teams. We treat one Coolify *Project* as our + * tenant boundary instead, and stamp `coolify_team_id` later if/when + * Coolify exposes team creation. + */ + +import { query, queryOne } from '@/lib/db-postgres'; +import { createProject as createCoolifyProject } from '@/lib/coolify'; +import { createOrg, getOrg, getUser, addOrgOwner } from '@/lib/gitea'; + +export interface VibnWorkspace { + id: string; + slug: string; + name: string; + owner_user_id: string; + coolify_project_uuid: string | null; + coolify_team_id: number | null; + gitea_org: string | null; + provision_status: 'pending' | 'partial' | 'ready' | 'error'; + provision_error: string | null; + created_at: Date; + updated_at: Date; +} + +export interface VibnWorkspaceMember { + id: string; + workspace_id: string; + user_id: string; + role: 'owner' | 'admin' | 'member'; + created_at: Date; +} + +// ────────────────────────────────────────────────── +// Slug helpers +// ────────────────────────────────────────────────── + +const SAFE_SLUG = /[^a-z0-9]+/g; + +export function workspaceSlugFromEmail(email: string): string { + const local = email.split('@')[0]?.toLowerCase() ?? 'user'; + return local.replace(SAFE_SLUG, '-').replace(/^-+|-+$/g, '') || 'user'; +} + +/** Coolify Project name we use for a workspace. Prefixed to avoid collisions. */ +export function coolifyProjectNameFor(slug: string): string { + return `vibn-ws-${slug}`; +} + +/** Gitea org name we use for a workspace. Same prefix for consistency. */ +export function giteaOrgNameFor(slug: string): string { + return `vibn-${slug}`; +} + +// ────────────────────────────────────────────────── +// CRUD +// ────────────────────────────────────────────────── + +export async function getWorkspaceById(id: string): Promise { + return queryOne(`SELECT * FROM vibn_workspaces WHERE id = $1`, [id]); +} + +export async function getWorkspaceBySlug(slug: string): Promise { + return queryOne(`SELECT * FROM vibn_workspaces WHERE slug = $1`, [slug]); +} + +export async function getWorkspaceByOwner(userId: string): Promise { + return queryOne( + `SELECT * FROM vibn_workspaces WHERE owner_user_id = $1 ORDER BY created_at ASC LIMIT 1`, + [userId] + ); +} + +export async function listWorkspacesForUser(userId: string): Promise { + return query( + `SELECT w.* FROM vibn_workspaces w + LEFT JOIN vibn_workspace_members m ON m.workspace_id = w.id + WHERE w.owner_user_id = $1 OR m.user_id = $1 + GROUP BY w.id + ORDER BY w.created_at ASC`, + [userId] + ); +} + +export async function userHasWorkspaceAccess(userId: string, workspaceId: string): Promise { + const row = await queryOne<{ id: string }>( + `SELECT w.id FROM vibn_workspaces w + LEFT JOIN vibn_workspace_members m ON m.workspace_id = w.id AND m.user_id = $1 + WHERE w.id = $2 AND (w.owner_user_id = $1 OR m.user_id = $1) + LIMIT 1`, + [userId, workspaceId] + ); + return !!row; +} + +// ────────────────────────────────────────────────── +// Get-or-create + provision +// ────────────────────────────────────────────────── + +/** + * Idempotently ensures a workspace row exists for the user. Does NOT + * provision Coolify/Gitea — call ensureWorkspaceProvisioned() for that. + * + * Suitable to call from the NextAuth signIn callback (cheap, single insert). + */ +export async function ensureWorkspaceForUser(opts: { + userId: string; + email: string; + displayName?: string | null; +}): Promise { + const existing = await getWorkspaceByOwner(opts.userId); + if (existing) return existing; + + const slug = await pickAvailableSlug(workspaceSlugFromEmail(opts.email)); + const name = opts.displayName?.trim() || opts.email.split('@')[0]; + + const inserted = await query( + `INSERT INTO vibn_workspaces (slug, name, owner_user_id) + VALUES ($1, $2, $3) + RETURNING *`, + [slug, name, opts.userId] + ); + const workspace = inserted[0]; + + await query( + `INSERT INTO vibn_workspace_members (workspace_id, user_id, role) + VALUES ($1, $2, 'owner') + ON CONFLICT (workspace_id, user_id) DO NOTHING`, + [workspace.id, opts.userId] + ); + + return workspace; +} + +/** + * Provisions Coolify Project + Gitea org for a workspace if not yet done. + * Idempotent. Failures are recorded on the row but do not throw — callers + * can retry by calling again. Returns the up-to-date workspace row. + */ +export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Promise { + if (workspace.provision_status === 'ready') return workspace; + + let coolifyUuid = workspace.coolify_project_uuid; + let giteaOrg = workspace.gitea_org; + const errors: string[] = []; + + // ── Coolify Project ──────────────────────────────────────────────── + if (!coolifyUuid) { + try { + const project = await createCoolifyProject( + coolifyProjectNameFor(workspace.slug), + `Vibn workspace ${workspace.slug}` + ); + coolifyUuid = project.uuid; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Coolify returns 400/409 if the name collides — fall through; the + // workspace can still be patched manually with the right UUID. + errors.push(`coolify: ${msg}`); + console.error('[workspaces] Coolify provisioning failed', workspace.slug, msg); + } + } + + // ── Gitea Org ────────────────────────────────────────────────────── + if (!giteaOrg) { + const wantOrg = giteaOrgNameFor(workspace.slug); + try { + const existingOrg = await getOrg(wantOrg); + if (existingOrg) { + giteaOrg = existingOrg.username; + } else { + const created = await createOrg({ + name: wantOrg, + fullName: workspace.name, + description: `Vibn workspace for ${workspace.slug}`, + visibility: 'private', + }); + giteaOrg = created.username; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`gitea: ${msg}`); + console.error('[workspaces] Gitea org provisioning failed', workspace.slug, msg); + } + } + + // ── Add the workspace owner to the Gitea org if they have a Gitea account. + // Best-effort: most Vibn users won't have a Gitea login, so a 404 is fine. + if (giteaOrg) { + try { + const ownerEmail = await queryOne<{ email: string }>( + `SELECT data->>'email' AS email FROM fs_users WHERE id = $1`, + [workspace.owner_user_id] + ); + const candidateLogin = ownerEmail?.email + ? workspaceSlugFromEmail(ownerEmail.email) + : null; + if (candidateLogin) { + const giteaUser = await getUser(candidateLogin); + if (giteaUser) { + await addOrgOwner(giteaOrg, giteaUser.login); + } + } + } catch (err) { + // Membership add is best-effort + console.warn('[workspaces] Skipping Gitea owner add', err); + } + } + + const status: VibnWorkspace['provision_status'] = + coolifyUuid && giteaOrg ? 'ready' : errors.length > 0 ? 'partial' : 'pending'; + + const updated = await query( + `UPDATE vibn_workspaces + SET coolify_project_uuid = COALESCE($2, coolify_project_uuid), + gitea_org = COALESCE($3, gitea_org), + provision_status = $4, + provision_error = $5, + updated_at = now() + WHERE id = $1 + RETURNING *`, + [workspace.id, coolifyUuid, giteaOrg, status, errors.length ? errors.join('; ') : null] + ); + + return updated[0]; +} + +/** + * Convenience: get-or-create + provision in one call. Used by the + * project-create flow so the first project in a fresh account always + * has somewhere to land. + */ +export async function getOrCreateProvisionedWorkspace(opts: { + userId: string; + email: string; + displayName?: string | null; +}): Promise { + const ws = await ensureWorkspaceForUser(opts); + return ensureWorkspaceProvisioned(ws); +} + +// ────────────────────────────────────────────────── +// Slug uniqueness +// ────────────────────────────────────────────────── + +async function pickAvailableSlug(base: string): Promise { + // Try base, then base-2, base-3, … up to base-99. + for (let i = 0; i < 100; i++) { + const candidate = i === 0 ? base : `${base}-${i + 1}`; + const existing = await queryOne<{ id: string }>( + `SELECT id FROM vibn_workspaces WHERE slug = $1 LIMIT 1`, + [candidate] + ); + if (!existing) return candidate; + } + // Extremely unlikely fallback + return `${base}-${Date.now().toString(36)}`; +}