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
177 lines
5.7 KiB
TypeScript
177 lines
5.7 KiB
TypeScript
/**
|
|
* 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_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';
|
|
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,
|
|
// 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,
|
|
})),
|
|
});
|
|
} 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;
|
|
/** @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();
|
|
} 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_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,
|
|
});
|
|
} 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)}`;
|
|
}
|