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:
@@ -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'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'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"
|
||||
|
||||
Reference in New Issue
Block a user