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:
2026-04-23 11:46:50 -07:00
parent 651ddf1e11
commit 3192e0f7b9
14 changed files with 1794 additions and 37 deletions

View File

@@ -33,7 +33,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { Copy, Download, KeyRound, Loader2, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Copy, Download, Eye, EyeOff, KeyRound, Loader2, Plus, RefreshCw, Trash2 } from "lucide-react";
interface WorkspaceSummary {
id: string;
@@ -63,6 +63,12 @@ interface ApiKey {
last_used_at: string | null;
revoked_at: string | null;
created_at: string;
/**
* True if the server still has the encrypted plaintext and can reveal
* it again on demand. False for legacy keys minted before the
* key_encrypted column was added — those can only be rotated.
*/
revealable: boolean;
}
interface MintedKey {
@@ -255,8 +261,8 @@ export function WorkspaceKeysPanel({ workspaceSlug: _urlHint }: { workspaceSlug?
<DialogTitle>Create workspace API key</DialogTitle>
<DialogDescription>
Used by AI agents (Cursor, Claude, scripts) to act on
behalf of <code>{workspace.slug}</code>. The token is shown
once save it somewhere safe.
behalf of <code>{workspace.slug}</code>. You&apos;ll be able
to reveal and copy the token again later from this page.
</DialogDescription>
</DialogHeader>
<div style={{ display: "grid", gap: 8 }}>
@@ -288,15 +294,15 @@ export function WorkspaceKeysPanel({ workspaceSlug: _urlHint }: { workspaceSlug?
<Dialog open={!!minted} onOpenChange={open => !open && setMinted(null)}>
<DialogContent style={{ maxWidth: 640 }}>
<DialogHeader>
<DialogTitle>Save your API key</DialogTitle>
<DialogTitle>Your new API key</DialogTitle>
<DialogDescription>
This is the only time the full key is shown. Store it in a
password manager or paste it into the Cursor config below.
Copy this into your AI tool now, or come back to this page
later and click <em>Show key</em> to reveal it again.
</DialogDescription>
</DialogHeader>
{minted && <MintedKeyView workspace={workspace} minted={minted} />}
<DialogFooter>
<Button onClick={() => setMinted(null)}>I&apos;ve saved it</Button>
<Button onClick={() => setMinted(null)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -435,7 +441,8 @@ function KeysCard({
<h2 style={cardTitleStyle}>API keys</h2>
<p style={cardSubtitleStyle}>
Tokens scoped to <code>{workspace.slug}</code>. Use them in Cursor,
Claude Code, the CLI, or any HTTP client.
Claude Code, the CLI, or any HTTP client. Click <em>Show key</em> on
any row to reveal the full token.
</p>
</div>
<div style={{ display: "flex", gap: 8 }}>
@@ -454,7 +461,12 @@ function KeysCard({
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
{active.map(k => (
<KeyRow key={k.id} k={k} onRevoke={() => onRevokeClick(k)} />
<KeyRow
key={k.id}
k={k}
workspaceSlug={workspace.slug}
onRevoke={() => onRevokeClick(k)}
/>
))}
</ul>
)}
@@ -466,7 +478,7 @@ function KeysCard({
</summary>
<ul style={{ listStyle: "none", margin: "8px 0 0", padding: 0, display: "flex", flexDirection: "column", gap: 6, opacity: 0.6 }}>
{revoked.map(k => (
<KeyRow key={k.id} k={k} />
<KeyRow key={k.id} k={k} workspaceSlug={workspace.slug} />
))}
</ul>
</details>
@@ -475,33 +487,150 @@ function KeysCard({
);
}
function KeyRow({ k, onRevoke }: { k: ApiKey; onRevoke?: () => void }) {
function KeyRow({
k,
workspaceSlug,
onRevoke,
}: {
k: ApiKey;
workspaceSlug: string;
onRevoke?: () => void;
}) {
const [token, setToken] = useState<string | null>(null);
const [revealing, setRevealing] = useState(false);
const [visible, setVisible] = useState(false);
const isActive = !k.revoked_at;
const reveal = useCallback(async () => {
setRevealing(true);
try {
const res = await fetch(
`/api/workspaces/${workspaceSlug}/keys/${k.id}/reveal`,
{ method: "POST", credentials: "include" },
);
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body?.error ?? `HTTP ${res.status}`);
}
setToken(body.token as string);
setVisible(true);
} catch (err) {
toast.error(
`Couldn't reveal key: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
setRevealing(false);
}
}, [k.id, workspaceSlug]);
const copy = useCallback(() => {
if (!token) return;
navigator.clipboard.writeText(token).then(
() => toast.success("Key copied"),
() => toast.error("Copy failed"),
);
}, [token]);
const masked = token
? `${token.slice(0, 12)}${"•".repeat(24)}${token.slice(-4)}`
: null;
return (
<li
style={{
display: "flex",
alignItems: "center",
gap: 12,
flexDirection: "column",
gap: 8,
padding: "10px 12px",
background: "#fff",
border: "1px solid var(--border, #e5e7eb)",
borderRadius: 8,
}}
>
<KeyRound size={16} style={{ color: "var(--muted)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)" }}>{k.name}</div>
<div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "monospace" }}>
{k.prefix}
{k.last_used_at
? ` · last used ${new Date(k.last_used_at).toLocaleString()}`
: " · never used"}
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<KeyRound size={16} style={{ color: "var(--muted)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)" }}>
{k.name}
{!k.revealable && isActive && (
<span
style={{
marginLeft: 8,
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--muted)",
border: "1px solid var(--border, #e5e7eb)",
padding: "1px 6px",
borderRadius: 4,
}}
title="Minted before reveal was enabled — rotate to get a revealable key"
>
legacy
</span>
)}
</div>
<div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "monospace" }}>
{k.prefix}
{k.last_used_at
? ` · last used ${new Date(k.last_used_at).toLocaleString()}`
: " · never used"}
</div>
</div>
{isActive && k.revealable && !token && (
<Button
variant="outline"
size="sm"
onClick={reveal}
disabled={revealing}
aria-label="Show key"
>
{revealing ? <Loader2 className="animate-spin" size={14} /> : <Eye size={14} />}
Show key
</Button>
)}
{isActive && token && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setVisible(v => !v)}
aria-label={visible ? "Hide key" : "Show key"}
>
{visible ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
<Button variant="ghost" size="sm" onClick={copy} aria-label="Copy key">
<Copy size={14} />
</Button>
</>
)}
{onRevoke && isActive && (
<Button variant="ghost" size="sm" onClick={onRevoke} aria-label="Revoke">
<Trash2 size={14} />
</Button>
)}
</div>
{onRevoke && (
<Button variant="ghost" size="sm" onClick={onRevoke} aria-label="Revoke">
<Trash2 size={14} />
</Button>
{token && (
<code
style={{
display: "block",
padding: "8px 10px",
background: "#0f172a",
color: "#e2e8f0",
borderRadius: 6,
fontFamily: "monospace",
fontSize: 12,
wordBreak: "break-all",
userSelect: "all",
}}
>
{visible ? token : masked}
</code>
)}
</li>
);
@@ -842,7 +971,7 @@ curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" ${APP_BASE}/api/workspaces/$
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<FileBlock
title="Your key"
description="Copy this now — the full value is never shown again."
description="Copy now, or reveal it later from the API keys list above."
filename={`${workspace.slug}-${minted.name.replace(/\s+/g, "-")}.txt`}
contents={minted.token}
language="text"