feat(workspaces): per-account tenancy + AI access keys + Cursor integration

Adds logical multi-tenancy on top of Coolify + Gitea so every Vibn
account gets its own isolated tenant boundary, and exposes that
boundary to AI agents (Cursor, Claude Code, scripts) through
per-workspace bearer tokens.

Schema (additive, idempotent — run /api/admin/migrate once after deploy)
  - vibn_workspaces: slug, name, owner, coolify_project_uuid,
    coolify_team_id (reserved for when Coolify ships POST /teams),
    gitea_org, provision_status
  - vibn_workspace_members: room for multi-user workspaces later
  - vibn_workspace_api_keys: sha256-hashed bearer tokens
  - fs_projects.vibn_workspace_id: nullable FK linking projects
    to their workspace

Provisioning
  - On first sign-in, ensureWorkspaceForUser() inserts the row
    (no network calls — keeps signin fast).
  - On first project create, ensureWorkspaceProvisioned() lazily
    creates a Coolify Project (vibn-ws-{slug}) and a Gitea org
    (vibn-{slug}). Failures are recorded on the row, not thrown,
    and POST /api/workspaces/{slug}/provision retries.

Auth surface
  - lib/auth/workspace-auth.ts: requireWorkspacePrincipal() accepts
    either a NextAuth session or "Authorization: Bearer vibn_sk_...".
    The bearer key is hard-pinned to one workspace — it cannot
    reach any other tenant.
  - mintWorkspaceApiKey / listWorkspaceApiKeys / revokeWorkspaceApiKey

Routes
  - GET    /api/workspaces                         list
  - GET    /api/workspaces/[slug]                  details
  - POST   /api/workspaces/[slug]/provision        retry provisioning
  - GET    /api/workspaces/[slug]/keys             list keys
  - POST   /api/workspaces/[slug]/keys             mint key (token shown once)
  - DELETE /api/workspaces/[slug]/keys/[keyId]     revoke

UI
  - components/workspace/WorkspaceKeysPanel.tsx: identity card,
    keys CRUD with one-time secret reveal, and a "Connect Cursor"
    block with copy/download for:
      .cursor/rules/vibn-workspace.mdc — rule telling the agent
        about the API + workspace IDs + house rules
      ~/.cursor/mcp.json — MCP server registration with key
        embedded (server URL is /api/mcp; HTTP MCP route lands next)
      .env.local — VIBN_API_KEY + smoke-test curl
  - Slotted into existing /[workspace]/settings between Workspace
    and Notifications cards (no other layout changes).

projects/create
  - Resolves the user's workspace (creating + provisioning lazily).
  - Repos go under workspace.gitea_org (falls back to GITEA_ADMIN_USER
    for backwards compat).
  - Coolify services are created inside workspace.coolify_project_uuid
    (renamed {slug}-{appName} to stay unique within the namespace) —
    no more per-Vibn-project Coolify Project sprawl.
  - Stamps vibn_workspace_id on fs_projects.

lib/gitea
  - createOrg, getOrg, addOrgOwner, getUser
  - createRepo now routes /orgs/{owner}/repos when owner != admin

Also includes prior-turn auth hardening that was already in
authOptions.ts (CredentialsProvider for dev-local, isLocalNextAuth
cookie config) bundled in to keep the auth layer in one consistent
state.

.env.example
  - Documents GITEA_API_URL / GITEA_API_TOKEN / GITEA_ADMIN_USER /
    GITEA_WEBHOOK_SECRET and COOLIFY_URL / COOLIFY_API_TOKEN /
    COOLIFY_SERVER_UUID, with the canonical hostnames
    (git.vibnai.com, coolify.vibnai.com).

Post-deploy
  - Run once: curl -X POST https://vibnai.com/api/admin/migrate \\
      -H "x-admin-secret: \$ADMIN_MIGRATE_SECRET"
  - Existing users get a workspace row on next sign-in.
  - Existing fs_projects keep working (legacy gitea owner + their
    own per-project Coolify Projects); new projects use the
    workspace-scoped path.

