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:
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user