feat(mcp): expose storage.{describe,provision,inject_env} tools

The per-workspace GCS backend (bucket, service account, HMAC keys) was
already provisioned for P5.3 but wasn't reachable through MCP, so
agents using vibn_sk_* tokens couldn't actually use object storage.

Three new tools:
- storage.describe    → bucket, region, endpoint, access_key_id.
                        No secret in response.
- storage.provision   → idempotent ensureWorkspaceGcsProvisioned().
- storage.inject_env  → writes STORAGE_* (or user-chosen prefix) env
                        vars into a Coolify app. SECRET_ACCESS_KEY is
                        tagged is_shown_once so Coolify masks it in
                        the UI, and it never leaves our backend — the
                        agent kicks off injection, but the HMAC secret
                        is read from our DB and pushed directly to
                        Coolify.

Apps can then hit the bucket with any S3 SDK (aws-sdk, boto3, etc.)
using force_path_style=true and the standard endpoint.

Made-with: Cursor
This commit is contained in:
2026-04-23 12:48:23 -07:00
parent fcd5d03894
commit 9959eaeeaa

View File

@@ -20,6 +20,12 @@
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { getWorkspaceBotCredentials, ensureWorkspaceProvisioned } from '@/lib/workspaces';
import {
ensureWorkspaceGcsProvisioned,
getWorkspaceGcsState,
getWorkspaceGcsHmacCredentials,
} from '@/lib/workspace-gcs';
import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage';
import {
deployApplication,
getApplicationInProject,
@@ -107,6 +113,9 @@ export async function GET() {
'domains.get',
'domains.register',
'domains.attach',
'storage.describe',
'storage.provision',
'storage.inject_env',
],
},
},
@@ -210,6 +219,13 @@ export async function POST(request: Request) {
case 'domains.attach':
return await toolDomainsAttach(principal, params);
case 'storage.describe':
return await toolStorageDescribe(principal);
case 'storage.provision':
return await toolStorageProvision(principal);
case 'storage.inject_env':
return await toolStorageInjectEnv(principal, params);
default:
return NextResponse.json(
{ error: `Unknown tool "${action}"` },
@@ -1139,3 +1155,133 @@ async function toolDomainsAttach(principal: Principal, params: Record<string, an
);
}
}
// ──────────────────────────────────────────────────
// Phase 5.3: Object storage (GCS via S3-compatible HMAC)
// ──────────────────────────────────────────────────
/**
* Shape of the S3-compatible credentials we expose to agents.
*
* The HMAC *secret* is never returned here — only the access id and
* the bucket/region/endpoint. Use `storage.inject_env` to push the
* full `{accessId, secret}` pair into a Coolify app's env vars
* server-side, where the secret never leaves our network.
*/
function describeWorkspaceStorage(ws: {
slug: string;
gcs_default_bucket_name: string | null;
gcs_hmac_access_id: string | null;
gcp_service_account_email: string | null;
gcp_provision_status: string | null;
}) {
return {
status: ws.gcp_provision_status ?? 'pending',
bucket: ws.gcs_default_bucket_name,
region: VIBN_GCS_LOCATION,
endpoint: 'https://storage.googleapis.com',
accessKeyId: ws.gcs_hmac_access_id,
serviceAccountEmail: ws.gcp_service_account_email,
note:
'S3-compatible credentials. Use AWS SDKs with forcePathStyle=true and this endpoint. ' +
'The secret access key is not returned here; call storage.inject_env to push it into a Coolify app.',
};
}
async function toolStorageDescribe(principal: Principal) {
const ws = await getWorkspaceGcsState(principal.workspace.id);
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
}
return NextResponse.json({ result: describeWorkspaceStorage(ws) });
}
async function toolStorageProvision(principal: Principal) {
const result = await ensureWorkspaceGcsProvisioned(principal.workspace);
return NextResponse.json({ result });
}
/**
* Inject the workspace's storage credentials into a Coolify app as
* env vars, so the app can reach the bucket with any S3 SDK. The
* HMAC secret is read server-side and written directly to Coolify —
* it never transits through the agent or our API response.
*
* Envs written (all tagged is_shown_once so Coolify hides the secret
* in the UI after first render):
* STORAGE_ENDPOINT = https://storage.googleapis.com
* STORAGE_REGION = northamerica-northeast1
* STORAGE_BUCKET = vibn-ws-{slug}-{rand}
* STORAGE_ACCESS_KEY_ID = GOOG1E... (HMAC access id)
* STORAGE_SECRET_ACCESS_KEY = ... (HMAC secret — shown-once)
* STORAGE_FORCE_PATH_STYLE = "true" (S3 SDKs need this for GCS)
*
* Agents can override the env var prefix via `params.prefix`
* (e.g. "S3_" for apps that expect AWS-style names).
*/
async function toolStorageInjectEnv(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
await getApplicationInProject(appUuid, projectUuid);
const prefix = String(params.prefix ?? 'STORAGE_');
if (!/^[A-Z][A-Z0-9_]*$/.test(prefix)) {
return NextResponse.json(
{ error: 'Param "prefix" must be uppercase ASCII (letters, digits, underscores)' },
{ status: 400 },
);
}
const ws = await getWorkspaceGcsState(principal.workspace.id);
if (!ws) return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
if (ws.gcp_provision_status !== 'ready' || !ws.gcs_default_bucket_name) {
return NextResponse.json(
{
error: `Workspace storage not ready (status=${ws.gcp_provision_status}). Call storage.provision first.`,
},
{ status: 409 },
);
}
const creds = getWorkspaceGcsHmacCredentials(ws);
if (!creds) {
return NextResponse.json(
{ error: 'Storage HMAC secret unavailable (pre-rotation key, or decrypt failed). Rotate and retry.' },
{ status: 409 },
);
}
const entries: Array<{ key: string; value: string; shownOnce?: boolean }> = [
{ key: `${prefix}ENDPOINT`, value: 'https://storage.googleapis.com' },
{ key: `${prefix}REGION`, value: VIBN_GCS_LOCATION },
{ key: `${prefix}BUCKET`, value: ws.gcs_default_bucket_name },
{ key: `${prefix}ACCESS_KEY_ID`, value: creds.accessId },
{ key: `${prefix}SECRET_ACCESS_KEY`, value: creds.secret, shownOnce: true },
{ key: `${prefix}FORCE_PATH_STYLE`, value: 'true' },
];
const written: string[] = [];
const failed: Array<{ key: string; error: string }> = [];
for (const e of entries) {
try {
await upsertApplicationEnv(appUuid, {
key: e.key,
value: e.value,
is_shown_once: e.shownOnce ?? false,
});
written.push(e.key);
} catch (err) {
failed.push({ key: e.key, error: err instanceof Error ? err.message : String(err) });
}
}
return NextResponse.json({
result: {
uuid: appUuid,
prefix,
written,
failed: failed.length ? failed : undefined,
bucket: ws.gcs_default_bucket_name,
},
});
}