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
47 lines
1.7 KiB
TypeScript
47 lines
1.7 KiB
TypeScript
/**
|
|
* Tiny AES-256-GCM wrapper for storing secrets (Gitea bot PATs, etc.)
|
|
* at rest in Postgres. Layout: base64( iv(12) || ciphertext || authTag(16) ).
|
|
*
|
|
* The key comes from VIBN_SECRETS_KEY. It must be base64 (32 bytes) OR
|
|
* any string we hash down to 32 bytes. We hash with SHA-256 so both
|
|
* forms work — rotating just means generating a new env value and
|
|
* re-provisioning workspaces.
|
|
*/
|
|
|
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
|
|
|
|
const IV_BYTES = 12;
|
|
|
|
function getKey(): Buffer {
|
|
const raw = process.env.VIBN_SECRETS_KEY;
|
|
if (!raw || raw.length < 16) {
|
|
throw new Error(
|
|
'VIBN_SECRETS_KEY env var is required (>=16 chars) to encrypt workspace secrets'
|
|
);
|
|
}
|
|
// Normalize any input into a 32-byte key via SHA-256.
|
|
return createHash('sha256').update(raw).digest();
|
|
}
|
|
|
|
export function encryptSecret(plain: string): string {
|
|
const key = getKey();
|
|
const iv = randomBytes(IV_BYTES);
|
|
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
return Buffer.concat([iv, enc, tag]).toString('base64');
|
|
}
|
|
|
|
export function decryptSecret(payload: string): string {
|
|
const buf = Buffer.from(payload, 'base64');
|
|
if (buf.length < IV_BYTES + 16) throw new Error('secret-box: payload too short');
|
|
const iv = buf.subarray(0, IV_BYTES);
|
|
const tag = buf.subarray(buf.length - 16);
|
|
const ciphertext = buf.subarray(IV_BYTES, buf.length - 16);
|
|
const key = getKey();
|
|
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
decipher.setAuthTag(tag);
|
|
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
return dec.toString('utf8');
|
|
}
|