feat(canvas+org): env preflight, EmptyState parity, shared useTemplateDeploy hook

Builds on #2061. Three internally-cohesive sub-features; easiest to
read in order.

## 1. Org-level env preflight

Server
- `OrgTemplate` + `OrgWorkspace` gain `required_env: string[]` and
  `recommended_env: string[]` YAML fields.
- `GET /org/templates` walks the tree and returns the tree-union
  (deduped, sorted) of both. `collectOrgEnv` dedup prefers required
  when the same key is declared at both tiers.
- `POST /org/import` preflights against `global_secrets` WHERE
  `octet_length(encrypted_value) > 0` (empty-value rows used to be
  counted as "configured" and the per-container preflight still
  failed at start time). 412 Precondition Failed + `missing_env`
  list when required keys are absent. `force=true` bypasses with
  an audit log line. DB lookup failure now returns 500 (was:
  silent fall-through that defeated the guard). Env-var NAMES
  validated against `^[A-Z][A-Z0-9_]{0,127}$` so a malicious
  template can't ship pathological names into the UI or DB.

Canvas
- New `OrgImportPreflightModal`: red "Required" section (blocking)
  and yellow "Recommended" section (non-blocking, import stays
  enabled, shows live missing-count next to the Import button).
- Per-key password input → `PUT /settings/secrets` → strike-through
  on save. Functional `setDrafts` throughout (no stale-closure
  clobbers on rapid successive saves). `useEffect` seed keyed on a
  sorted-join string signature so a parent re-render with a new
  array identity doesn't clobber typed inputs.
- `TemplatePalette.handleImport` branches: zero env declarations →
  straight to import; any declarations → fetch configured global
  secret keys, open the modal.

Tests (Go): `TestCollectOrgEnv_*` (5) cover union-across-levels,
required-wins-over-recommended (including same-struct), dedup,
empty, invalid-name rejection.

## 2. EmptyState parity with TemplatePalette

The "Deploy your first agent" grid used to call `POST /workspaces`
with no preflight while the sidebar palette ran
`checkDeploySecrets` + `MissingKeysModal` first. Same template
deployed two different ways → first-run users saw containers boot
in `failed` state without guidance. Now both surfaces share one
preflight + modal handshake.

EmptyState's previous `interface Template` dropped `runtime`,
`models`, and `required_env` — silently discarding exactly the
fields the preflight needs. `Template` now lives in
`deploy-preflight.ts` and is imported from there by both surfaces.

## 3. useTemplateDeploy hook

With the preflight + modal wiring now duplicated across
EmptyState + TemplatePalette + (going forward) any third surface,
extracted the pattern into `canvas/src/hooks/useTemplateDeploy.tsx`:

  const { deploy, deploying, error, modal } = useTemplateDeploy({
    canvasCoords: ...,   // optional, default random
    onDeployed: (id) => ...,
  });

Closes three drift surfaces that the duplication had created:
- `resolveRuntime` id→runtime fallback table (moved to
  `deploy-preflight.ts`). EmptyState had a narrower fallback that
  would have silently disagreed with the palette on any future id
  needing a non-identity mapping.
- `checkDeploySecrets` call signature. One owner.
- `MissingKeysModal` JSX wiring. One owner.

Narrow try/catch around `checkDeploySecrets` so a preflight network
failure clears `deploying` and surfaces via `setError` instead of
stranding the button forever. `modal: ReactNode` (not a
`renderModal()` function) — the previous memoization bought
nothing since consumers called it inline every render. Named
`MissingKeysInfo` interface for the state shape.

## 4. Viewport auto-fit user-pan gate fix

During org deploy the canvas was meant to pan+zoom to follow each
arriving workspace (`molecule:fit-deploying-org` event → debounced
fitView). In practice the fit stayed stuck on wherever the first
fit landed.

Root cause: React Flow v12 fires `onMoveEnd` with a truthy `event`
at the END of a programmatic `fitView` animation. The original
"respect-user-pan" gate stamped `userPannedAtRef` in `onMoveEnd`,
so our own fit completing looked like a user pan, and every
subsequent auto-fit short-circuited for the rest of the deploy.

Fix: stop trusting `onMoveEnd` for user-intent detection. Register
explicit `wheel` + `pointerdown` listeners on `document` with
capture phase and `target.closest('.react-flow__pane')` filter.
Capture-phase immunity to `stopPropagation`; pane-filter rejects
toolbar / modal / side-panel clicks (the old `window` fallback
caught those). `onMoveEnd` simplified to only drive the debounced
viewport save.

Also: fit event dispatched on root arrivals (not just children),
so the canvas centers on the just-landed root immediately instead
of waiting ~2s for the first child. Animation 600ms → 400ms so
successive per-arrival fits don't pile up visually. End-state fit
stays at 1200ms — intentional asymmetry ("settling" vs
"tracking"), documented in code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-24 15:14:59 -07:00
parent a34121d451
commit 5adc8a74d5
10 changed files with 1113 additions and 176 deletions

View File