Not in this commit (follow-ups)
  - Wiring requireWorkspacePrincipal into the rest of /api/projects/*
    so API keys can drive existing routes
  - HTTP MCP server at /api/mcp (the mcp.json snippet already
    points at the right URL — no client re-setup when it lands)
  - Backfill script to assign legacy fs_projects to a workspace

Made-with: Cursor
This commit is contained in:
2026-04-20 17:17:12 -07:00
parent ccc6cc1da5
commit acb63a2a5a
14 changed files with 1824 additions and 56 deletions

View File

@@ -1,11 +1,25 @@
# Copy to Coolify environment variables (or .env.local for dev). Do not commit secrets. # Copy to Coolify environment variables (or .env.local for dev). Do not commit secrets.
# --- Postgres (Coolify internal service DNS, same stack as this app) --- # --- Postgres: local `next dev` (Coolify internal hostnames do NOT work on your laptop) ---
# Example: postgresql://USER:PASS@<coolify-service-uuid>:5432/vibn # 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= DATABASE_URL=
POSTGRES_URL= POSTGRES_URL=
# --- Public URL of this Next app (OAuth callbacks, runner callbacks) --- # --- 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_URL=https://vibnai.com
NEXTAUTH_SECRET= NEXTAUTH_SECRET=
@@ -18,6 +32,30 @@ AGENT_RUNNER_SECRET=
# --- Optional: one-shot DDL via POST /api/admin/migrate --- # --- Optional: one-shot DDL via POST /api/admin/migrate ---
# ADMIN_MIGRATE_SECRET= # 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 OAuth / Gemini (see .google.env locally) ---
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= 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

View File

@@ -9,6 +9,7 @@ import { auth } from '@/lib/firebase/config';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Settings, User, Bell, Shield, Trash2 } from 'lucide-react'; import { Settings, User, Bell, Shield, Trash2 } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { WorkspaceKeysPanel } from '@/components/workspace/WorkspaceKeysPanel';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -177,6 +178,9 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Workspace tenancy + AI access keys */}
<WorkspaceKeysPanel workspaceSlug={workspace} />
{/* Notifications */} {/* Notifications */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@@ -139,6 +139,54 @@ export async function POST(req: NextRequest) {
expires TIMESTAMPTZ NOT NULL, expires TIMESTAMPTZ NOT NULL,
UNIQUE (identifier, token) 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) { for (const stmt of statements) {

View File

@@ -1,12 +1,12 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; import { authSession } from "@/lib/auth/session-server";
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres'; import { query } from '@/lib/db-postgres';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea'; import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea';
import { pushTurborepoScaffold } from '@/lib/scaffold'; 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 { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace';
import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces';
import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts'; import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts';
const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT; 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) { export async function POST(request: Request) {
try { try {
const session = await getServerSession(authOptions); const session = await authSession();
if (!session?.user?.email) { if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
@@ -53,6 +53,19 @@ export async function POST(request: Request) {
const firebaseUserId = users[0]!.id; 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 body = await request.json();
const { const {
projectName, projectName,
@@ -97,14 +110,15 @@ export async function POST(request: Request) {
description: `${projectName} — managed by Vibn`, description: `${projectName} — managed by Vibn`,
private: true, private: true,
auto_init: false, 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) { } catch (createErr) {
const msg = createErr instanceof Error ? createErr.message : String(createErr); const msg = createErr instanceof Error ? createErr.message : String(createErr);
// 409 = repo already exists — link to it instead of failing // 409 = repo already exists — link to it instead of failing
if (msg.includes('409')) { if (msg.includes('409')) {
console.log(`[API] Gitea repo already exists, linking to ${GITEA_ADMIN_USER}/${repoName}`); console.log(`[API] Gitea repo already exists, linking to ${repoOwner}/${repoName}`);
repo = await getRepo(GITEA_ADMIN_USER, repoName); repo = await getRepo(repoOwner, repoName);
if (!repo) throw new Error(`Repo ${repoName} exists but could not be fetched`); if (!repo) throw new Error(`Repo ${repoName} exists but could not be fetched`);
} else { } else {
throw createErr; throw createErr;
@@ -125,7 +139,7 @@ export async function POST(request: Request) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
github_url: githubRepoUrl, github_url: githubRepoUrl,
gitea_repo: `${GITEA_ADMIN_USER}/${repoName}`, gitea_repo: `${repoOwner}/${repoName}`,
project_name: projectName, project_name: projectName,
github_token: githubToken || undefined, github_token: githubToken || undefined,
}), }),
@@ -136,17 +150,17 @@ export async function POST(request: Request) {
} }
console.log(`[API] GitHub repo mirrored to ${giteaRepo}`); console.log(`[API] GitHub repo mirrored to ${giteaRepo}`);
} else { } else {
await pushTurborepoScaffold(GITEA_ADMIN_USER, repoName, slug, projectName); await pushTurborepoScaffold(repoOwner, repoName, slug, projectName);
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`); console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
} }
// Register webhook — skip if one already points to this project // Register webhook — skip if one already points to this project
const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`; 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)); const alreadyHooked = existingHooks.some(h => h.config.url.includes(projectId));
if (!alreadyHooked) { 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; giteaWebhookId = hook.id;
console.log(`[API] Webhook registered: ${giteaRepo}, id: ${giteaWebhookId}`); console.log(`[API] Webhook registered: ${giteaRepo}, id: ${giteaWebhookId}`);
} else { } 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 APP_BASE_DOMAIN = process.env.APP_BASE_DOMAIN ?? 'vibnai.com';
const appNames = ['product', 'website', 'admin', 'storybook'] as const; const appNames = ['product', 'website', 'admin', 'storybook'] as const;
@@ -168,22 +182,17 @@ export async function POST(request: Request) {
name: string; path: string; coolifyServiceUuid: string | null; domain: string | null; name: string; path: string; coolifyServiceUuid: string | null; domain: string | null;
}> = appNames.map(name => ({ name, path: `apps/${name}`, coolifyServiceUuid: null, domain: 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.
if (giteaCloneUrl) { const coolifyProjectUuid: string | null = vibnWorkspace.coolify_project_uuid;
try {
const coolifyProject = await createCoolifyProject(
projectName,
`Vibn project for ${projectName}`
);
coolifyProjectUuid = coolifyProject.uuid;
if (giteaCloneUrl && coolifyProjectUuid) {
for (const app of provisionedApps) { for (const app of provisionedApps) {
try { try {
const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`; const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`;
const service = await createMonorepoAppService({ const service = await createMonorepoAppService({
projectUuid: coolifyProject.uuid, projectUuid: coolifyProjectUuid,
appName: app.name, appName: `${slug}-${app.name}`, // unique within the workspace's Coolify Project
gitRepo: giteaCloneUrl, gitRepo: giteaCloneUrl,
domain, domain,
}); });
@@ -194,9 +203,8 @@ export async function POST(request: Request) {
console.error(`[API] Coolify service failed for ${app.name}:`, appErr); console.error(`[API] Coolify service failed for ${app.name}:`, appErr);
} }
} }
} catch (coolifyErr) { } else if (!coolifyProjectUuid) {
console.error('[API] Coolify project provisioning failed (non-fatal):', coolifyErr); 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(` await query(`
INSERT INTO fs_projects (id, data, user_id, workspace, slug) INSERT INTO fs_projects (id, data, user_id, workspace, slug, vibn_workspace_id)
VALUES ($1, $2::jsonb, $3, $4, $5) VALUES ($1, $2::jsonb, $3, $4, $5, $6)
`, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug]); `, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug, vibnWorkspace.id]);
// Associate any unlinked sessions for this workspace path // Associate any unlinked sessions for this workspace path
if (workspacePath) { if (workspacePath) {

View File

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

View File

@@ -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,
});
}

View File

@@ -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,
});
}

View File

@@ -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,
},
});
}

View File

@@ -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,
};
}

View File

@@ -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<WorkspaceSummary | null>(null);
const [keys, setKeys] = useState<ApiKey[]>([]);
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<MintedKey | null>(null);
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(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 (
<div style={{ padding: 24, color: "var(--muted)", fontSize: 13 }}>
<Loader2 className="inline animate-spin" size={14} /> Loading workspace
</div>
);
}
if (!workspace) {
return (
<div style={{ padding: 24, color: "var(--muted)", fontSize: 13 }}>
Workspace not found.
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
<WorkspaceIdentityCard workspace={workspace} onProvision={provision} provisioning={provisioning} />
<KeysCard
workspace={workspace}
keys={keys}
onCreateClick={() => setShowCreate(true)}
onRevokeClick={k => setKeyToRevoke(k)}
onRefresh={refresh}
/>
<CursorIntegrationCard workspace={workspace} />
{/* ── Create key modal ─────────────────────────────────────── */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create workspace API key</DialogTitle>
<DialogDescription>
Used by AI agents (Cursor, Claude, scripts) to act on
behalf of <code>{workspace.slug}</code>. The token is shown
once save it somewhere safe.
</DialogDescription>
</DialogHeader>
<div style={{ display: "grid", gap: 8 }}>
<Label htmlFor="key-name">Key name</Label>
<Input
id="key-name"
placeholder="e.g. Cursor on my MacBook"
value={newName}
onChange={e => setNewName(e.target.value)}
autoFocus
onKeyDown={e => {
if (e.key === "Enter") createKey();
}}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowCreate(false)} disabled={creating}>
Cancel
</Button>
<Button onClick={createKey} disabled={creating || !newName.trim()}>
{creating && <Loader2 className="animate-spin" size={14} />}
Mint key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Show minted secret ONCE ──────────────────────────────── */}
<Dialog open={!!minted} onOpenChange={open => !open && setMinted(null)}>
<DialogContent style={{ maxWidth: 640 }}>
<DialogHeader>
<DialogTitle>Save your API key</DialogTitle>
<DialogDescription>
This is the only time the full key is shown. Store it in a
password manager or paste it into the Cursor config below.
</DialogDescription>
</DialogHeader>
{minted && <MintedKeyView workspace={workspace} minted={minted} />}
<DialogFooter>
<Button onClick={() => setMinted(null)}>I&apos;ve saved it</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Revoke confirm ───────────────────────────────────────── */}
<AlertDialog open={!!keyToRevoke} onOpenChange={open => !open && setKeyToRevoke(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke this key?</AlertDialogTitle>
<AlertDialogDescription>
Any agent using <code>{keyToRevoke?.prefix}</code> will
immediately lose access to {workspace.slug}. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={revoking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={revokeKey} disabled={revoking}>
{revoking && <Loader2 className="animate-spin" size={14} />}
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// ──────────────────────────────────────────────────
// 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 (
<section style={cardStyle}>
<header style={cardHeaderStyle}>
<div>
<h2 style={cardTitleStyle}>Workspace</h2>
<p style={cardSubtitleStyle}>
Your tenant boundary on Coolify and Gitea. All AI access is scoped to this workspace.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={onProvision}
disabled={provisioning}
>
{provisioning ? <Loader2 className="animate-spin" size={14} /> : <RefreshCw size={14} />}
Re-provision
</Button>
</header>
<dl style={kvGrid}>
<Kv label="Slug" value={<code>{workspace.slug}</code>} />
<Kv
label="Status"
value={
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: statusColor }} />
{status}
</span>
}
/>
<Kv
label="Coolify Project"
value={
workspace.coolifyProjectUuid ? (
<code style={{ fontSize: 11 }}>{workspace.coolifyProjectUuid}</code>
) : (
<span style={{ color: "var(--muted)" }}>not provisioned</span>
)
}
/>
<Kv
label="Gitea Org"
value={
workspace.giteaOrg ? (
<code>{workspace.giteaOrg}</code>
) : (
<span style={{ color: "var(--muted)" }}>not provisioned</span>
)
}
/>
</dl>
{workspace.provisionError && (
<div
style={{
marginTop: 12,
padding: "10px 12px",
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 8,
color: "#991b1b",
fontSize: 12,
fontFamily: "monospace",
}}
>
{workspace.provisionError}
</div>
)}
</section>
);
}
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 (
<section style={cardStyle}>
<header style={cardHeaderStyle}>
<div>
<h2 style={cardTitleStyle}>API keys</h2>
<p style={cardSubtitleStyle}>
Tokens scoped to <code>{workspace.slug}</code>. Use them in Cursor,
Claude Code, the CLI, or any HTTP client.
</p>
</div>
<div style={{ display: "flex", gap: 8 }}>
<Button variant="ghost" size="sm" onClick={onRefresh}>
<RefreshCw size={14} />
</Button>
<Button size="sm" onClick={onCreateClick}>
<Plus size={14} />
New key
</Button>
</div>
</header>
{active.length === 0 ? (
<EmptyKeysState onCreateClick={onCreateClick} />
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
{active.map(k => (
<KeyRow key={k.id} k={k} onRevoke={() => onRevokeClick(k)} />
))}
</ul>
)}
{revoked.length > 0 && (
<details style={{ marginTop: 16 }}>
<summary style={{ fontSize: 12, color: "var(--muted)", cursor: "pointer" }}>
{revoked.length} revoked
</summary>
<ul style={{ listStyle: "none", margin: "8px 0 0", padding: 0, display: "flex", flexDirection: "column", gap: 6, opacity: 0.6 }}>
{revoked.map(k => (
<KeyRow key={k.id} k={k} />
))}
</ul>
</details>
)}
</section>
);
}
function KeyRow({ k, onRevoke }: { k: ApiKey; onRevoke?: () => void }) {
return (
<li
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "10px 12px",
background: "#fff",
border: "1px solid var(--border, #e5e7eb)",
borderRadius: 8,
}}
>
<KeyRound size={16} style={{ color: "var(--muted)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)" }}>{k.name}</div>
<div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "monospace" }}>
{k.prefix}
{k.last_used_at
? ` · last used ${new Date(k.last_used_at).toLocaleString()}`
: " · never used"}
</div>
</div>
{onRevoke && (
<Button variant="ghost" size="sm" onClick={onRevoke} aria-label="Revoke">
<Trash2 size={14} />
</Button>
)}
</li>
);
}
function EmptyKeysState({ onCreateClick }: { onCreateClick: () => void }) {
return (
<div
style={{
padding: "24px 16px",
textAlign: "center",
background: "#f9fafb",
border: "1px dashed var(--border, #e5e7eb)",
borderRadius: 8,
}}
>
<KeyRound size={20} style={{ margin: "0 auto 8px", color: "var(--muted)" }} />
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)" }}>No API keys yet</div>
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 4 }}>
Mint one to let Cursor or any AI agent push code and deploy on your behalf.
</div>
<Button size="sm" onClick={onCreateClick} style={{ marginTop: 12 }}>
<Plus size={14} />
Create first key
</Button>
</div>
);
}
// ──────────────────────────────────────────────────
// Cursor / VS Code integration block
// ──────────────────────────────────────────────────
function CursorIntegrationCard({ workspace }: { workspace: WorkspaceSummary }) {
const cursorRule = buildCursorRule(workspace);
const mcpJson = buildMcpJson(workspace, "<paste-your-vibn_sk_-token>");
const envSnippet = buildEnvSnippet(workspace, "<paste-your-vibn_sk_-token>");
return (
<section style={cardStyle}>
<header style={cardHeaderStyle}>
<div>
<h2 style={cardTitleStyle}>Connect Cursor</h2>
<p style={cardSubtitleStyle}>
Drop these into your repo (or <code>~/.cursor/</code>) so any
agent inside Cursor knows how to talk to this workspace.
</p>
</div>
</header>
<FileBlock
title=".cursor/rules/vibn-workspace.mdc"
description="Tells the agent it can use the Vibn API and which workspace it's bound to."
filename="vibn-workspace.mdc"
contents={cursorRule}
language="markdown"
/>
<FileBlock
title="~/.cursor/mcp.json"
description="Registers Vibn as an MCP server so the agent can call workspace endpoints natively. Paste your minted key in place of the placeholder."
filename="mcp.json"
contents={mcpJson}
language="json"
/>
<FileBlock
title=".env.local (for shell / scripts)"
description="If the agent shells out (curl, gh, scripts), expose the key as an env var."
filename="vibn.env"
contents={envSnippet}
language="bash"
/>
</section>
);
}
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 (
<div style={{ marginTop: 14, border: "1px solid var(--border, #e5e7eb)", borderRadius: 8, overflow: "hidden" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 12px",
background: "#f9fafb",
borderBottom: "1px solid var(--border, #e5e7eb)",
gap: 12,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: "var(--ink)", fontFamily: "monospace" }}>{title}</div>
<div style={{ fontSize: 11.5, color: "var(--muted)", marginTop: 2 }}>{description}</div>
</div>
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
<Button variant="ghost" size="sm" onClick={copy} aria-label={`Copy ${filename}`}>
<Copy size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={download} aria-label={`Download ${filename}`}>
<Download size={14} />
</Button>
</div>
</div>
<pre
style={{
margin: 0,
padding: 12,
background: "#0f172a",
color: "#e2e8f0",
fontSize: 12,
lineHeight: 1.55,
overflowX: "auto",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
}}
>
<code data-language={language}>{contents}</code>
</pre>
</div>
);
}
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 (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<FileBlock
title="Your key"
description="Copy this now — the full value is never shown again."
filename={`${workspace.slug}-${minted.name.replace(/\s+/g, "-")}.txt`}
contents={minted.token}
language="text"
/>
<FileBlock
title=".cursor/rules/vibn-workspace.mdc"
description="Drop into your repo so Cursor agents know about the Vibn API."
filename="vibn-workspace.mdc"
contents={cursorRule}
language="markdown"
/>
<FileBlock
title="~/.cursor/mcp.json (key embedded)"
description="Paste into Cursor's MCP config to register Vibn as a tool source."
filename="mcp.json"
contents={mcpJson}
language="json"
/>
<FileBlock
title=".env.local"
description="For shell / script use."
filename="vibn.env"
contents={envSnippet}
language="bash"
/>
</div>
);
}
// ──────────────────────────────────────────────────
// 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 ?? "<org>"}/<repo>\` 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 (
<div>
<dt style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--muted)" }}>
{label}
</dt>
<dd style={{ margin: "4px 0 0", fontSize: 13, color: "var(--ink)", wordBreak: "break-all" }}>{value}</dd>
</div>
);
}

