feat!: scope per-app config and settings by environment
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m27s
CI / docker (push) Successful in 1m10s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m40s
SonarQube / sonarqube (push) Successful in 4m29s

BREAKING: wipe dev PostgreSQL before deploying — V1 checksum changes.
Agents must now send environmentId on registration (400 if missing).

Two tables previously keyed on app name alone caused cross-environment
data bleed: writing config for (app=X, env=dev) would overwrite the row
used by (app=X, env=prod) agents, and agent startup fetches ignored env
entirely.

- V1 schema: application_config and app_settings are now PK (app, env).
- Repositories: env-keyed finders/saves; env is the authoritative column,
  stamped on the stored JSON so the row agrees with itself.
- ApplicationConfigController.getConfig is dual-mode — AGENT role uses
  JWT env claim (agents cannot spoof env); non-agent callers provide env
  via ?environment= query param.
- AppSettingsController endpoints now require ?environment=.
- SensitiveKeysAdminController fan-out iterates (app, env) slices so each
  env gets its own merged keys.
- DiagramController ingestion stamps env on TaggedDiagram; ClickHouse
  route_diagrams INSERT + findProcessorRouteMapping are env-scoped.
- AgentRegistrationController: environmentId is required on register;
  removed all "default" fallbacks from register/refresh/heartbeat auto-heal.
- UI hooks (useApplicationConfig, useProcessorRouteMapping, useAppSettings,
  useAllAppSettings, useUpdateAppSettings) take env, wired to
  useEnvironmentStore at all call sites.
- New ConfigEnvIsolationIT covers env-isolation for both repositories.

Plan in docs/superpowers/plans/2026-04-16-environment-scoping.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-16 22:25:21 +02:00
parent c272ac6c24
commit 9b1ef51d77
33 changed files with 573 additions and 193 deletions

View File

@@ -56,11 +56,12 @@ export function useAllApplicationConfigs() {
})
}
export function useApplicationConfig(application: string | undefined) {
export function useApplicationConfig(application: string | undefined, environment: string | undefined) {
return useQuery({
queryKey: ['applicationConfig', application],
queryKey: ['applicationConfig', application, environment],
queryFn: async () => {
const res = await authFetch(`/config/${application}`)
const envParam = environment ? `?environment=${encodeURIComponent(environment)}` : ''
const res = await authFetch(`/config/${application}${envParam}`)
if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`)
const data = await res.json()
// Server returns AppConfigResponse: { config, globalSensitiveKeys, mergedSensitiveKeys }
@@ -69,7 +70,7 @@ export function useApplicationConfig(application: string | undefined) {
cfg.mergedSensitiveKeys = data.mergedSensitiveKeys ?? null
return cfg as ApplicationConfig
},
enabled: !!application,
enabled: !!application && !!environment,
})
}
@@ -100,15 +101,16 @@ export function useUpdateApplicationConfig() {
// ── Processor → Route Mapping ─────────────────────────────────────────────
export function useProcessorRouteMapping(application?: string) {
export function useProcessorRouteMapping(application?: string, environment?: string) {
return useQuery({
queryKey: ['config', application, 'processor-routes'],
queryKey: ['config', application, environment, 'processor-routes'],
queryFn: async () => {
const res = await authFetch(`/config/${application}/processor-routes`)
const res = await authFetch(
`/config/${application}/processor-routes?environment=${encodeURIComponent(environment!)}`)
if (!res.ok) throw new Error('Failed to fetch processor-route mapping')
return res.json() as Promise<Record<string, string>>
},
enabled: !!application,
enabled: !!application && !!environment,
})
}

View File

@@ -115,6 +115,7 @@ export function usePunchcard(application?: string, environment?: string) {
export interface AppSettings {
appId: string;
environment: string;
slaThresholdMs: number;
healthErrorWarn: number;
healthErrorCrit: number;
@@ -124,19 +125,22 @@ export interface AppSettings {
updatedAt: string;
}
export function useAppSettings(appId?: string) {
export function useAppSettings(appId?: string, environment?: string) {
return useQuery({
queryKey: ['app-settings', appId],
queryFn: () => fetchJson<AppSettings>(`/admin/app-settings/${appId}`),
enabled: !!appId,
queryKey: ['app-settings', appId, environment],
queryFn: () => fetchJson<AppSettings>(
`/admin/app-settings/${appId}?environment=${encodeURIComponent(environment!)}`),
enabled: !!appId && !!environment,
staleTime: 60_000,
});
}
export function useAllAppSettings() {
export function useAllAppSettings(environment?: string) {
return useQuery({
queryKey: ['app-settings', 'all'],
queryFn: () => fetchJson<AppSettings[]>('/admin/app-settings'),
queryKey: ['app-settings', 'all', environment],
queryFn: () => fetchJson<AppSettings[]>(
`/admin/app-settings?environment=${encodeURIComponent(environment!)}`),
enabled: !!environment,
staleTime: 60_000,
});
}
@@ -144,13 +148,15 @@ export function useAllAppSettings() {
export function useUpdateAppSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ appId, settings }: { appId: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
const token = useAuthStore.getState().accessToken;
const res = await fetch(`${config.apiBaseUrl}/admin/app-settings/${appId}`, {
method: 'PUT',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
mutationFn: async ({ appId, environment, settings }:
{ appId: string; environment: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
const res = await fetch(
`${config.apiBaseUrl}/admin/app-settings/${appId}?environment=${encodeURIComponent(environment)}`,
{
method: 'PUT',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (!res.ok) throw new Error('Failed to update app settings');
return res.json();
},