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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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