@ -1,27 +1,19 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { OrgTemplatesSection } from "./TemplatePalette";
import { type Template } from "@/lib/deploy-preflight";
import { useTemplateDeploy } from "@/hooks/useTemplateDeploy";
import { Spinner } from "./Spinner";
import { TIER_CONFIG } from "@/lib/design-tokens";
interface Template {
id: string;
name: string;
description: string;
tier: number;
model: string;
skills: string[];
skill_count: number;
}
export function EmptyState() {
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(true);
const [deploying, setDeploying] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [blankCreating, setBlankCreating] = useState(false);
const [blankError, setBlankError] = useState<string | null>(null);
useEffect(() => {
api
@ -31,48 +23,56 @@ export function EmptyState() {
.finally(() => setLoading(false));
}, []);
const deploy = async (template: Template) => {
setDeploying(template.id);
setError(null);
try {
const ws = await api.post<{ id: string }>("/workspaces", {
name: template.name,
template: template.id,
tier: template.tier,
canvas: { x: 200, y: 150 },
});
// Auto-select the new workspace and open chat
setTimeout(() => {
useCanvasStore.getState().selectNode(ws.id);
useCanvasStore.getState().setPanelTab("chat");
}, 500);
} catch (e) {
setError(e instanceof Error ? e.message : "Deploy failed");
} finally {
setDeploying(null);
}
};
// Canvas fills in a visible "center-ish" spot on a fresh tenant so
// the user doesn't have to pan to find their new workspace. Fixed
// (200, 150) instead of the sidebar's random placement because the
// canvas is guaranteed empty when this component mounts.
const firstDeployCoords = useCallback(() => ({ x: 200, y: 150 }), []);
// After the POST succeeds, auto-select the new workspace and flip
// the panel to Chat. This is a UX flourish that only makes sense
// on first deploy (the canvas is empty so the selection can't
// surprise anyone); the sidebar intentionally skips this step.
// 500 ms delay so React Flow has a frame to render the new node
// before it receives focus.
const handleDeployed = useCallback((workspaceId: string) => {
setTimeout(() => {
useCanvasStore.getState().selectNode(workspaceId);
useCanvasStore.getState().setPanelTab("chat");
}, 500);
}, []);
const { deploy, deploying, error, modal } = useTemplateDeploy({
canvasCoords: firstDeployCoords,
onDeployed: handleDeployed,
});
// "Create blank" bypasses templates entirely — no preflight, no
// modal, just POST /workspaces with a default name and tier.
// Deliberately NOT routed through useTemplateDeploy because it
// has no `template.id` to deploy against.
const createBlank = async () => {
setDeploying("blank");
setError(null);
setBlankCreating(true);
setBlankError(null);
try {
const ws = await api.post<{ id: string }>("/workspaces", {
name: "My First Agent",
tier: 2,
canvas: { x: 200, y: 150 },
canvas: firstDeployCoords(),
});
setTimeout(() => {
useCanvasStore.getState().selectNode(ws.id);
useCanvasStore.getState().setPanelTab("chat");
}, 500);
handleDeployed(ws.id);
} catch (e) {
setError(e instanceof Error ? e.message : "Create failed");
setBlankError(e instanceof Error ? e.message : "Create failed");
} finally {
setDeploying(null);
setBlankCreating(false);
}
};
// Any active gesture locks every button so the user can't fire a
// second POST while the first is still in flight.
const anyDeploying = !!deploying || blankCreating;
const displayError = error ?? blankError;
return (
<div className="absolute inset-0 flex items-start justify-center pointer-events-none z-[1] overflow-y-auto py-8">
<div className="relative max-w-2xl w-full rounded-3xl border border-zinc-800/70 bg-zinc-950/80 backdrop-blur-xl px-8 py-8 text-center shadow-2xl shadow-black/40 pointer-events-auto mx-4">
@ -112,8 +112,8 @@ export function EmptyState() {
<button
type="button"
key={t.id}
onClick={() => deploy(t)}
disabled={!!deploying}
onClick={() => void deploy(t)}
disabled={anyDeploying}
className="group rounded-xl border border-zinc-800/60 bg-zinc-900/50 px-3.5 py-3 hover:border-blue-500/40 hover:bg-zinc-900/80 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-800/60 disabled:hover:bg-zinc-900/50 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
>
<div className="flex items-center gap-2 mb-1">
@ -143,10 +143,10 @@ export function EmptyState() {
<button
type="button"
onClick={createBlank}
disabled={!!deploying}
disabled={anyDeploying}
className="w-full rounded-xl border border-dashed border-zinc-700/60 bg-zinc-900/30 px-4 py-3 text-sm text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 hover:bg-zinc-900/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-zinc-400 disabled:hover:border-zinc-700/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
>
{deploying === "blank" ? "Creating..." : "+ Create blank workspace"}
{blankCreating ? "Creating..." : "+ Create blank workspace"}
</button>
{/* Org templates — instantiate a whole team in one click */}
@ -154,12 +154,17 @@ export function EmptyState() {
<OrgTemplatesSection />
</div>
{error && (
{displayError && (
<div role="alert" className="mt-3 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
{error}
{displayError}
</div>
)}
{/* Missing-keys preflight modal owned by useTemplateDeploy,
shared with TemplatePalette. Rendered inline here so it
overlays this card naturally. */}
{modal}
{/* Tips */}
<div className="mt-5 pt-4 border-t border-zinc-800/50">
<div className="flex items-center justify-center gap-6 text-[10px] text-zinc-400">

View File

@ -0,0 +1,329 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createSecret } from "@/lib/api/secrets";
interface Props {
open: boolean;
/** Display name of the org template — headline only. */
orgName: string;
/** Total workspace count so the header can read "12 workspaces". */
workspaceCount: number;
/** Env vars the server has declared MUST be set as global secrets.
* Import is disabled until every entry here is configured. */
requiredEnv: string[];
/** Env vars the server suggests import can proceed without them,
* but the user sees them listed so they can decide. */
recommendedEnv: string[];
/** Names of env vars already configured globally. Used to strike
* through entries the user has already set up in another
* session. Passed in rather than queried inside the modal so the
* parent can refresh after each save without prop-driven effects. */
configuredKeys: Set<string>;
/** Called after a successful secret save so the parent can refresh
* `configuredKeys`. */
onSecretSaved: () => void;
/** User clicked Import with all required envs satisfied. */
onProceed: () => void;
/** User dismissed the modal. Import is NOT fired. */
onCancel: () => void;
}
interface DraftEntry {
key: string;
value: string;
saving: boolean;
error: string | null;
}
/**
* OrgImportPreflightModal
* -----------------------
* Two-tier env preflight before POST /org/import:
*
* - REQUIRED section (red, blocking) every entry MUST be configured
* globally before the Import button enables. Matches the server-
* side preflight that would 412 the import anyway.
*
* - RECOMMENDED section (yellow, non-blocking) listed so the user
* can add them if they want the full experience, but the Import
* button stays enabled regardless.
*
* Saving goes to the GLOBAL secrets endpoint (PUT /settings/secrets)
* because org-level templates deploy shared resources. Per-workspace
* overrides still work via the Config tab on an individual node
* after import. The modal does NOT enable Import the moment a key is
* typed only after it saves successfully (so a half-entered token
* can't proceed and then fail at container-start time instead).
*/
export function OrgImportPreflightModal({
open,
orgName,
workspaceCount,
requiredEnv,
recommendedEnv,
configuredKeys,
onSecretSaved,
onProceed,
onCancel,
}: Props) {
const [drafts, setDrafts] = useState<Record<string, DraftEntry>>({});
// Seed a draft entry per declared key the first time the modal
// opens. Entries persist across `configuredKeys` changes so a mid-
// save recheck doesn't wipe what the user typed.
//
// Dep: dervie a STABLE string from the env-name lists rather than
// the array refs themselves. The parent computes
// `preflight.org.required_env ?? []`, which produces a fresh []
// identity on every re-render (e.g. when refreshConfiguredKeys
// bumps state); depending on the array refs would re-fire the
// effect on every parent render and mask any future edit that
// drops the `if (!next[k])` guard as a silent input-reset bug.
const envKeysSignature = useMemo(
() => [...requiredEnv, ...recommendedEnv].sort().join("|"),
[requiredEnv, recommendedEnv],
);
useEffect(() => {
if (!open) return;
setDrafts((prev) => {
const next = { ...prev };
for (const k of [...requiredEnv, ...recommendedEnv]) {
if (!next[k]) {
next[k] = { key: k, value: "", saving: false, error: null };
}
}
return next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, envKeysSignature]);
const missingRequired = useMemo(
() => requiredEnv.filter((k) => !configuredKeys.has(k)),
[requiredEnv, configuredKeys],
);
const missingRecommended = useMemo(
() => recommendedEnv.filter((k) => !configuredKeys.has(k)),
[recommendedEnv, configuredKeys],
);
const canProceed = missingRequired.length === 0;
const saveOne = useCallback(
async (key: string) => {
// Functional setter throughout so two near-simultaneous saves
// don't have the second one's call see a stale snapshot captured
// before the first save's setState landed. Read the current
// value AND write the `saving` flag in a single transition
// rather than reading from closure-scoped `drafts`.
let startValue = "";
setDrafts((d) => {
const current = d[key];
if (!current || !current.value.trim()) return d;
startValue = current.value;
return { ...d, [key]: { ...current, saving: true, error: null } };
});
if (!startValue.trim()) return;
try {
await createSecret("global", key, startValue);
setDrafts((d) => ({
...d,
[key]: { ...d[key], value: "", saving: false, error: null },
}));
// Let the parent refresh configuredKeys so the strike-through
// updates and canProceed recomputes.
onSecretSaved();
} catch (e) {
setDrafts((d) => ({
...d,
[key]: {
...d[key],
saving: false,
error: e instanceof Error ? e.message : "Save failed",
},
}));
}
},
[onSecretSaved],
);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="org-preflight-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onClick={onCancel}
>
<div
className="w-[560px] max-h-[85vh] overflow-auto rounded-xl bg-zinc-900 border border-zinc-700 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<header className="px-5 py-4 border-b border-zinc-800">
<h2 id="org-preflight-title" className="text-sm font-semibold text-zinc-100">
Deploy {orgName}
</h2>
<p className="mt-0.5 text-[11px] text-zinc-500">
{workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}.
Review the credentials needed before import.
</p>
</header>
<section className="p-5 space-y-5">
{requiredEnv.length > 0 && (
<EnvList
tone="required"
title="Required"
subtitle="Import is blocked until every key below is saved globally."
entries={requiredEnv}
configuredKeys={configuredKeys}
drafts={drafts}
onChange={(key, value) =>
setDrafts((d) => ({ ...d, [key]: { ...d[key], value } }))
}
onSave={saveOne}
/>
)}
{recommendedEnv.length > 0 && (
<EnvList
tone="recommended"
title="Recommended"
subtitle="Not required, but some features degrade without them. Add them now for the best experience."
entries={recommendedEnv}
configuredKeys={configuredKeys}
drafts={drafts}
onChange={(key, value) =>
setDrafts((d) => ({ ...d, [key]: { ...d[key], value } }))
}
onSave={saveOne}
/>
)}
{requiredEnv.length === 0 && recommendedEnv.length === 0 && (
<p className="text-[12px] text-zinc-400">
No additional credentials required for this template.
</p>
)}
</section>
<footer className="px-5 py-3 border-t border-zinc-800 flex items-center justify-between">
<button
type="button"
onClick={onCancel}
className="px-3 py-1.5 text-[11px] rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300"
>
Cancel
</button>
<div className="flex items-center gap-2">
{missingRecommended.length > 0 && canProceed && (
<span className="text-[10px] text-amber-400/90">
{missingRecommended.length} recommended key
{missingRecommended.length === 1 ? "" : "s"} still unset
</span>
)}
<button
type="button"
onClick={onProceed}
disabled={!canProceed}
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-blue-600 hover:bg-blue-500 text-white disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed"
>
Import
</button>
</div>
</footer>
</div>
</div>
);
}
interface EnvListProps {
tone: "required" | "recommended";
title: string;
subtitle: string;
entries: string[];
configuredKeys: Set<string>;
drafts: Record<string, DraftEntry>;
onChange: (key: string, value: string) => void;
onSave: (key: string) => void;
}
function EnvList({
tone,
title,
subtitle,
entries,
configuredKeys,
drafts,
onChange,
onSave,
}: EnvListProps) {
const accent =
tone === "required"
? "border-red-800/60 bg-red-950/20"
: "border-amber-800/50 bg-amber-950/15";
const headerColor =
tone === "required" ? "text-red-300" : "text-amber-300";
return (
<div className={`rounded-lg border ${accent} p-3`}>
<h3 className={`text-[11px] font-semibold uppercase tracking-wide ${headerColor}`}>
{title}
</h3>
<p className="mt-0.5 mb-2 text-[10px] text-zinc-400">{subtitle}</p>
<ul className="space-y-2">
{entries.map((k) => {
const configured = configuredKeys.has(k);
const d = drafts[k];
return (
<li
key={k}
className="flex items-center gap-2 rounded bg-zinc-900/70 border border-zinc-800 px-2 py-1.5"
>
<code
className={`text-[11px] font-mono flex-1 ${
configured ? "text-zinc-500 line-through" : "text-zinc-200"
}`}
>
{k}
</code>
{configured ? (
<span className="text-[10px] text-emerald-400"> set</span>
) : (
<>
<input
type="password"
aria-label={`Value for ${k}`}
placeholder="paste value"
value={d?.value ?? ""}
onChange={(e) => onChange(k, e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSave(k);
}
}}
disabled={d?.saving}
className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-[11px] text-zinc-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
<button
type="button"
onClick={() => onSave(k)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
</>
)}
{d?.error && (
<span className="text-[9px] text-red-400 basis-full pl-1">
{d.error}
</span>
)}
</li>
);
})}
</ul>
</div>
);
}

View File

@ -4,32 +4,36 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import type { WorkspaceData } from "@/store/socket";
import { checkDeploySecrets, type PreflightResult, type ModelSpec } from "@/lib/deploy-preflight";
import { MissingKeysModal } from "./MissingKeysModal";
import { type Template } from "@/lib/deploy-preflight";
import { useTemplateDeploy } from "@/hooks/useTemplateDeploy";
import { OrgImportPreflightModal } from "./OrgImportPreflightModal";
import { ConfirmDialog } from "./ConfirmDialog";
import { Spinner } from "./Spinner";
import { showToast } from "./Toaster";
import { TIER_CONFIG } from "@/lib/design-tokens";
import { listSecrets } from "@/lib/api/secrets";
interface Template {
id: string;
name: string;
description: string;
tier: number;
runtime?: string;
model: string;
models?: ModelSpec[];
/** AND-required env vars declared at runtime_config.required_env. */
required_env?: string[];
skills: string[];
skill_count: number;
}
// `Template` type and `resolveRuntime` helper now live in
// `@/lib/deploy-preflight` so EmptyState can import the same ones. Was
// redeclared here + a narrower redeclaration in EmptyState; the
// narrower one dropped `runtime`, `models`, `required_env`, which is
// exactly the data the preflight needs. See reviewer's "runtime
// fallback drift" note — single source of truth closes the drift.
export interface OrgTemplate {
dir: string;
name: string;
description: string;
workspaces: number;
/** Env vars that MUST be set as global secrets before the org can
* import. Server refuses the import with 412 if any are missing;
* the canvas preflights against /secrets/list to avoid the round
* trip. Aggregated from org-level + every workspace in the tree. */
required_env?: string[];
/** "Nice-to-have" tier. Import proceeds without them but features
* may degrade a channel's webhook posts get dropped, a fallback
* LLM isn't available, etc. Surfaced to the user as a non-blocking
* warning with an "add now" affordance. */
recommended_env?: string[];
}
/** Fetch the list of org templates from the platform. Returns [] on error
@ -91,6 +95,14 @@ export function OrgTemplatesSection() {
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Preflight modal state. `preflight` is non-null when the user
// clicked Import on an org with declared required/recommended envs
// and we're waiting for them to confirm; null otherwise (direct
// import path for orgs with zero env requirements).
const [preflight, setPreflight] = useState<{
org: OrgTemplate;
configuredKeys: Set<string>;
} | null>(null);
// Collapsed by default — org templates are multi-workspace imports
// that most new users don't reach for first. Keeping them
// expand-on-demand frees ~400 px of vertical space for the
@ -109,7 +121,25 @@ export function OrgTemplatesSection() {
loadOrgs();
}, [loadOrgs]);
const handleImport = async (org: OrgTemplate) => {
/** Fetch the set of global secret KEYS that are already configured.
* Used to strike through already-set entries in the preflight modal
* and to decide whether the import needs the modal at all. */
const loadConfiguredKeys = useCallback(async (): Promise<Set<string>> => {
try {
const secrets = await listSecrets("global");
return new Set(secrets.map((s) => s.name));
} catch {
// Secrets endpoint unreachable → assume nothing configured.
// The server will refuse the import with 412 and the user
// retries; safer than letting the import fly blind.
return new Set();
}
}, []);
/** Actually run the import. Split out so both the "no preflight
* needed" fast path and the "preflight modal approved" path can
* share the fetch + hydrate + toast sequence. */
const doImport = useCallback(async (org: OrgTemplate) => {
setImporting(org.dir);
setError(null);
try {
@ -149,7 +179,45 @@ export function OrgTemplatesSection() {
} finally {
setImporting(null);
}
};
}, []);
/** Entry point for the Import button. Two paths:
*
* 1. No env declared by the template (required_env + recommended_env
* both empty) fire doImport directly. Matches the pre-preflight
* behaviour for existing templates.
*
* 2. Any env declared load the configured-keys set and open the
* preflight modal. doImport runs only when the user clicks
* Import inside the modal, which is gated to "required envs all
* configured" by the modal itself. */
const handleImport = useCallback(async (org: OrgTemplate) => {
const hasEnvDeclarations =
(org.required_env && org.required_env.length > 0) ||
(org.recommended_env && org.recommended_env.length > 0);
if (!hasEnvDeclarations) {
void doImport(org);
return;
}
// Flip the button to its "Importing…" state while the secrets
// lookup runs — on a tenant with 500+ global secrets the round
// trip can be > 200 ms and the user otherwise gets zero visual
// feedback after clicking. Cleared on modal close / error.
setImporting(org.dir);
try {
const configuredKeys = await loadConfiguredKeys();
setPreflight({ org, configuredKeys });
} finally {
setImporting(null);
}
}, [doImport, loadConfiguredKeys]);
/** Called by the preflight modal after a successful key save so the
* strike-through re-renders and canProceed recomputes. */
const refreshConfiguredKeys = useCallback(async () => {
const keys = await loadConfiguredKeys();
setPreflight((prev) => (prev ? { ...prev, configuredKeys: keys } : prev));
}, [loadConfiguredKeys]);
return (
<div className="space-y-2" data-testid="org-templates-section">
@ -238,6 +306,24 @@ export function OrgTemplatesSection() {
})}
</div>
)}
{preflight && (
<OrgImportPreflightModal
open
orgName={preflight.org.name || preflight.org.dir}
workspaceCount={preflight.org.workspaces}
requiredEnv={preflight.org.required_env ?? []}
recommendedEnv={preflight.org.recommended_env ?? []}
configuredKeys={preflight.configuredKeys}
onSecretSaved={refreshConfiguredKeys}
onProceed={() => {
const org = preflight.org;
setPreflight(null);
void doImport(org);
}}
onCancel={() => setPreflight(null)}
/>
)}
</div>
);
}
@ -335,14 +421,6 @@ export function TemplatePalette() {
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Missing keys modal state
const [missingKeysInfo, setMissingKeysInfo] = useState<{
template: Template;
preflight: PreflightResult;
} | null>(null);
const loadTemplates = useCallback(async () => {
setLoading(true);
@ -360,65 +438,15 @@ export function TemplatePalette() {
if (open) loadTemplates();
}, [open, loadTemplates]);
/** Resolve runtime from template ID (e.g., "langgraph", "claude-code-default" → "claude-code") */
const resolveRuntime = (templateId: string): string => {
const runtimeMap: Record<string, string> = {
langgraph: "langgraph",
"claude-code-default": "claude-code",
openclaw: "openclaw",
deepagents: "deepagents",
crewai: "crewai",
autogen: "autogen",
};
return runtimeMap[templateId] ?? templateId.replace(/-default$/, "");
};
/** Actually execute the deploy API call */
const executeDeploy = useCallback(async (template: Template) => {
setCreating(template.id);
setError(null);
try {
await api.post("/workspaces", {
name: template.name,
template: template.id,
tier: template.tier,
canvas: {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100,
},
});
setCreating(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to deploy");
setCreating(null);
}
}, []);
/** Pre-deploy check: validate secrets before deploying */
const handleDeploy = async (template: Template) => {
setCreating(template.id);
setError(null);
// Prefer the runtime the Go /templates endpoint returned verbatim —
// resolveRuntime() is a legacy id→runtime fallback for installs whose
// template summary predates the `runtime` field.
const runtime = template.runtime ?? resolveRuntime(template.id);
const preflight = await checkDeploySecrets({
runtime,
models: template.models,
required_env: template.required_env,
});
if (!preflight.ok) {
// Missing keys — show the modal instead of deploying
setMissingKeysInfo({ template, preflight });
setCreating(null);
return;
}
// All keys present — deploy directly
await executeDeploy(template);
};
// Preflight + POST + modal wiring moved into useTemplateDeploy so
// this component and EmptyState use one implementation. The sidebar
// uses the hook's default random canvas placement (no override) —
// an already-populated canvas shouldn't have new deploys stacking on
// a single fixed point. No post-deploy side effect either: the
// palette is operator-triggered, so auto-selecting would yank
// focus off whatever the user was already looking at.
const { deploy: handleDeploy, deploying: creating, error, modal } =
useTemplateDeploy();
return (
<>
@ -442,21 +470,9 @@ export function TemplatePalette() {
</svg>
</button>
{/* Missing Keys Modal */}
<MissingKeysModal
open={!!missingKeysInfo}
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
providers={missingKeysInfo?.preflight.providers ?? []}
runtime={missingKeysInfo?.preflight.runtime ?? ""}
onKeysAdded={() => {
if (missingKeysInfo) {
const template = missingKeysInfo.template;
setMissingKeysInfo(null);
executeDeploy(template);
}
}}
onCancel={() => setMissingKeysInfo(null)}
/>
{/* Missing-keys modal rendered by the shared hook. Same
instance shape used by EmptyState. */}
{modal}
{/* Sidebar */}
{open && (
@ -499,7 +515,7 @@ export function TemplatePalette() {
<button
type="button"
key={t.id}
onClick={() => handleDeploy(t)}
onClick={() => void handleDeploy(t)}
disabled={isDeploying}
className="w-full text-left bg-zinc-800/40 hover:bg-zinc-800/70 border border-zinc-700/40 hover:border-zinc-600/50 rounded-xl p-3 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-800/40 disabled:hover:border-zinc-700/40 group focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
>

View File

@ -31,11 +31,16 @@ export function useCanvasViewport() {
// render so we can detect the boundary when the last one finishes
// and auto-fit the viewport around the whole tree.
const hadProvisioningRef = useRef(false);
// Respect-user-pan gate for the deploy-time auto-fit: whenever the
// user moves the canvas (onMoveEnd stamps userPannedAtRef), we
// compare against the last auto-fit timestamp; if the user moved
// AFTER the last auto-fit, the auto-fit handler bails out for the
// rest of this deploy cycle.
// Respect-user-pan gate for the deploy-time auto-fit. Earlier
// revisions tried to detect user pans via `onMoveEnd`, but React
// Flow v12 fires that callback with a truthy event at the END of
// a programmatic fitView animation — so the first auto-fit we
// triggered would immediately look like a user pan and block
// every subsequent fit for the rest of the deploy, leaving the
// viewport stuck wherever the first fit landed. Now we stamp
// this ref ONLY on wheel / pointerdown / touchstart on the
// React Flow pane itself (see the effect below), which are
// unambiguous user-gesture signals.
const userPannedAtRef = useRef<number | null>(null);
const lastAutoFitAtRef = useRef(0);
@ -47,6 +52,35 @@ export function useCanvasViewport() {
};
}, []);
// User-gesture listeners for the respect-user-pan gate. Listens on
// `document` with capture phase and filters to events whose target
// lies inside the React Flow pane — this avoids a mount-order race
// (`.react-flow__pane` may not exist when the hook first runs if
// RF is behind a Suspense boundary) AND keeps clicks on the
// toolbar / modals / side panel from stamping user-pan-intent.
// Capture phase runs before target-phase `stopPropagation` so a
// handler elsewhere can't swallow the signal. `pointerdown` covers
// mouse + touch + pen on every modern browser — no separate
// `touchstart` listener needed (would double-stamp on mobile).
useEffect(() => {
if (typeof window === "undefined") return;
const stamp = (e: Event) => {
const target = e.target as HTMLElement | null;
if (!target?.closest?.(".react-flow__pane")) return;
userPannedAtRef.current = Date.now();
};
const opts: AddEventListenerOptions = { passive: true, capture: true };
const targets: Array<keyof DocumentEventMap> = ["wheel", "pointerdown"];
for (const ev of targets) {
document.addEventListener(ev, stamp, opts);
}
return () => {
for (const ev of targets) {
document.removeEventListener(ev, stamp, opts);
}
};
}, []);
// Auto-fit the viewport once all workspaces finish provisioning. Org
// imports land dozens of new nodes off-screen; without a follow-up
// fit, the user has to manually pan + zoom to find what they just
@ -105,6 +139,11 @@ export function useCanvasViewport() {
// fitView zooms to that smaller-than-real rectangle.
autoFitTimerRef.current = setTimeout(() => {
fitView({
// Deliberately SLOWER than the in-flight tracking fits
// (400ms). The asymmetry reads as "settling" on the
// finished org rather than "tracking" another arrival,
// which is the intended UX for the "deploy done" moment.
// Don't normalize these two durations to the same value.
duration: 1200,
// Match the deploy-time fit padding (0.45) so end-state
// and in-flight state use the same framing — otherwise
@ -184,7 +223,11 @@ export function useCanvasViewport() {
if (subtree.length === 0) return;
fitView({
nodes: subtree.map((id) => ({ id })),
duration: 600,
// Short animation — server paces children ~2s apart, so a
// 400ms fit animation reads as "smoothly tracked" rather
// than "constantly lurching". Longer durations (the earlier
// 600ms) start to overlap if the user re-triggers deploys.
duration: 400,
// Generous padding so the right-hand Communications panel,
// bottom-left Legend, and bottom-right "New Workspace"
// button don't cover the outer cards. React Flow padding
@ -204,9 +247,12 @@ export function useCanvasViewport() {
};
const handler = (e: Event) => {
const { rootId } = (e as CustomEvent<{ rootId: string }>).detail;
// Keep the most recently-requested root — if the user triggers
// imports on two different orgs back-to-back, the later one
// wins the viewport, which matches user intent.
// Keep the most recently-requested root. Back-to-back imports
// on two different orgs (rare — user would have to click
// Import twice within 500ms) "later wins" the viewport rather
// than ping-ponging between them. If this becomes a real
// pattern we'd flush the pending fit synchronously when
// `rootId` changes, rather than resetting the timer.
pendingFitRootRef.current = rootId;
clearTimeout(autoFitTimerRef.current);
autoFitTimerRef.current = setTimeout(runFit, 500);
@ -251,16 +297,12 @@ export function useCanvasViewport() {
}, [fitBounds]);
const onMoveEnd = useCallback(
(event: unknown, vp: { x: number; y: number; zoom: number }) => {
// Stamp user-pan timestamp only when the move was actually
// initiated by the user (mouse / trackpad / keyboard). React
// Flow also fires onMoveEnd for programmatic fitView() calls
// — `event` is null in that case, which would otherwise
// defeat the respect-user-pan gate by making every auto-fit
// look like a user move.
if (event !== null) {
userPannedAtRef.current = Date.now();
}
(_event: unknown, vp: { x: number; y: number; zoom: number }) => {
// User-pan detection moved to the wheel/pointerdown listener
// above — onMoveEnd fires for programmatic fitView too, which
// made this callback an unreliable source for user-intent
// tracking. This now only handles the debounced viewport
// save so a reload lands the user back where they were.
clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
saveViewport(vp.x, vp.y, vp.zoom);

View File

@ -0,0 +1,170 @@
"use client";
import { useCallback, useState, type ReactNode } from "react";
import { api } from "@/lib/api";
import {
checkDeploySecrets,
resolveRuntime,
type PreflightResult,
type Template,
} from "@/lib/deploy-preflight";
import { MissingKeysModal } from "@/components/MissingKeysModal";
/**
* useTemplateDeploy shared preflight + POST + modal wiring for
* every surface that deploys a workspace from a template.
*
* Owns: `checkDeploySecrets` call, `MissingKeysModal` render, the
* `POST /workspaces` that follows, and per-template `deploying`
* state. Returns `modal` as a `ReactNode` ready to place inline.
*
* Why a hook rather than two copies: the runtime-fallback table
* (`resolveRuntime`) and the preflight wiring were previously
* copy-pasted between TemplatePalette and EmptyState. When the
* copies drifted (palette had the full id-to-runtime map,
* empty-state had only the `-default` strip), the two surfaces
* could silently disagree on future templates that need a
* non-identity mapping. Single owner closes the drift surface.
*/
export interface UseTemplateDeployOptions {
/** Compute canvas coords for the new workspace. Called once per
* successful deploy. Defaults to random coords in the [100, 500] ×
* [100, 400] band, matching the sidebar palette's historical
* placement. Override for surfaces that want deterministic
* placement (e.g. EmptyState's first-deploy "center-ish" target). */
canvasCoords?: () => { x: number; y: number };
/** Optional post-deploy side effect passed the id of the new
* workspace. EmptyState uses this to auto-select the node and
* flip the side panel to Chat so a fresh tenant sees something
* useful. */
onDeployed?: (workspaceId: string) => void;
}
/** Paired template + preflight result carried through the "user
* clicked deploy modal opens keys saved retry" loop. Named
* so the `useState` generic and any future signature change have
* a single place to track. */
interface MissingKeysInfo {
template: Template;
preflight: PreflightResult;
}
export interface UseTemplateDeployResult {
/** Template id currently being deployed (incl. the preflight
* network call), or null when idle. Callers pass this to disable
* the relevant button and show a spinner. */
deploying: string | null;
/** Last deploy error message, or null. Cleared on next `deploy`
* call. */
error: string | null;
/** Kick off a deploy. Opens the missing-keys modal if preflight
* returns not-ok; otherwise fires POST /workspaces directly. */
deploy: (template: Template) => Promise<void>;
/** The missing-keys modal, ready to place inline. Always non-null
* (the underlying component self-gates on `open`), so the caller
* can drop `{modal}` anywhere without conditionals. */
modal: ReactNode;
}
export function useTemplateDeploy(
options: UseTemplateDeployOptions = {},
): UseTemplateDeployResult {
const [deploying, setDeploying] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [missingKeysInfo, setMissingKeysInfo] = useState<MissingKeysInfo | null>(null);
const { canvasCoords, onDeployed } = options;
/** Actually execute the POST /workspaces call. Split from `deploy`
* so the "modal → keys added → retry" path can reuse it without
* re-running preflight (the user just proved the keys are now set). */
const executeDeploy = useCallback(
async (template: Template) => {
setDeploying(template.id);
setError(null);
try {
const coords = canvasCoords
? canvasCoords()
: {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100,
};
const ws = await api.post<{ id: string }>("/workspaces", {
name: template.name,
template: template.id,
tier: template.tier,
canvas: coords,
});
onDeployed?.(ws.id);
} catch (e) {
setError(e instanceof Error ? e.message : "Deploy failed");
} finally {
setDeploying(null);
}
},
[canvasCoords, onDeployed],
);
const deploy = useCallback(
async (template: Template) => {
setDeploying(template.id);
setError(null);
let preflight: PreflightResult;
try {
const runtime = template.runtime ?? resolveRuntime(template.id);
preflight = await checkDeploySecrets({
runtime,
models: template.models,
required_env: template.required_env,
});
} catch (e) {
// Preflight network failure used to strand `deploying` — the
// button stayed disabled forever because the throw bypassed
// the setDeploying(null) in the non-ok branch below. Any
// future refactor that drops this try block will regress the
// same way; keep it narrow around just the preflight call
// so a successful preflight still lets executeDeploy own
// its own error path.
setError(e instanceof Error ? e.message : "Preflight check failed");
setDeploying(null);
return;
}
if (!preflight.ok) {
setMissingKeysInfo({ template, preflight });
setDeploying(null);
return;
}
await executeDeploy(template);
},
[executeDeploy],
);
// No useCallback here — consumers call this on every render anyway
// (it's placed inline in JSX), and useCallback's deps would
// invalidate on every state change, making the memoisation a wash.
// Plain ReactNode is simpler and equally performant.
const modal: ReactNode = (
<MissingKeysModal
open={!!missingKeysInfo}
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
providers={missingKeysInfo?.preflight.providers ?? []}
runtime={missingKeysInfo?.preflight.runtime ?? ""}
onKeysAdded={() => {
if (missingKeysInfo) {
const template = missingKeysInfo.template;
setMissingKeysInfo(null);
// Intentional fire-and-forget — executeDeploy manages
// its own error state via setError.
void executeDeploy(template);
}
}}
onCancel={() => setMissingKeysInfo(null)}
/>
);
return { deploying, error, deploy, modal };
}

View File

@ -33,6 +33,46 @@ export interface TemplateLike {
required_env?: string[];
}
/** Full /templates response shape shared by TemplatePalette (sidebar)
* and EmptyState (welcome grid). Was previously re-declared in each
* with subtly different fields EmptyState's narrower shape silently
* dropped `runtime`, `models`, and `required_env`, so the preflight
* couldn't see provider alternatives the template declared. Keep this
* the single source of truth. */
export interface Template extends TemplateLike {
id: string;
name: string;
description: string;
tier: number;
model: string;
skills: string[];
skill_count: number;
}
/** Map from a template id to the runtime name the per-workspace
* preflight expects. Used only when the server's `/templates`
* response predates the `runtime` field on the summary (legacy
* installs) modern responses carry it verbatim. Strip `-default`
* for the claude-code template and identity-map everything else
* that matches our current runtime registry.
*
* Lives in the preflight module (not TemplatePalette) so EmptyState
* uses the SAME fallback table. A previous duplication in both call
* sites left EmptyState with only the `-default` suffix strip, which
* would silently disagree with TemplatePalette on templates whose
* id needs a non-identity mapping. */
export function resolveRuntime(templateId: string): string {
const runtimeMap: Record<string, string> = {
langgraph: "langgraph",
"claude-code-default": "claude-code",
openclaw: "openclaw",
deepagents: "deepagents",
crewai: "crewai",
autogen: "autogen",
};
return runtimeMap[templateId] ?? templateId.replace(/-default$/, "");
}
export interface SecretEntry {
key: string;
has_value: boolean;

View File

@ -288,10 +288,17 @@ export function handleCanvasEvent(
// which aborts the fit when the user moved after the last
// auto-fit). Event name matches the existing handler in
// useCanvasViewport that knows how to compute subtree bounds.
if (parentIdRaw && typeof window !== "undefined") {
//
// Fire for roots too (not just children) so the canvas
// centers on the just-landed root immediately instead of
// waiting for the first child to arrive ~2s later. The
// viewport hook walks UP to find the true root, so passing
// the node's own id when there's no parent is equivalent
// to passing the root.
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("molecule:fit-deploying-org", {
detail: { rootId: parentIdRaw },
detail: { rootId: parentIdRaw ?? msg.workspace_id },
}),
);
}

View File

@ -189,6 +189,20 @@ type OrgTemplate struct {
// GlobalMemories is a list of org-wide memories seeded as GLOBAL scope
// on the first root workspace (PM) during org import. Issue #1050.
GlobalMemories []models.MemorySeed `yaml:"global_memories" json:"global_memories"`
// RequiredEnv is the union of env vars WHICH MUST be set globally (or
// on every workspace in the subtree that needs them) before import
// will succeed. Declared at the org level for shared creds, also
// extensible per-workspace via OrgWorkspace.RequiredEnv for team-
// scoped credentials (e.g. LEGAL_VAULT_TOKEN only matters if the Legal
// subtree is part of this template). The canvas preflights both.
RequiredEnv []string `yaml:"required_env" json:"required_env"`
// RecommendedEnv is the "nice-to-have" tier — import still succeeds
// without them, but features degrade. The canvas shows them as a
// yellow warning ("add now for best experience") rather than a
// blocking red. Example: SLACK_WEBHOOK_URL for a team whose agents
// occasionally post status updates, or ANTHROPIC_API_KEY when an
// agent might fall back to claude from its primary provider.
RecommendedEnv []string `yaml:"recommended_env" json:"recommended_env"`
}
type OrgDefaults struct {
@ -295,7 +309,16 @@ type OrgWorkspace struct {
X float64 `yaml:"x" json:"x"`
Y float64 `yaml:"y" json:"y"`
} `yaml:"canvas" json:"canvas"`
Children []OrgWorkspace `yaml:"children" json:"children"`
// RequiredEnv / RecommendedEnv declared at the workspace level
// narrow down what a specific team needs beyond the org-wide union.
// When GET /org/templates walks the tree, these flow up into
// OrgTemplate.RequiredEnv / RecommendedEnv. A workspace's subtree
// inherits: a parent declaring ANTHROPIC_API_KEY as required
// means every descendant considers it required too (no override
// needed at each leaf).
RequiredEnv []string `yaml:"required_env" json:"required_env"`
RecommendedEnv []string `yaml:"recommended_env" json:"recommended_env"`
Children []OrgWorkspace `yaml:"children" json:"children"`
}
// ListTemplates handles GET /org/templates — lists available org templates.
@ -354,11 +377,18 @@ func (h *OrgHandler) ListTemplates(c *gin.Context) {
continue
}
count := countWorkspaces(tmpl.Workspaces)
// Walk the tree to collect required + recommended env union.
// Canvas uses these to render a preflight modal BEFORE firing
// the import — saves the user from a 15-workspace import that
// dies one container at a time on missing creds.
required, recommended := collectOrgEnv(&tmpl)
templates = append(templates, map[string]interface{}{
"dir": e.Name(),
"name": tmpl.Name,
"description": tmpl.Description,
"workspaces": count,
"dir": e.Name(),
"name": tmpl.Name,
"description": tmpl.Description,
"workspaces": count,
"required_env": required,
"recommended_env": recommended,
})
}
@ -370,6 +400,13 @@ func (h *OrgHandler) Import(c *gin.Context) {
var body struct {
Dir string `json:"dir"` // org template directory name
Template OrgTemplate `json:"template"` // or inline template
// Force skips the required-env preflight. Used by tooling
// that already computed the preflight client-side and wants
// to proceed despite missing creds (usually because the
// user explicitly acknowledged the tradeoff). Default behavior
// refuses the import with a 412 and the missing-key list so
// the canvas can surface them in its preflight modal.
Force bool `json:"force"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
@ -415,6 +452,55 @@ func (h *OrgHandler) Import(c *gin.Context) {
return
}
// Required-env preflight — refuses import when any required_env is
// missing from global_secrets (unless `force: true` overrides). The
// canvas runs the same check client-side against GET /org/templates
// output and shows a modal so users set keys before clicking Import;
// this server-side check is the authoritative guard in case a caller
// bypasses the UI (CLI, API clients, etc.). 412 Precondition Failed
// carries the missing-key list so tooling can render the same
// add-key flow.
required, _ := collectOrgEnv(&tmpl)
if body.Force {
// Log the bypass so a post-incident search can find who
// imported an org with missing creds. The common audit flow
// treats log.Printf at INFO as the low-cost trail for
// explicit-override actions — keeps force as a supported
// knob but makes it investigable.
log.Printf("Org import: force=true bypass — template=%q, required_env=%v", tmpl.Name, required)
} else if len(required) > 0 {
ctx := c.Request.Context()
configured, err := loadConfiguredGlobalSecretKeys(ctx)
if err != nil {
// Fail closed. Previously this fell through and imported
// anyway, defeating the preflight for exactly the case
// it's meant to cover. A DB hiccup should look like a
// retryable 500, not a silent green light for an import
// that will fail at container-start time on every node.
log.Printf("Org import preflight: global secrets lookup failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "could not verify required environment variables; try again or pass force=true to override",
})
return
}
var missing []string
for _, k := range required {
if _, ok := configured[k]; !ok {
missing = append(missing, k)
}
}
if len(missing) > 0 {
c.JSON(http.StatusPreconditionFailed, gin.H{
"error": "missing required environment variables",
"missing_env": missing,
"required_env": required,
"template": tmpl.Name,
"suggestion": "set these as global secrets (POST /settings/secrets) or pass force=true to override",
})
return
}
}
results := []map[string]interface{}{}
var createErr error

View File

@ -10,6 +10,8 @@ import (
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
@ -540,6 +542,104 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
return nil
}
// envVarNamePattern guards template-supplied env var names against
// pathological inputs. A malicious template could ship
// required_env: ["'; DROP …"] or whitespace-only entries that would
// flow through collectOrgEnv → into the 412 response body and,
// worse, into the modal's PUT /settings/secrets input. Schema
// already has `key TEXT NOT NULL UNIQUE` and our queries are
// parameterised so SQL injection isn't the threat — the real risks
// are UI rendering weirdness (newlines, NUL bytes, zero-width chars)
// and downstream env-var semantics (POSIX requires uppercase +
// underscore + digit). A strict regex filters both classes of
// problem at a single choke point.
var envVarNamePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`)
// collectOrgEnv walks the whole template tree and returns the union of
// required_env and recommended_env declared anywhere — at the org
// level, on root workspaces, or on any nested child. Deduplicates so
// the canvas sees a clean set. An env var declared as required at ANY
// level wins over recommended (required is strictly stricter).
// Entries that fail envVarNamePattern are dropped with a log line so
// a bad template surfaces in operator logs without breaking import.
func collectOrgEnv(tmpl *OrgTemplate) (required, recommended []string) {
req := map[string]struct{}{}
rec := map[string]struct{}{}
accept := func(into map[string]struct{}, src []string, where string) {
for _, k := range src {
if !envVarNamePattern.MatchString(k) {
if k != "" {
log.Printf("collectOrgEnv: rejecting invalid env var name %q from %s (must match %s)", k, where, envVarNamePattern)
}
continue
}
into[k] = struct{}{}
}
}
accept(req, tmpl.RequiredEnv, "template root")
accept(rec, tmpl.RecommendedEnv, "template root")
var walk func([]OrgWorkspace)
walk = func(ws []OrgWorkspace) {
for _, w := range ws {
accept(req, w.RequiredEnv, "workspace "+w.Name)
accept(rec, w.RecommendedEnv, "workspace "+w.Name)
walk(w.Children)
}
}
walk(tmpl.Workspaces)
// Required wins — a key recommended at one layer and required at
// another surfaces only on the required side.
for k := range req {
delete(rec, k)
}
required = make([]string, 0, len(req))
for k := range req {
required = append(required, k)
}
recommended = make([]string, 0, len(rec))
for k := range rec {
recommended = append(recommended, k)
}
sort.Strings(required)
sort.Strings(recommended)
return required, recommended
}
// loadConfiguredGlobalSecretKeys returns the set of key names present
// in global_secrets WHERE the encrypted_value is non-empty. Filtering
// on the payload size catches the failure mode where a row was
// upserted with an empty value (historical rows predating the
// binding:"required" guard on SetGlobal, or a future direct SQL
// path that skips it) — the preflight would otherwise report the
// key as "configured" and the per-container preflight would still
// fail at start time, defeating the whole feature.
// The LIMIT is a sanity cap: at realistic tenant sizes (< 1k
// secrets) it's a no-op; at pathological sizes it stops one slow
// query from wedging org imports. A hit gets logged so operators
// can investigate.
const globalSecretsPreflightLimit = 10000
func loadConfiguredGlobalSecretKeys(ctx context.Context) (map[string]struct{}, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT key FROM global_secrets WHERE octet_length(encrypted_value) > 0 LIMIT $1`,
globalSecretsPreflightLimit)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]struct{}{}
for rows.Next() {
var k string
if scanErr := rows.Scan(&k); scanErr == nil && k != "" {
out[k] = struct{}{}
}
}
if len(out) == globalSecretsPreflightLimit {
log.Printf("loadConfiguredGlobalSecretKeys: hit LIMIT %d — org-import preflight may be incomplete", globalSecretsPreflightLimit)
}
return out, rows.Err()
}
func countWorkspaces(workspaces []OrgWorkspace) int {
count := len(workspaces)
for _, ws := range workspaces {

View File

@ -650,3 +650,145 @@ func TestOrgImport_ScheduleComputeError(t *testing.T) {
})
}
}
// ============================================================================
// Org env-preflight aggregation (collectOrgEnv)
// ============================================================================
func TestCollectOrgEnv_UnionAcrossLevels(t *testing.T) {
tmpl := &OrgTemplate{
RequiredEnv: []string{"ANTHROPIC_API_KEY"},
RecommendedEnv: []string{"SLACK_WEBHOOK_URL"},
Workspaces: []OrgWorkspace{
{
Name: "Root",
RequiredEnv: []string{"GITHUB_TOKEN"},
Children: []OrgWorkspace{
{
Name: "Leaf",
RequiredEnv: []string{"OPENROUTER_API_KEY"},
RecommendedEnv: []string{"DISCORD_WEBHOOK_URL"},
},
},
},
},
}
req, rec := collectOrgEnv(tmpl)
// Required is the union of top-level + root + leaf.
wantReq := []string{"ANTHROPIC_API_KEY", "GITHUB_TOKEN", "OPENROUTER_API_KEY"}
if !stringSlicesEqual(req, wantReq) {
t.Errorf("required mismatch: got %v, want %v", req, wantReq)
}
wantRec := []string{"DISCORD_WEBHOOK_URL", "SLACK_WEBHOOK_URL"}
if !stringSlicesEqual(rec, wantRec) {
t.Errorf("recommended mismatch: got %v, want %v", rec, wantRec)
}
}
func TestCollectOrgEnv_RequiredWinsOverRecommended(t *testing.T) {
// Same key declared at one layer as recommended and another as
// required MUST surface only on the required side — a required
// declaration is strictly stricter than a recommended one, and
// listing it in both tiers would confuse the preflight modal.
tmpl := &OrgTemplate{
RecommendedEnv: []string{"API_KEY"},
Workspaces: []OrgWorkspace{
{Name: "X", RequiredEnv: []string{"API_KEY"}},
},
}
req, rec := collectOrgEnv(tmpl)
if len(req) != 1 || req[0] != "API_KEY" {
t.Errorf("required should contain API_KEY, got %v", req)
}
for _, k := range rec {
if k == "API_KEY" {
t.Errorf("API_KEY must not appear in recommended once required elsewhere")
}
}
}
func TestCollectOrgEnv_Dedup(t *testing.T) {
// Same key declared twice at different levels should appear once.
tmpl := &OrgTemplate{
RequiredEnv: []string{"K", "K"},
Workspaces: []OrgWorkspace{
{Name: "A", RequiredEnv: []string{"K"}},
{Name: "B", RequiredEnv: []string{"K"}, Children: []OrgWorkspace{
{Name: "C", RequiredEnv: []string{"K"}},
}},
},
}
req, _ := collectOrgEnv(tmpl)
if len(req) != 1 || req[0] != "K" {
t.Errorf("dedup failed: got %v, want [K]", req)
}
}
func TestCollectOrgEnv_Empty(t *testing.T) {
tmpl := &OrgTemplate{}
req, rec := collectOrgEnv(tmpl)
if len(req) != 0 || len(rec) != 0 {
t.Errorf("empty template should produce empty slices, got req=%v rec=%v", req, rec)
}
}
// stringSlicesEqual checks ordered equality — collectOrgEnv sorts its
// output so callers can do deterministic comparisons.
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestCollectOrgEnv_RequiredWinsOnSameStruct(t *testing.T) {
// The same key declared required AND recommended on the SAME
// workspace node (rare but legal to parse) must still dedup
// correctly and end up required-only.
tmpl := &OrgTemplate{
Workspaces: []OrgWorkspace{
{
Name: "X",
RequiredEnv: []string{"API_KEY"},
RecommendedEnv: []string{"API_KEY"},
},
},
}
req, rec := collectOrgEnv(tmpl)
if len(req) != 1 || req[0] != "API_KEY" {
t.Errorf("required should contain API_KEY once, got %v", req)
}
for _, k := range rec {
if k == "API_KEY" {
t.Errorf("API_KEY must not appear in recommended when also required on same struct")
}
}
}
func TestCollectOrgEnv_RejectsInvalidNames(t *testing.T) {
// Names failing envVarNamePattern (lowercase, traversal, whitespace,
// shell metachars) must be dropped silently — the log line is not
// asserted here; the output slice assertion is enough to prove the
// filter fires.
tmpl := &OrgTemplate{
RequiredEnv: []string{
"VALID_ONE",
"lowercase_bad",
"../../etc/passwd",
"name with spaces",
"WITH-DASH",
"'; DROP TABLE users;--",
"",
"A", // single char — still valid per regex
},
}
req, _ := collectOrgEnv(tmpl)
if !stringSlicesEqual(req, []string{"A", "VALID_ONE"}) {
t.Errorf("expected only valid names, got %v", req)
}
}