feat(ai-access): per-workspace Gitea bot + tenant-safe Coolify proxy + MCP
Ship Phases 1–3 of the multi-tenant AI access plan so an AI agent can
act on a Vibn workspace with one bearer token and zero admin reach.
Phase 1 — Gitea bot per workspace
- Add gitea_bot_username / gitea_bot_user_id / gitea_bot_token_encrypted
columns to vibn_workspaces (migrate route).
- New lib/auth/secret-box.ts (AES-256-GCM, VIBN_SECRETS_KEY) for PAT at rest.
- Extend lib/gitea.ts with createUser, createAccessTokenFor (Sudo PAT),
createOrgTeam, addOrgTeamMember, ensureOrgTeamMembership.
- ensureWorkspaceProvisioned now mints a vibn-bot-<slug> user, adds it to
a Writers team (write perms only) on the workspace's org, and stores
its PAT encrypted.
- GET /api/workspaces/[slug]/gitea-credentials returns a workspace-scoped
bot PAT + clone URL template; session or vibn_sk_ bearer auth.
Phase 2 — Tenant-safe Coolify proxy + real MCP
- lib/coolify.ts: projectUuidOf, listApplicationsInProject,
getApplicationInProject, TenantError, env CRUD, deployments list.
- Workspace-scoped REST endpoints (all filtered by coolify_project_uuid):
GET/POST /api/workspaces/[slug]/apps/[uuid](/deploy|/envs|/deployments),
GET /api/workspaces/[slug]/deployments/[deploymentUuid]/logs.
- Full rewrite of /api/mcp off legacy Firebase onto Postgres vibn_sk_
keys, exposing workspace.describe, gitea.credentials, projects.*,
apps.* (list/get/deploy/deployments, envs.list/upsert/delete).
Phase 3 — Settings UI AI bundle
- GET /api/workspaces/[slug]/bootstrap.sh: curl|sh installer that writes
.cursor/rules, .cursor/mcp.json and appends VIBN_* to .env.local.
Embeds the caller's vibn_sk_ token when invoked with bearer auth.
- WorkspaceKeysPanel: single AiAccessBundleCard with system-prompt block,
one-line bootstrap, Reveal-bot-PAT button, collapsible manual-setup
fallback. Minted-key modal also shows the bootstrap one-liner.
Ops prerequisites:
- Set VIBN_SECRETS_KEY (>=16 chars) on the frontend.
- Run /api/admin/migrate to add the three bot columns.
- GITEA_API_TOKEN must be a site-admin token (needed for admin/users
+ Sudo PAT mint); otherwise provision_status lands on 'partial'.
Made-with: Cursor
This commit is contained in:
47
app/api/workspaces/[slug]/apps/[uuid]/deploy/route.ts
Normal file
47
app/api/workspaces/[slug]/apps/[uuid]/deploy/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* POST /api/workspaces/[slug]/apps/[uuid]/deploy
|
||||
*
|
||||
* Trigger a deploy on a Coolify app. Guard: app must belong to this
|
||||
* workspace's Coolify project before we forward the call.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import {
|
||||
deployApplication,
|
||||
getApplicationInProject,
|
||||
TenantError,
|
||||
} from '@/lib/coolify';
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||
) {
|
||||
const { slug, uuid } = await params;
|
||||
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
|
||||
const ws = principal.workspace;
|
||||
if (!ws.coolify_project_uuid) {
|
||||
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Tenant check before any mutation.
|
||||
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||
const result = await deployApplication(uuid);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
deploymentUuid: result.deployment_uuid,
|
||||
appUuid: uuid,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof TenantError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Deploy failed', details: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
app/api/workspaces/[slug]/apps/[uuid]/deployments/route.ts
Normal file
41
app/api/workspaces/[slug]/apps/[uuid]/deployments/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* GET /api/workspaces/[slug]/apps/[uuid]/deployments
|
||||
*
|
||||
* Recent deployments for an app. Tenant-checked.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import {
|
||||
getApplicationInProject,
|
||||
listApplicationDeployments,
|
||||
TenantError,
|
||||
} from '@/lib/coolify';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||
) {
|
||||
const { slug, uuid } = await params;
|
||||
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
|
||||
const ws = principal.workspace;
|
||||
if (!ws.coolify_project_uuid) {
|
||||
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||
const deployments = await listApplicationDeployments(uuid);
|
||||
return NextResponse.json({ deployments });
|
||||
} catch (err) {
|
||||
if (err instanceof TenantError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Coolify request failed', details: String(err) },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
154
app/api/workspaces/[slug]/apps/[uuid]/envs/route.ts
Normal file
154
app/api/workspaces/[slug]/apps/[uuid]/envs/route.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* GET /api/workspaces/[slug]/apps/[uuid]/envs — list env vars
|
||||
* PATCH /api/workspaces/[slug]/apps/[uuid]/envs — upsert one env var
|
||||
* body: { key, value, is_preview?, is_build_time?, is_literal?, is_multiline? }
|
||||
* DELETE /api/workspaces/[slug]/apps/[uuid]/envs?key=FOO — delete one env var
|
||||
*
|
||||
* Tenant boundary: the app must belong to the workspace's Coolify project.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import {
|
||||
deleteApplicationEnv,
|
||||
getApplicationInProject,
|
||||
listApplicationEnvs,
|
||||
TenantError,
|
||||
upsertApplicationEnv,
|
||||
} from '@/lib/coolify';
|
||||
|
||||
async function verify(request: Request, slug: string, uuid: string) {
|
||||
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||
if (principal instanceof NextResponse) return { error: principal };
|
||||
const ws = principal.workspace;
|
||||
if (!ws.coolify_project_uuid) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: 'Workspace has no Coolify project yet' },
|
||||
{ status: 503 }
|
||||
),
|
||||
};
|
||||
}
|
||||
try {
|
||||
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||
} catch (err) {
|
||||
if (err instanceof TenantError) {
|
||||
return { error: NextResponse.json({ error: err.message }, { status: 403 }) };
|
||||
}
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: 'Coolify request failed', details: String(err) },
|
||||
{ status: 502 }
|
||||
),
|
||||
};
|
||||
}
|
||||
return { principal };
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||
) {
|
||||
const { slug, uuid } = await params;
|
||||
const check = await verify(request, slug, uuid);
|
||||
if ('error' in check) return check.error;
|
||||
|
||||
try {
|
||||
const envs = await listApplicationEnvs(uuid);
|
||||
// Redact values by default for API-key callers — they can re-fetch
|
||||
// with ?reveal=true when they need the actual values (e.g. to copy
|
||||
// a DATABASE_URL). Session callers always get full values.
|
||||
const url = new URL(request.url);
|
||||
const reveal =
|
||||
check.principal.source === 'session' || url.searchParams.get('reveal') === 'true';
|
||||
return NextResponse.json({
|
||||
envs: envs.map(e => ({
|
||||
key: e.key,
|
||||
value: reveal ? e.value : maskValue(e.value),
|
||||
isPreview: e.is_preview ?? false,
|
||||
isBuildTime: e.is_build_time ?? false,
|
||||
isLiteral: e.is_literal ?? false,
|
||||
isMultiline: e.is_multiline ?? false,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Coolify request failed', details: String(err) },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||
) {
|
||||
const { slug, uuid } = await params;
|
||||
const check = await verify(request, slug, uuid);
|
||||
if ('error' in check) return check.error;
|
||||
|
||||
let body: {
|
||||
key?: string;
|
||||
value?: string;
|
||||
is_preview?: boolean;
|
||||
is_build_time?: boolean;
|
||||
is_literal?: boolean;
|
||||
is_multiline?: boolean;
|
||||
};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.key || typeof body.value !== 'string') {
|
||||
return NextResponse.json({ error: 'Fields "key" and "value" are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const env = await upsertApplicationEnv(uuid, {
|
||||
key: body.key,
|
||||
value: body.value,
|
||||
is_preview: body.is_preview ?? false,
|
||||
is_build_time: body.is_build_time ?? false,
|
||||
is_literal: body.is_literal ?? false,
|
||||
is_multiline: body.is_multiline ?? false,
|
||||
});
|
||||
return NextResponse.json({ ok: true, key: env.key });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Coolify request failed', details: String(err) },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||
) {
|
||||
const { slug, uuid } = await params;
|
||||
const check = await verify(request, slug, uuid);
|
||||
if ('error' in check) return check.error;
|
||||
|
||||
const key = new URL(request.url).searchParams.get('key');
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: 'Query param "key" is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteApplicationEnv(uuid, key);
|
||||
return NextResponse.json({ ok: true, key });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Coolify request failed', details: String(err) },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maskValue(v: string): string {
|
||||
if (!v) return '';
|
||||
if (v.length <= 4) return '•'.repeat(v.length);
|
||||
return `${v.slice(0, 2)}${'•'.repeat(Math.min(v.length - 4, 10))}${v.slice(-2)}`;
|
||||
}
|
||||
45
app/api/workspaces/[slug]/apps/[uuid]/route.ts
Normal file
45
app/api/workspaces/[slug]/apps/[uuid]/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* GET /api/workspaces/[slug]/apps/[uuid]
|
||||
*
|
||||
* Single Coolify app details. Verifies the app's project uuid matches
|
||||
* the workspace's before returning anything.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import { getApplicationInProject, projectUuidOf, TenantError } from '@/lib/coolify';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||
) {
|
||||
const { slug, uuid } = await params;
|
||||
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
|
||||
const ws = principal.workspace;
|
||||
if (!ws.coolify_project_uuid) {
|
||||
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||
return NextResponse.json({
|
||||
uuid: app.uuid,
|
||||
name: app.name,
|
||||
status: app.status,
|
||||
fqdn: app.fqdn ?? null,
|
||||
gitRepository: app.git_repository ?? null,
|
||||
gitBranch: app.git_branch ?? null,
|
||||
projectUuid: projectUuidOf(app),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof TenantError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/api/workspaces/[slug]/apps/route.ts
Normal file
50
app/api/workspaces/[slug]/apps/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* GET /api/workspaces/[slug]/apps — list Coolify apps in this workspace
|
||||
*
|
||||
* Auth: session OR `Bearer vibn_sk_...`. The workspace's
|
||||
* `coolify_project_uuid` acts as the tenant boundary — any app whose
|
||||
* Coolify project uuid doesn't match is filtered out even if the
|
||||
* token issuer accidentally had wider reach.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import { listApplicationsInProject, projectUuidOf } from '@/lib/coolify';
|
||||
|
||||
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 ws = principal.workspace;
|
||||
if (!ws.coolify_project_uuid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workspace has no Coolify project yet', apps: [] },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const apps = await listApplicationsInProject(ws.coolify_project_uuid);
|
||||
return NextResponse.json({
|
||||
workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid },
|
||||
apps: apps.map(a => ({
|
||||
uuid: a.uuid,
|
||||
name: a.name,
|
||||
status: a.status,
|
||||
fqdn: a.fqdn ?? null,
|
||||
gitRepository: a.git_repository ?? null,
|
||||
gitBranch: a.git_branch ?? null,
|
||||
projectUuid: projectUuidOf(a),
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
194
app/api/workspaces/[slug]/bootstrap.sh/route.ts
Normal file
194
app/api/workspaces/[slug]/bootstrap.sh/route.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* GET /api/workspaces/[slug]/bootstrap.sh
|
||||
*
|
||||
* One-shot installer. Intended usage inside a repo:
|
||||
*
|
||||
* curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" \
|
||||
* https://vibnai.com/api/workspaces/<slug>/bootstrap.sh | sh
|
||||
*
|
||||
* Writes three files into the cwd:
|
||||
* - .cursor/rules/vibn-workspace.mdc (system prompt for AI agents)
|
||||
* - .cursor/mcp.json (registers /api/mcp as an MCP server)
|
||||
* - .env.local (appends VIBN_* envs; never overwrites)
|
||||
*
|
||||
* Auth: caller MUST already have a `vibn_sk_...` token. We embed the
|
||||
* same token in the generated mcp.json so Cursor agents can re-use it.
|
||||
* Session auth works too but then nothing is embedded (the user gets
|
||||
* placeholder strings to fill in themselves).
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
|
||||
const APP_BASE = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ?? 'https://vibnai.com';
|
||||
|
||||
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 ws = principal.workspace;
|
||||
const tokenFromHeader = extractBearer(request);
|
||||
// For API-key callers we can safely echo the token they sent us
|
||||
// back into the generated files. For session callers we emit a
|
||||
// placeholder — we don't want to re-issue long-lived tokens from
|
||||
// a cookie-authenticated browser request.
|
||||
const embedToken =
|
||||
principal.source === 'api_key' && tokenFromHeader
|
||||
? tokenFromHeader
|
||||
: '<paste your vibn_sk_ token here>';
|
||||
|
||||
const script = buildScript({
|
||||
slug: ws.slug,
|
||||
giteaOrg: ws.gitea_org ?? '(unprovisioned)',
|
||||
coolifyProjectUuid: ws.coolify_project_uuid ?? '(unprovisioned)',
|
||||
appBase: APP_BASE,
|
||||
token: embedToken,
|
||||
});
|
||||
|
||||
return new NextResponse(script, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/x-shellscript; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function extractBearer(request: Request): string | null {
|
||||
const a = request.headers.get('authorization');
|
||||
if (!a) return null;
|
||||
const m = /^Bearer\s+(vibn_sk_[A-Za-z0-9_-]+)/i.exec(a.trim());
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
|
||||
function buildScript(opts: {
|
||||
slug: string;
|
||||
giteaOrg: string;
|
||||
coolifyProjectUuid: string;
|
||||
appBase: string;
|
||||
token: string;
|
||||
}): string {
|
||||
const { slug, giteaOrg, coolifyProjectUuid, appBase, token } = opts;
|
||||
|
||||
// Build the file bodies in TS so we can shell-escape them cleanly
|
||||
// using base64. The script itself does no string interpolation on
|
||||
// these payloads — it just decodes and writes.
|
||||
const rule = buildCursorRule({ slug, giteaOrg, coolifyProjectUuid, appBase });
|
||||
const mcp = JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[`vibn-${slug}`]: {
|
||||
url: `${appBase}/api/mcp`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
const env = `VIBN_API_BASE=${appBase}\nVIBN_WORKSPACE=${slug}\nVIBN_API_KEY=${token}\n`;
|
||||
|
||||
const b64Rule = Buffer.from(rule, 'utf8').toString('base64');
|
||||
const b64Mcp = Buffer.from(mcp, 'utf8').toString('base64');
|
||||
const b64Env = Buffer.from(env, 'utf8').toString('base64');
|
||||
|
||||
return `#!/usr/bin/env sh
|
||||
# Vibn workspace bootstrap — generated ${new Date().toISOString()}
|
||||
# Workspace: ${slug}
|
||||
#
|
||||
# Writes .cursor/rules/vibn-workspace.mdc, .cursor/mcp.json,
|
||||
# and appends VIBN_* env vars to .env.local (never overwrites).
|
||||
set -eu
|
||||
|
||||
mkdir -p .cursor/rules
|
||||
|
||||
echo "${b64Rule}" | base64 -d > .cursor/rules/vibn-workspace.mdc
|
||||
echo " wrote .cursor/rules/vibn-workspace.mdc"
|
||||
|
||||
echo "${b64Mcp}" | base64 -d > .cursor/mcp.json
|
||||
echo " wrote .cursor/mcp.json"
|
||||
|
||||
if [ -f .env.local ] && grep -q '^VIBN_API_BASE=' .env.local 2>/dev/null; then
|
||||
echo " .env.local already has VIBN_* — skipping env append"
|
||||
else
|
||||
printf '\\n# Vibn workspace ${slug}\\n' >> .env.local
|
||||
echo "${b64Env}" | base64 -d >> .env.local
|
||||
echo " appended VIBN_* to .env.local"
|
||||
fi
|
||||
|
||||
if [ -f .gitignore ] && ! grep -q '^.env.local$' .gitignore 2>/dev/null; then
|
||||
echo '.env.local' >> .gitignore
|
||||
echo " added .env.local to .gitignore"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Vibn workspace '${slug}' is wired up."
|
||||
echo "Restart Cursor to pick up the new MCP server."
|
||||
`;
|
||||
}
|
||||
|
||||
function buildCursorRule(opts: {
|
||||
slug: string;
|
||||
giteaOrg: string;
|
||||
coolifyProjectUuid: string;
|
||||
appBase: string;
|
||||
}): string {
|
||||
const { slug, giteaOrg, coolifyProjectUuid, appBase } = opts;
|
||||
return `---
|
||||
description: Vibn workspace "${slug}" — one-shot setup for AI agents
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Vibn workspace: ${slug}
|
||||
|
||||
You are acting on behalf of the Vibn workspace **${slug}**. All AI
|
||||
integration with Gitea and Coolify happens through the Vibn REST API,
|
||||
which enforces tenancy for you.
|
||||
|
||||
## How to act
|
||||
|
||||
1. Before any git or deploy work, call:
|
||||
\`GET ${appBase}/api/workspaces/${slug}/gitea-credentials\`
|
||||
with \`Authorization: Bearer $VIBN_API_KEY\` to get a
|
||||
workspace-scoped bot username, PAT, and clone URL template.
|
||||
|
||||
2. Use the returned \`cloneUrlTemplate\` (with \`{{repo}}\` substituted)
|
||||
as the git remote. Never pass the root admin token to git.
|
||||
|
||||
3. For deploys, logs, env vars, call the workspace-scoped Coolify
|
||||
endpoints under \`${appBase}/api/workspaces/${slug}/apps/...\`.
|
||||
Any cross-tenant attempt is rejected with HTTP 403.
|
||||
|
||||
## Identity
|
||||
|
||||
- Gitea org: \`${giteaOrg}\`
|
||||
- Coolify project uuid: \`${coolifyProjectUuid}\`
|
||||
- API base: \`${appBase}\`
|
||||
|
||||
## Useful endpoints
|
||||
|
||||
| Method | Path |
|
||||
|-------:|----------------------------------------------------------------|
|
||||
| GET | /api/workspaces/${slug} |
|
||||
| GET | /api/workspaces/${slug}/gitea-credentials |
|
||||
| GET | /api/workspaces/${slug}/apps |
|
||||
| GET | /api/workspaces/${slug}/apps/{uuid} |
|
||||
| POST | /api/workspaces/${slug}/apps/{uuid}/deploy |
|
||||
| GET | /api/workspaces/${slug}/apps/{uuid}/envs |
|
||||
| PATCH | /api/workspaces/${slug}/apps/{uuid}/envs |
|
||||
| DELETE | /api/workspaces/${slug}/apps/{uuid}/envs?key=FOO |
|
||||
| POST | /api/mcp (JSON { tool, params } — see GET /api/mcp for list) |
|
||||
|
||||
## Rules
|
||||
|
||||
- Never print or commit \`$VIBN_API_KEY\`.
|
||||
- Prefer PRs over force-pushing \`main\`.
|
||||
- If you see HTTP 403 on Coolify ops, you're trying to touch an app
|
||||
outside this workspace — stop and ask the user.
|
||||
- Re-run \`bootstrap.sh\` instead of hand-editing these files.
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* GET /api/workspaces/[slug]/deployments/[deploymentUuid]/logs
|
||||
*
|
||||
* Raw deployment logs. We can't tell from a deployment UUID alone
|
||||
* which project it belongs to, so we require `?appUuid=...` and
|
||||
* verify that app belongs to the workspace first. This keeps the
|
||||
* tenant boundary intact even though Coolify's log endpoint is
|
||||
* global.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import {
|
||||
getApplicationInProject,
|
||||
getDeploymentLogs,
|
||||
TenantError,
|
||||
} from '@/lib/coolify';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string; deploymentUuid: string }> }
|
||||
) {
|
||||
const { slug, deploymentUuid } = await params;
|
||||
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
|
||||
const ws = principal.workspace;
|
||||
if (!ws.coolify_project_uuid) {
|
||||
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||
}
|
||||
|
||||
const appUuid = new URL(request.url).searchParams.get('appUuid');
|
||||
if (!appUuid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Query param "appUuid" is required for tenant enforcement' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await getApplicationInProject(appUuid, ws.coolify_project_uuid);
|
||||
const logs = await getDeploymentLogs(deploymentUuid);
|
||||
return NextResponse.json(logs);
|
||||
} catch (err) {
|
||||
if (err instanceof TenantError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Coolify request failed', details: String(err) },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
85
app/api/workspaces/[slug]/gitea-credentials/route.ts
Normal file
85
app/api/workspaces/[slug]/gitea-credentials/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* GET /api/workspaces/[slug]/gitea-credentials
|
||||
*
|
||||
* Returns a ready-to-use git clone URL for the workspace's Gitea org,
|
||||
* plus the bot username/token. This is the one endpoint an AI agent
|
||||
* calls before doing any git work — it hides all the admin/org/bot
|
||||
* bookkeeping behind a single bearer-auth request.
|
||||
*
|
||||
* Auth: NextAuth session (owner) OR `Bearer vibn_sk_...` scoped to
|
||||
* this workspace. Never returns credentials for a different workspace.
|
||||
*
|
||||
* The plaintext PAT is decrypted on the server on every call — we
|
||||
* never persist it in logs or client state.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import { getWorkspaceBotCredentials, ensureWorkspaceProvisioned } from '@/lib/workspaces';
|
||||
|
||||
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||
|
||||
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;
|
||||
|
||||
// If the bot has never been provisioned, do it now. Idempotent.
|
||||
let workspace = principal.workspace;
|
||||
if (!workspace.gitea_bot_token_encrypted || !workspace.gitea_org) {
|
||||
try {
|
||||
workspace = await ensureWorkspaceProvisioned(workspace);
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Provisioning failed',
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const creds = getWorkspaceBotCredentials(workspace);
|
||||
if (!creds) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Workspace has no Gitea bot yet',
|
||||
provisionStatus: workspace.provision_status,
|
||||
provisionError: workspace.provision_error,
|
||||
hint:
|
||||
'POST /api/workspaces/' +
|
||||
slug +
|
||||
'/provision to retry bot provisioning.',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const apiBase = GITEA_API_URL.replace(/\/$/, '');
|
||||
const host = new URL(apiBase).host;
|
||||
|
||||
return NextResponse.json({
|
||||
workspace: { slug: workspace.slug, giteaOrg: creds.org },
|
||||
bot: {
|
||||
username: creds.username,
|
||||
// Full plaintext PAT — treat like a password.
|
||||
token: creds.token,
|
||||
},
|
||||
gitea: {
|
||||
apiBase,
|
||||
host,
|
||||
// Templates for the agent. Substitute {{repo}} with the repo name.
|
||||
cloneUrlTemplate: `https://${creds.username}:${creds.token}@${host}/${creds.org}/{{repo}}.git`,
|
||||
sshRemoteTemplate: `git@${host}:${creds.org}/{{repo}}.git`,
|
||||
webUrlTemplate: `${apiBase}/${creds.org}/{{repo}}`,
|
||||
},
|
||||
principal: {
|
||||
source: principal.source,
|
||||
apiKeyId: principal.apiKeyId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export async function GET(
|
||||
coolifyProjectUuid: w.coolify_project_uuid,
|
||||
coolifyTeamId: w.coolify_team_id,
|
||||
giteaOrg: w.gitea_org,
|
||||
giteaBotUsername: w.gitea_bot_username,
|
||||
giteaBotReady: !!(w.gitea_bot_username && w.gitea_bot_token_encrypted),
|
||||
provisionStatus: w.provision_status,
|
||||
provisionError: w.provision_error,
|
||||
createdAt: w.created_at,
|
||||
|
||||
Reference in New Issue
Block a user