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:
42
.env.example
42
.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@<coolify-service-uuid>: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
|
||||
|
||||
@@ -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() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Workspace tenancy + AI access keys */}
|
||||
<WorkspaceKeysPanel workspaceSlug={workspace} />
|
||||
|
||||
{/* Notifications */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
28
app/api/workspaces/[slug]/keys/[keyId]/route.ts
Normal file
28
app/api/workspaces/[slug]/keys/[keyId]/route.ts
Normal 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 });
|
||||
}
|
||||
72
app/api/workspaces/[slug]/keys/route.ts
Normal file
72
app/api/workspaces/[slug]/keys/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
29
app/api/workspaces/[slug]/provision/route.ts
Normal file
29
app/api/workspaces/[slug]/provision/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
33
app/api/workspaces/[slug]/route.ts
Normal file
33
app/api/workspaces/[slug]/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
52
app/api/workspaces/route.ts
Normal file
52
app/api/workspaces/route.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
778
components/workspace/WorkspaceKeysPanel.tsx
Normal file
778
components/workspace/WorkspaceKeysPanel.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
247
lib/auth/workspace-auth.ts
Normal file
247
lib/auth/workspace-auth.ts
Normal 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');
|
||||
}
|
||||
83
lib/gitea.ts
83
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<GiteaRepo> {
|
||||
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<GiteaOrg> {
|
||||
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<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.
|
||||
*/
|
||||
|
||||
270
lib/workspaces.ts
Normal file
270
lib/workspaces.ts
Normal 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user