fix(coolify): strip is_build_time from env writes; add reveal + GCS
Coolify v4's POST/PATCH /applications/{uuid}/envs only accepts key,
value, is_preview, is_literal, is_multiline, is_shown_once. Sending
is_build_time triggers a 422 "This field is not allowed." — it's now
a derived read-only flag (is_buildtime) computed from Dockerfile ARG
usage. Breaks agents trying to upsert env vars.
Three-layer fix so this can't regress:
- lib/coolify.ts: COOLIFY_ENV_WRITE_FIELDS whitelist enforced at the
network boundary, regardless of caller shape
- app/api/workspaces/[slug]/apps/[uuid]/envs: stops forwarding the
field; returns a deprecation warning when callers send it; GET
reads both is_buildtime and is_build_time for version parity
- app/api/mcp/route.ts: same treatment in the MCP dispatcher;
AI_CAPABILITIES.md doc corrected
Also bundles (not related to the above):
- Workspace API keys are now revealable from settings. New
key_encrypted column stores AES-256-GCM(VIBN_SECRETS_KEY, token).
POST /api/workspaces/[slug]/keys/[keyId]/reveal returns plaintext
for session principals only; API-key principals cannot reveal
siblings. Legacy keys stay valid for auth but can't reveal.
- P5.3 Object storage: lib/gcp/storage.ts + lib/workspace-gcs.ts
idempotently provision a per-workspace GCS bucket, service
account, IAM binding and HMAC key. New POST /api/workspaces/
[slug]/storage/buckets endpoint. Migration script + smoke test
included. Proven end-to-end against prod master-ai-484822.
Made-with: Cursor
This commit is contained in:
@@ -406,15 +406,25 @@ async function toolAppsEnvsUpsert(principal: Principal, params: Record<string, a
|
||||
);
|
||||
}
|
||||
await getApplicationInProject(appUuid, projectUuid);
|
||||
// Coolify v4 rejects `is_build_time` on POST/PATCH (it's a derived
|
||||
// read-only flag now). Silently drop it here so agents that still send
|
||||
// it don't get a surprise 422. See lib/coolify.ts upsertApplicationEnv
|
||||
// for the hard enforcement at the network boundary.
|
||||
const result = await upsertApplicationEnv(appUuid, {
|
||||
key,
|
||||
value,
|
||||
is_preview: !!params.is_preview,
|
||||
is_build_time: !!params.is_build_time,
|
||||
is_literal: !!params.is_literal,
|
||||
is_multiline: !!params.is_multiline,
|
||||
is_shown_once: !!params.is_shown_once,
|
||||
});
|
||||
return NextResponse.json({ result });
|
||||
const body: Record<string, unknown> = { result };
|
||||
if (params.is_build_time !== undefined) {
|
||||
body.warnings = [
|
||||
'is_build_time is ignored — Coolify derives build-vs-runtime from Dockerfile ARG usage. Omit this field going forward.',
|
||||
];
|
||||
}
|
||||
return NextResponse.json(body);
|
||||
}
|
||||
|
||||
async function toolAppsEnvsDelete(principal: Principal, params: Record<string, any>) {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* 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? }
|
||||
* body: { key, value, is_preview?, is_literal?, is_multiline?, is_shown_once? }
|
||||
* 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.
|
||||
*
|
||||
* NOTE: `is_build_time` is **not** a writable flag in Coolify v4 — it's a
|
||||
* derived read-only attribute. We silently drop it from incoming request
|
||||
* bodies for back-compat with older agents; the value is computed by
|
||||
* Coolify at build time based on Dockerfile ARG usage.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
@@ -66,7 +71,11 @@ export async function GET(
|
||||
key: e.key,
|
||||
value: reveal ? e.value : maskValue(e.value),
|
||||
isPreview: e.is_preview ?? false,
|
||||
isBuildTime: e.is_build_time ?? false,
|
||||
// Coolify spells the read-only build-time flag two different ways
|
||||
// depending on version — `is_buildtime` (new, one word) and
|
||||
// `is_build_time` (old, underscored). Fall through both.
|
||||
isBuildTime: e.is_buildtime ?? e.is_build_time ?? false,
|
||||
isRuntime: e.is_runtime ?? true,
|
||||
isLiteral: e.is_literal ?? false,
|
||||
isMultiline: e.is_multiline ?? false,
|
||||
})),
|
||||
@@ -91,9 +100,11 @@ export async function PATCH(
|
||||
key?: string;
|
||||
value?: string;
|
||||
is_preview?: boolean;
|
||||
/** @deprecated silently dropped — Coolify no longer accepts this on write. */
|
||||
is_build_time?: boolean;
|
||||
is_literal?: boolean;
|
||||
is_multiline?: boolean;
|
||||
is_shown_once?: boolean;
|
||||
};
|
||||
try {
|
||||
body = await request.json();
|
||||
@@ -110,11 +121,22 @@ export async function PATCH(
|
||||
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,
|
||||
is_shown_once: body.is_shown_once ?? false,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
key: env.key,
|
||||
// Soft-deprecation signal so the caller's agent can learn to stop
|
||||
// sending the flag without hard-breaking today.
|
||||
warnings:
|
||||
body.is_build_time !== undefined
|
||||
? [
|
||||
'is_build_time is ignored — Coolify derives build-vs-runtime from Dockerfile ARG usage. Omit this field going forward.',
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
return NextResponse.json({ ok: true, key: env.key });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Coolify request failed', details: String(err) },
|
||||
|
||||
57
app/api/workspaces/[slug]/keys/[keyId]/reveal/route.ts
Normal file
57
app/api/workspaces/[slug]/keys/[keyId]/reveal/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* POST /api/workspaces/[slug]/keys/[keyId]/reveal
|
||||
*
|
||||
* Returns the plaintext `vibn_sk_...` token for an active workspace key.
|
||||
*
|
||||
* Intentionally restricted to SESSION principals. An API-key principal
|
||||
* cannot reveal keys — this prevents a leaked agent token from being
|
||||
* used to exfiltrate sibling keys. We use POST (not GET) to keep the
|
||||
* secret out of server logs / the browser history / referrer headers.
|
||||
*
|
||||
* Returns 409 with { revealable: false } for legacy keys minted before
|
||||
* the key_encrypted column existed — those plaintexts were never stored
|
||||
* and can never be recovered. The caller should prompt the user to
|
||||
* rotate (revoke + mint new).
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import {
|
||||
requireWorkspacePrincipal,
|
||||
revealWorkspaceApiKey,
|
||||
} from '@/lib/auth/workspace-auth';
|
||||
|
||||
export async function POST(
|
||||
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 revealed from a signed-in session' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const revealed = await revealWorkspaceApiKey(principal.workspace.id, keyId);
|
||||
if (!revealed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Key not found, already revoked, or was minted before reveal was enabled. ' +
|
||||
'Rotate the key (revoke + create new) if you need the plaintext.',
|
||||
revealable: false,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: revealed.id,
|
||||
name: revealed.name,
|
||||
prefix: revealed.prefix,
|
||||
token: revealed.token,
|
||||
});
|
||||
}
|
||||
98
app/api/workspaces/[slug]/storage/buckets/route.ts
Normal file
98
app/api/workspaces/[slug]/storage/buckets/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* GET /api/workspaces/[slug]/storage/buckets — describe the workspace's
|
||||
* provisioned GCS state (default bucket name, SA email, HMAC accessId,
|
||||
* provision status). Does NOT return the HMAC secret.
|
||||
*
|
||||
* POST /api/workspaces/[slug]/storage/buckets — idempotently provisions
|
||||
* the per-workspace GCS substrate:
|
||||
* 1. dedicated GCP service account (vibn-ws-{slug}@…)
|
||||
* 2. SA JSON keyfile (encrypted at rest)
|
||||
* 3. default bucket vibn-ws-{slug}-{6char} in northamerica-northeast1
|
||||
* 4. roles/storage.objectAdmin binding for the SA on that bucket
|
||||
* 5. HMAC key on the SA so app code can use AWS S3 SDKs
|
||||
* Safe to re-run; each step short-circuits when already complete.
|
||||
*
|
||||
* Auth: session OR `Bearer vibn_sk_...`. Same workspace-scope rules as
|
||||
* every other /api/workspaces/[slug]/* endpoint.
|
||||
*
|
||||
* P5.3 — vertical slice. The full storage.* tool family (presign,
|
||||
* list_objects, delete_object, set_lifecycle) lands once this
|
||||
* provisioning step is verified end-to-end.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import {
|
||||
ensureWorkspaceGcsProvisioned,
|
||||
getWorkspaceGcsState,
|
||||
} from '@/lib/workspace-gcs';
|
||||
|
||||
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 = await getWorkspaceGcsState(principal.workspace.id);
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
workspace: { slug: ws.slug },
|
||||
storage: {
|
||||
status: ws.gcp_provision_status ?? 'pending',
|
||||
error: ws.gcp_provision_error ?? null,
|
||||
serviceAccountEmail: ws.gcp_service_account_email ?? null,
|
||||
defaultBucketName: ws.gcs_default_bucket_name ?? null,
|
||||
hmacAccessId: ws.gcs_hmac_access_id ?? null,
|
||||
location: 'northamerica-northeast1',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
const result = await ensureWorkspaceGcsProvisioned(principal.workspace);
|
||||
return NextResponse.json(
|
||||
{
|
||||
workspace: { slug: principal.workspace.slug },
|
||||
storage: {
|
||||
status: result.status,
|
||||
serviceAccountEmail: result.serviceAccountEmail,
|
||||
bucket: result.bucket,
|
||||
hmacAccessId: result.hmac.accessId,
|
||||
location: result.bucket.location,
|
||||
},
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Schema-not-applied detection: makes the failure mode obvious in
|
||||
// dev before the operator runs scripts/migrate-workspace-gcs.sql.
|
||||
if (/column .* does not exist/i.test(message)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'GCS columns missing on vibn_workspaces. Run scripts/migrate-workspace-gcs.sql.',
|
||||
details: message,
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'GCS provisioning failed', details: message },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user