View File

@@ -1,14 +1,85 @@
import { NextAuthOptions } from "next-auth"; import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import GoogleProvider from "next-auth/providers/google"; import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { query } from "@/lib/db-postgres"; import { query } from "@/lib/db-postgres";
import { ensureWorkspaceForUser } from "@/lib/workspaces";
const prisma = new PrismaClient(); 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 = { export const authOptions: NextAuthOptions = {
debug: process.env.NODE_ENV === "development",
adapter: PrismaAdapter(prisma), adapter: PrismaAdapter(prisma),
providers: [ 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({ GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || "", clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
@@ -20,8 +91,8 @@ export const authOptions: NextAuthOptions = {
}, },
callbacks: { callbacks: {
async session({ session, user }) { async session({ session, user }) {
if (session.user) { if (session.user && "id" in user && user.id) {
session.user.id = user.id; (session.user as { id: string }).id = user.id;
} }
return session; return session;
}, },
@@ -42,16 +113,34 @@ export const authOptions: NextAuthOptions = {
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`, `SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
[user.email] [user.email]
); );
let fsUserId: string;
if (existing.length === 0) { if (existing.length === 0) {
await query( const inserted = await query<{ id: string }>(
`INSERT INTO fs_users (id, user_id, data) VALUES (gen_random_uuid()::text, $1, $2::jsonb)`, `INSERT INTO fs_users (id, user_id, data)
VALUES (gen_random_uuid()::text, $1, $2::jsonb)
RETURNING id`,
[user.id, data] [user.id, data]
); );
fsUserId = inserted[0].id;
} else { } else {
await query( await query(
`UPDATE fs_users SET user_id = $1, data = data || $2::jsonb, updated_at = NOW() WHERE id = $3`, `UPDATE fs_users SET user_id = $1, data = data || $2::jsonb, updated_at = NOW() WHERE id = $3`,
[user.id, data, existing[0].id] [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) { } catch (e) {
console.error("[signIn] Failed to upsert fs_user:", e); console.error("[signIn] Failed to upsert fs_user:", e);
@@ -66,13 +155,14 @@ export const authOptions: NextAuthOptions = {
secret: process.env.NEXTAUTH_SECRET, secret: process.env.NEXTAUTH_SECRET,
cookies: { cookies: {
sessionToken: { 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: { options: {
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
secure: true, secure: !isLocalNextAuth,
domain: ".vibnai.com", // share across all subdomains (theia.vibnai.com, etc.) ...(isLocalNextAuth ? {} : { domain: ".vibnai.com" }),
}, },
}, },
}, },

247
lib/auth/workspace-auth.ts Normal file
View File

@@ -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<WorkspacePrincipal | NextResponse> {
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<WorkspacePrincipal | null> {
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<MintedApiKey> {
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<Array<{
id: string;
name: string;
prefix: string;
scopes: string[];
created_by: string;
last_used_at: Date | null;
revoked_at: Date | null;
created_at: Date;
}>> {
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<boolean> {
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');
}

View File

@@ -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( export async function createRepo(
name: string, name: string,
@@ -62,18 +65,86 @@ export async function createRepo(
): Promise<GiteaRepo> { ): Promise<GiteaRepo> {
const { description = '', private: isPrivate = true, owner = GITEA_ADMIN_USER, auto_init = true } = opts; const { description = '', private: isPrivate = true, owner = GITEA_ADMIN_USER, auto_init = true } = opts;
return giteaFetch(`/user/repos`, { const body = JSON.stringify({
method: 'POST',
body: JSON.stringify({
name, name,
description, description,
private: isPrivate, private: isPrivate,
auto_init, auto_init,
default_branch: 'main', 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<GiteaOrg> {
const { name, fullName = name, description = '', visibility = 'private' } = opts;
return giteaFetch(`/orgs`, {
method: 'POST',
body: JSON.stringify({
username: name,
full_name: fullName,
description,
visibility,
repo_admin_change_team_access: true,
}), }),
}); });
} }
export async function getOrg(name: string): Promise<GiteaOrg | null> {
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<void> {
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. * Get an existing repo.
*/ */

270
lib/workspaces.ts Normal file
View File

@@ -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<VibnWorkspace | null> {
return queryOne<VibnWorkspace>(`SELECT * FROM vibn_workspaces WHERE id = $1`, [id]);
}
export async function getWorkspaceBySlug(slug: string): Promise<VibnWorkspace | null> {
return queryOne<VibnWorkspace>(`SELECT * FROM vibn_workspaces WHERE slug = $1`, [slug]);
}
export async function getWorkspaceByOwner(userId: string): Promise<VibnWorkspace | null> {
return queryOne<VibnWorkspace>(
`SELECT * FROM vibn_workspaces WHERE owner_user_id = $1 ORDER BY created_at ASC LIMIT 1`,
[userId]
);
}
export async function listWorkspacesForUser(userId: string): Promise<VibnWorkspace[]> {
return query<VibnWorkspace>(
`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<boolean> {
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<VibnWorkspace> {
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<VibnWorkspace>(
`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<VibnWorkspace> {
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<VibnWorkspace>(
`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<VibnWorkspace> {
const ws = await ensureWorkspaceForUser(opts);
return ensureWorkspaceProvisioned(ws);
}
// ──────────────────────────────────────────────────
// Slug uniqueness
// ──────────────────────────────────────────────────
async function pickAvailableSlug(base: string): Promise<string> {
// 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)}`;
}